feat(ops): add workspace MCP deployment overlays
This commit is contained in:
parent
56b766b23b
commit
847a08da93
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const sourceRoot = resolve(here, "ops-mcp-workspace-tools");
|
||||
const overlayRoot = join(sourceRoot, "overlays");
|
||||
const release = JSON.parse(await readFile(join(sourceRoot, "release.json"), "utf8"));
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const [requestedRelease = release.release, ...extra] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || requestedRelease !== release.release) {
|
||||
throw new Error("usage: build-ops-mcp-workspace-tools-artifacts.mjs [" + release.release + "]");
|
||||
}
|
||||
|
||||
const productionRoots = {
|
||||
tasker: "/volume1/docker/nodedc-platform/tasker",
|
||||
"ops-agents": "/volume1/docker/nodedc-platform/ops-agents",
|
||||
};
|
||||
const results = [];
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
for (const [component, descriptor] of Object.entries(release.components)) {
|
||||
validateDescriptor(component, descriptor);
|
||||
const target = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".tgz");
|
||||
const predecessorPath = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".predecessor.sha256");
|
||||
const newPathsPath = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".new-paths");
|
||||
await assertFresh(target);
|
||||
await assertFresh(predecessorPath);
|
||||
await assertFresh(newPathsPath);
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-" + component + "-workspace-tools-"));
|
||||
const payload = join(stage, "payload");
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of descriptor.files) {
|
||||
const source = join(overlayRoot, component, relativePath);
|
||||
const info = await lstat(source);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error("invalid_overlay_source:" + component + ":" + relativePath);
|
||||
}
|
||||
await mkdir(dirname(join(payload, relativePath)), { recursive: true });
|
||||
await cp(source, join(payload, relativePath), { force: false });
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
"id=" + descriptor.artifactId + "\ncomponent=" + component + "\ntype=app-overlay\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), descriptor.files.join("\n") + "\n", "utf8");
|
||||
run("python3", ["-c", canonicalTarScript(), target, stage]);
|
||||
|
||||
const productionRoot = productionRoots[component];
|
||||
const predecessorLines = Object.entries(descriptor.predecessors)
|
||||
.map(([relativePath, digest]) => digest + " " + productionRoot + "/" + relativePath);
|
||||
await writeFile(predecessorPath, predecessorLines.join("\n") + "\n", "utf8");
|
||||
await writeFile(
|
||||
newPathsPath,
|
||||
descriptor.newPaths.map((relativePath) => productionRoot + "/" + relativePath).join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
results.push({
|
||||
component,
|
||||
artifactId: descriptor.artifactId,
|
||||
artifact: target,
|
||||
artifactSha256: sha(await readFile(target)),
|
||||
predecessorChecks: predecessorPath,
|
||||
predecessorChecksSha256: sha(await readFile(predecessorPath)),
|
||||
newPaths: newPathsPath,
|
||||
newPathsSha256: sha(await readFile(newPathsPath)),
|
||||
files: descriptor.files,
|
||||
});
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
schemaVersion: release.schemaVersion,
|
||||
release: release.release,
|
||||
deployOrder: ["tasker", "ops-agents"],
|
||||
runnerChanged: false,
|
||||
results,
|
||||
}, null, 2));
|
||||
|
||||
function validateDescriptor(component, descriptor) {
|
||||
if (!(component in productionRoots)) throw new Error("unsupported_component:" + component);
|
||||
if (!/^[A-Za-z0-9._-]{1,96}$/.test(descriptor.artifactId)) {
|
||||
throw new Error("invalid_artifact_id:" + component);
|
||||
}
|
||||
if (!Array.isArray(descriptor.files) || descriptor.files.length === 0) {
|
||||
throw new Error("empty_file_list:" + component);
|
||||
}
|
||||
if (new Set(descriptor.files).size !== descriptor.files.length) {
|
||||
throw new Error("duplicate_file:" + component);
|
||||
}
|
||||
for (const relativePath of descriptor.files) validateRelativePath(relativePath);
|
||||
for (const relativePath of Object.keys(descriptor.predecessors)) {
|
||||
validateRelativePath(relativePath);
|
||||
if (!descriptor.files.includes(relativePath)) {
|
||||
throw new Error("predecessor_not_in_files:" + component + ":" + relativePath);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(descriptor.predecessors[relativePath])) {
|
||||
throw new Error("invalid_predecessor_sha256:" + component + ":" + relativePath);
|
||||
}
|
||||
}
|
||||
for (const relativePath of descriptor.newPaths) {
|
||||
validateRelativePath(relativePath);
|
||||
if (!descriptor.files.includes(relativePath)) {
|
||||
throw new Error("new_path_not_in_files:" + component + ":" + relativePath);
|
||||
}
|
||||
if (relativePath in descriptor.predecessors) {
|
||||
throw new Error("new_path_has_predecessor:" + component + ":" + relativePath);
|
||||
}
|
||||
}
|
||||
const covered = new Set([...Object.keys(descriptor.predecessors), ...descriptor.newPaths]);
|
||||
if (covered.size !== descriptor.files.length) {
|
||||
throw new Error("predecessor_partition_incomplete:" + component);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRelativePath(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !value
|
||||
|| value.startsWith("/")
|
||||
|| value.split("/").some((part) => !part || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error("unsafe_relative_path:" + value);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("output_already_exists:" + path);
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) throw new Error(command + "_failed:" + (result.stderr || result.stdout));
|
||||
return result;
|
||||
}
|
||||
|
||||
function sha(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Ops MCP workspace tools release
|
||||
|
||||
This release updates two already registered deploy-runner components. It does
|
||||
not add a component and does not change the deploy runner.
|
||||
|
||||
Deploy order:
|
||||
|
||||
1. Tasker restores full card reads and adds the narrow project-create and
|
||||
attachment adapter intents. It also exposes the two new scopes in the
|
||||
existing Tasker agent settings UI.
|
||||
2. Ops Agent publishes the three MCP tools, mints independent Ops and
|
||||
Ontology credentials from one setup code, and installs two separate MCP
|
||||
servers through npm package 0.1.3.
|
||||
|
||||
The builder emits, for each artifact:
|
||||
|
||||
- a deterministic tgz;
|
||||
- a predecessor SHA-256 file for every replaced production path;
|
||||
- a new-path file for paths that must not exist before apply.
|
||||
|
||||
Operators must verify both companion files before plan. The deploy runner
|
||||
then provides the existing component backup, compose rebuild/recreate,
|
||||
healthcheck, state registration, and rollback behavior.
|
||||
|
||||
Security boundaries:
|
||||
|
||||
- no raw Tasker API;
|
||||
- no delete/archive tool;
|
||||
- project creation requires project:create, an agent-scoped token, existing
|
||||
workspace entitlement, and Tasker workspace-admin revalidation;
|
||||
- attachments accept explicit base64 bytes only, max 5 MiB, and use Tasker's
|
||||
existing FileAsset/S3 quota and dedup path;
|
||||
- Ops and Ontology tokens have distinct persisted purposes and cannot be used
|
||||
against the other endpoint;
|
||||
- Ontology stays a separate read-only MCP.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4100
|
||||
HOST_BIND=172.22.0.222
|
||||
HOST_PORT=18190
|
||||
LOG_LEVEL=info
|
||||
|
||||
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
|
||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
|
||||
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
|
||||
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
|
||||
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
|
||||
NODEDC_ONTOLOGY_CORE_URL=http://172.22.0.222:18104
|
||||
# Optional. Defaults to NODEDC_INTERNAL_ACCESS_TOKEN.
|
||||
# NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN=replace-with-platform-internal-access-token
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token
|
||||
|
||||
POSTGRES_DB=nodedc_agent_gateway
|
||||
POSTGRES_USER=nodedc_agent_gateway
|
||||
POSTGRES_PASSWORD=replace-with-strong-postgres-password
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
# NODE.DC Tasker Codex API
|
||||
|
||||
Отдельный модуль NODE.DC для безопасного подключения локальных Codex/AI-агентов к Tasker / Operational Core.
|
||||
|
||||
Модуль не является частью Plane fork и не должен становиться backend-расширением Tasker. Его роль — agent gateway: выдача ограниченных agent credentials, проверка прав, MCP/REST-контракт для внешних агентов, аудит и маршрутизация разрешённых операций в Tasker через узкий internal adapter.
|
||||
|
||||
## Documents
|
||||
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [UX flow](docs/UX_FLOW.md)
|
||||
- [MCP tools contract](docs/MCP_TOOLS_CONTRACT.md)
|
||||
- [Tasker API audit](docs/TASKER_API_AUDIT.md)
|
||||
- [Threat model](docs/THREAT_MODEL.md)
|
||||
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
||||
|
||||
## Core rule
|
||||
|
||||
External Codex instances never receive Plane session cookies, raw Tasker API tokens, database access, or a generic HTTP proxy into Tasker.
|
||||
|
||||
All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are recorded as actions of a dedicated agent identity owned by a human platform user.
|
||||
|
||||
## Current implementation
|
||||
|
||||
- Fastify service with `/healthz`, `/readyz`, and capability metadata.
|
||||
- Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys.
|
||||
- Internal REST endpoints for agent profile, grant, and token lifecycle.
|
||||
- Lifecycle endpoints are protected by `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`; public agent traffic uses only issued agent tokens.
|
||||
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
|
||||
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
|
||||
- Agent setup endpoint returns an MCP config template and AGENTS.md instruction pack without echoing the raw token.
|
||||
- Internal AI Workspace entitlement endpoint issues short-lived run tokens and returns a dynamic Ops MCP server profile for the Platform run profile.
|
||||
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
|
||||
- MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
|
||||
- A separate `/ontology-mcp` endpoint exposes the read-only Ontology MCP with a
|
||||
different token purpose; the npm installer configures both servers independently.
|
||||
- The Ops tool surface includes full issue reading (comments and attachment
|
||||
metadata), workspace-gated project creation, and bounded base64 attachment upload.
|
||||
- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes.
|
||||
- Agent Gateway writes audit events for executed, replayed, and failed write-tool calls.
|
||||
- Tool execution calls the real Tasker internal adapter; no fake Tasker storage exists in Gateway.
|
||||
- Local real e2e smoke verifies Gateway -> MCP -> Tasker runtime writes.
|
||||
|
||||
## Local development
|
||||
|
||||
Product-like Docker run:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --env-file .env -f docker-compose.local.yml up -d --build
|
||||
curl http://127.0.0.1:4100/readyz
|
||||
```
|
||||
|
||||
The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` internal endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100` in local development). `HOST_BIND` and `HOST_PORT` control the host-side port for reverse proxy deployments; Synology should use `docker-compose.synology.yml` with `172.22.0.222:18190:4100` because `18090` is reserved for Tasker. The user-facing setup packet uses `NODEDC_AGENT_GATEWAY_PUBLIC_URL`; product defaults point to `https://ops-agents.nodedc.ru`, not localhost.
|
||||
|
||||
Synology deployment notes live in `docs/SYNOLOGY_DEPLOY.md`.
|
||||
|
||||
Direct Node.js development:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose --env-file .env -f docker-compose.local.yml up -d postgres
|
||||
npm install
|
||||
npm run migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
npm run smoke:mcp
|
||||
npm run smoke:gateway
|
||||
curl http://127.0.0.1:4100/readyz
|
||||
curl http://127.0.0.1:4100/api/v1/meta/capabilities
|
||||
```
|
||||
|
||||
Create a local test agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4100/api/v1/agents \
|
||||
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"owner_user_id":"local-user","owner_email":"local@example.test","display_name":"Local Codex"}'
|
||||
```
|
||||
|
||||
Create a token and inspect effective agent session:
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -sS -X POST http://127.0.0.1:4100/api/v1/agents/<agent-id>/tokens \
|
||||
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"Local Codex token"}' | jq -r .token)
|
||||
|
||||
curl http://127.0.0.1:4100/api/v1/agent-session \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Tasker UI should use the owner-scoped internal lifecycle API through its backend proxy:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4100/api/internal/v1/owners/<owner-user-id>/agents \
|
||||
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN"
|
||||
```
|
||||
|
||||
The internal API verifies the owner path against the stored agent owner before returning agent detail, grants, tokens, setup packets, or revoke responses.
|
||||
|
||||
Issue an AI Workspace entitlement for a Codex run:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/entitlements \
|
||||
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"schemaVersion":"ai-workspace.entitlement-request.v1",
|
||||
"appId":"ops",
|
||||
"owner":{"userId":"local-user","email":"local@example.test"},
|
||||
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
|
||||
}'
|
||||
```
|
||||
|
||||
The response uses the AI Workspace adapter contract and returns `appGrants.ops.mcpServers[]` with a bearer token narrowed to the matching active agent grant(s) for the requested Ops workspace/project. The token expires after `NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS` seconds, is marked as `grant_scope=token`, and the response reports `grantMode=token-scoped-run-grants`. Existing long-lived agent tokens keep the legacy `grant_scope=agent` behavior for manually installed local Codex clients.
|
||||
|
||||
Run the same check without issuing a token:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/preflight \
|
||||
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"schemaVersion":"ai-workspace.entitlement-request.v1",
|
||||
"appId":"ops",
|
||||
"owner":{"userId":"local-user","email":"local@example.test"},
|
||||
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
|
||||
}'
|
||||
```
|
||||
|
||||
Preflight returns redacted diagnostics: selected agent, matching grants, scopes, `grantMode`, token TTL, and MCP endpoint metadata. It does not create or return a bearer token.
|
||||
|
||||
Generate a local Codex setup packet:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4100/api/v1/agent-session/setup \
|
||||
-H "Authorization: Bearer $TOKEN" | jq -r .setup.agents_md
|
||||
```
|
||||
|
||||
The setup packet includes:
|
||||
|
||||
- MCP endpoint and header template with `<agent-token>` placeholder;
|
||||
- available tools for the current grants;
|
||||
- AGENTS.md rules for Tasker card writing, no-delete boundary, and required `idempotency_key`;
|
||||
- no raw token echo.
|
||||
|
||||
Call MCP tools through the JSON-RPC endpoint:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:4100/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'MCP-Protocol-Version: 2025-06-18' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{}}' | jq
|
||||
```
|
||||
|
||||
## Local testing strategy
|
||||
|
||||
No fake Tasker storage is embedded into Agent Gateway.
|
||||
|
||||
Local verification is split into product layers:
|
||||
|
||||
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, idempotency requirement, and the Tasker boundary.
|
||||
2. `npm run smoke:gateway` verifies the REST compatibility boundary and idempotency requirement over the same tool execution path.
|
||||
3. `npm run smoke:e2e` verifies REST tool endpoints and idempotent replay against the real local Tasker runtime.
|
||||
4. `npm run smoke:mcp:e2e` verifies MCP tool calls and idempotent replay against the real local Tasker runtime.
|
||||
5. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
|
||||
|
||||
Example real localhost MCP e2e:
|
||||
|
||||
```bash
|
||||
TOKEN=$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for line in Path('/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env').read_text().splitlines():
|
||||
if line.startswith('NODEDC_INTERNAL_ACCESS_TOKEN=') or line.startswith('PLANE_NODEDC_ACCESS_TOKEN='):
|
||||
value = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if value:
|
||||
print(value)
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway' \
|
||||
NODE_ENV=development \
|
||||
LOG_LEVEL=silent \
|
||||
NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \
|
||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN='replace-with-gateway-internal-token' \
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \
|
||||
SMOKE_WORKSPACE_SLUG='nodedc' \
|
||||
SMOKE_PROJECT_ID='<project-id>' \
|
||||
npm run smoke:mcp:e2e
|
||||
```
|
||||
|
||||
Current Tasker internal adapter contract expected by Gateway:
|
||||
|
||||
- `POST /api/internal/nodedc/agent/projects/resolve`
|
||||
- `POST /api/internal/nodedc/agent/projects`
|
||||
- `GET /api/internal/nodedc/agent/projects/:projectId/context`
|
||||
- `GET /api/internal/nodedc/agent/issues?project_id=...`
|
||||
- `POST /api/internal/nodedc/agent/issues`
|
||||
- `GET /api/internal/nodedc/agent/issues/:issueId`
|
||||
- `PATCH /api/internal/nodedc/agent/issues/:issueId`
|
||||
- `POST /api/internal/nodedc/agent/issues/:issueId/attachments`
|
||||
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
|
||||
- `POST /api/internal/nodedc/agent/issues/:issueId/comments`
|
||||
- `PUT /api/internal/nodedc/agent/issues/:issueId/labels`
|
||||
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-nodedc_agent_gateway}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-nodedc_agent_gateway}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}
|
||||
volumes:
|
||||
- agent-gateway-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
agent-gateway:
|
||||
build:
|
||||
context: .
|
||||
init: true
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
HOST: 0.0.0.0
|
||||
PORT: ${PORT:-4100}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway}
|
||||
NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
|
||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}
|
||||
NODEDC_OPS_CODEX_INSTALL_CHANNEL: ${NODEDC_OPS_CODEX_INSTALL_CHANNEL:-registry}
|
||||
NODEDC_OPS_CODEX_NPM_SPEC: ${NODEDC_OPS_CODEX_NPM_SPEC:-@nodedc/ops-codex}
|
||||
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200}
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
|
||||
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
|
||||
NODEDC_ENGINE_INTERNAL_URL: ${NODEDC_ENGINE_INTERNAL_URL:-http://172.22.0.222:3001}
|
||||
NODEDC_ONTOLOGY_CORE_URL: ${NODEDC_ONTOLOGY_CORE_URL:-http://172.22.0.222:18104}
|
||||
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: ${NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN:-}
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${HOST_BIND:-172.22.0.222}:${HOST_PORT:-18190}:${PORT:-4100}"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || 4100) + '/readyz').then(async r => { const b = await r.json(); process.exit(r.ok && b.ok ? 0 : 1); }).catch(() => process.exit(1))\"",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
agent-gateway-postgres:
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
# Architecture: NODE.DC Tasker Codex API
|
||||
|
||||
Last updated: 2026-07-18.
|
||||
|
||||
## Goal
|
||||
|
||||
Give selected NODE.DC users a controlled way to connect their local Codex or another AI coding agent to Tasker so the agent can maintain project work items according to NODE.DC task-card rules.
|
||||
|
||||
The agent can create projects inside an explicitly granted workspace, create and
|
||||
update cards, move them through states, write structured task documentation,
|
||||
update checkers, add comments and bounded file attachments, assign existing
|
||||
workspace users to project cards when permitted, and apply labels. It cannot
|
||||
delete cards or projects, manage workspace settings, bypass workspace/project
|
||||
grants, or call arbitrary Tasker endpoints.
|
||||
|
||||
## Product boundary
|
||||
|
||||
This module is a standalone service:
|
||||
|
||||
```text
|
||||
Launcher / Hub
|
||||
owns platform entitlement: can this user/client use Codex agents?
|
||||
|
||||
Tasker / Operational Core
|
||||
owns workspaces, projects, issues, labels, states, members, comments
|
||||
|
||||
Agent Gateway
|
||||
owns agents, tokens, scopes, grants, MCP tools, audit, rate limits
|
||||
```
|
||||
|
||||
The service is deployed as its own Docker container and has its own repository and database. It may call Launcher and Tasker through internal APIs, but it does not read or write either database directly.
|
||||
|
||||
## Runtime shape
|
||||
|
||||
```text
|
||||
Local Codex
|
||||
-> independent Ops and read-only Ontology MCP servers over HTTPS
|
||||
-> Agent Gateway public endpoints (/mcp and /ontology-mcp)
|
||||
-> scope and grant checks
|
||||
-> Tasker internal adapter
|
||||
-> Tasker domain models
|
||||
```
|
||||
|
||||
```text
|
||||
User UI
|
||||
-> Launcher entitlement
|
||||
-> Tasker workspace feature settings
|
||||
-> Agent Gateway agent/token management
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Launcher integration
|
||||
|
||||
Launcher decides whether a user can use the module at all.
|
||||
|
||||
Required Launcher concepts:
|
||||
|
||||
- service: `operational-core`
|
||||
- module entitlement: `codex_agents`
|
||||
- scope owner: client/user/access matrix
|
||||
- open contour support
|
||||
- enterprise contour support
|
||||
|
||||
Launcher should not create low-level Tasker cards or tokens. It only projects entitlement and global lifecycle state.
|
||||
|
||||
### Tasker integration
|
||||
|
||||
Tasker shows the feature in `Workspace settings -> Features` only when:
|
||||
|
||||
- the current user has Tasker access;
|
||||
- Launcher access check says the user has `codex_agents`;
|
||||
- the workspace policy allows this workspace to host agents.
|
||||
|
||||
Tasker remains the owner of issues, states, labels, project members, comments, and structured blocks.
|
||||
|
||||
### Agent Gateway
|
||||
|
||||
Agent Gateway owns:
|
||||
|
||||
- agent profiles;
|
||||
- pairing codes;
|
||||
- opaque access tokens;
|
||||
- token hashes;
|
||||
- grants;
|
||||
- scopes;
|
||||
- audit events;
|
||||
- idempotency keys;
|
||||
- rate limits;
|
||||
- MCP tools exposed to local Codex.
|
||||
|
||||
It should not execute user code and should not run Codex itself.
|
||||
|
||||
Agent lifecycle management is not public. Tasker/Launcher-facing management calls use `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`, while external Codex calls use only the opaque agent token issued for one agent. The owner-scoped internal routes verify that the requested agent belongs to the requested `owner_user_id`.
|
||||
|
||||
### Tasker internal adapter
|
||||
|
||||
The adapter is a narrow Tasker API layer for Agent Gateway. It exists because the current Plane REST API is broad and includes operations the agent must not receive directly.
|
||||
|
||||
The adapter should expose intent-level operations:
|
||||
|
||||
- list allowed workspaces/projects;
|
||||
- read project states, labels, members;
|
||||
- create a project within a granted workspace after Tasker admin revalidation;
|
||||
- search/list issues;
|
||||
- read one issue with full comments and attachment metadata;
|
||||
- create issue;
|
||||
- patch allowed issue fields;
|
||||
- append comment;
|
||||
- attach a bounded base64 file through Tasker-owned object storage;
|
||||
- update NODE.DC structured blocks;
|
||||
- add existing workspace member to project when explicitly allowed;
|
||||
- publish audit/realtime events.
|
||||
|
||||
It must reject delete, archive, workspace settings, project deletion, invite
|
||||
creation, arbitrary path/URL attachments, and arbitrary path proxying.
|
||||
|
||||
## Data model
|
||||
|
||||
Initial Agent Gateway entities:
|
||||
|
||||
```text
|
||||
agent
|
||||
id
|
||||
owner_user_id
|
||||
owner_email
|
||||
display_name
|
||||
avatar_url
|
||||
status: active | disabled | revoked
|
||||
created_at
|
||||
updated_at
|
||||
|
||||
agent_token
|
||||
id
|
||||
agent_id
|
||||
token_hash
|
||||
name
|
||||
purpose: ops | ontology
|
||||
status: active | revoked | expired
|
||||
expires_at
|
||||
last_used_at
|
||||
created_at
|
||||
|
||||
agent_grant
|
||||
id
|
||||
agent_id
|
||||
workspace_slug
|
||||
project_id (empty string internally means workspace-level grant)
|
||||
scopes[]
|
||||
mode: voluntary | reporting
|
||||
created_by_user_id
|
||||
created_at
|
||||
updated_at
|
||||
|
||||
pairing_code
|
||||
id
|
||||
agent_id
|
||||
code_hash
|
||||
status: active | used | expired | revoked
|
||||
expires_at
|
||||
created_at
|
||||
used_at
|
||||
|
||||
agent_audit_event
|
||||
id
|
||||
agent_id
|
||||
event_type
|
||||
actor_user_id
|
||||
metadata
|
||||
created_at
|
||||
|
||||
idempotency_key
|
||||
key
|
||||
agent_id
|
||||
request_hash
|
||||
response_body
|
||||
created_at
|
||||
expires_at
|
||||
```
|
||||
|
||||
## Actor model
|
||||
|
||||
Writes should be visible as agent actions, not anonymous platform automation.
|
||||
|
||||
Recommended Tasker-side identity:
|
||||
|
||||
```text
|
||||
display_name: Codex Agent: <name>
|
||||
email: agent+<agent_id>@agents.nodedc.local
|
||||
is_bot: true
|
||||
owner_user_id: <human user id>
|
||||
```
|
||||
|
||||
For audit and UI, every write must preserve both:
|
||||
|
||||
- `agent_id`;
|
||||
- `owner_user_id`.
|
||||
|
||||
If Tasker cannot yet represent owned bot users cleanly, the adapter can initially use a system actor and store the agent metadata in audit payloads, but this should be treated as a migration step, not the final model.
|
||||
|
||||
## Capability model
|
||||
|
||||
Allowed initial scopes:
|
||||
|
||||
```text
|
||||
workspace:read
|
||||
project:read
|
||||
project:create
|
||||
project:member:add_existing
|
||||
issue:read
|
||||
issue:create
|
||||
issue:update
|
||||
issue:move
|
||||
issue:comment
|
||||
issue:label
|
||||
issue:assign
|
||||
issue:structured_blocks:write
|
||||
issue:attachment:write
|
||||
```
|
||||
|
||||
Explicitly denied for MVP:
|
||||
|
||||
```text
|
||||
issue:delete
|
||||
issue:archive
|
||||
comment:delete
|
||||
label:delete
|
||||
state:create
|
||||
state:delete
|
||||
project:delete
|
||||
workspace:settings
|
||||
workspace:member:invite
|
||||
workspace:member:remove
|
||||
raw_tasker_api
|
||||
```
|
||||
|
||||
Deleting or archiving a card remains a human-only operation.
|
||||
|
||||
## Two operating modes
|
||||
|
||||
### Voluntary mode
|
||||
|
||||
The user creates an agent and gives its token to their local Codex. Codex updates Tasker when the user asks it to.
|
||||
|
||||
This mode is user-owned and best for personal development workflow.
|
||||
|
||||
### Reporting mode
|
||||
|
||||
The enterprise/project admin requires agent reporting for a project. The developer still runs local Codex, but the expected workflow is:
|
||||
|
||||
- start work session;
|
||||
- update active issue/checker;
|
||||
- append implementation notes;
|
||||
- finish work session.
|
||||
|
||||
This mode is policy-visible but cannot be fully enforced if Codex runs entirely on the developer machine outside a managed wrapper. The system can enforce token scope and show stale/missing reports, but it cannot force an arbitrary local agent to report unless the organization distributes a managed Codex config or wrapper.
|
||||
|
||||
## Deployment
|
||||
|
||||
Initial local target:
|
||||
|
||||
```text
|
||||
ops-agents.local.nodedc -> Agent Gateway
|
||||
```
|
||||
|
||||
Production-like domains should follow platform conventions:
|
||||
|
||||
```text
|
||||
ops-agents.nodedc.ru or ops-agents.<deployment-domain>
|
||||
```
|
||||
|
||||
The service should support:
|
||||
|
||||
- local `.env`;
|
||||
- staging `.env.staging`;
|
||||
- production secret store;
|
||||
- Docker image build;
|
||||
- container startup migrations;
|
||||
- health endpoint;
|
||||
- preflight script validating URLs/secrets.
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
# MCP Tools Contract
|
||||
|
||||
Last updated: 2026-07-18.
|
||||
|
||||
## Position
|
||||
|
||||
The current Tasker / Plane fork does not expose a dedicated MCP server. It exposes REST endpoints and NODE.DC internal endpoints. The new module should expose MCP tools externally and translate those tool calls into validated Tasker adapter calls.
|
||||
|
||||
Codex should not call generic Tasker REST directly.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- Agent Gateway exposes `/mcp` as JSON-RPC over HTTP.
|
||||
- Implemented MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`.
|
||||
- `tools/list` returns only tools allowed by the authenticated agent session scopes.
|
||||
- `tools/call` uses the same product runtime as REST tool endpoints.
|
||||
- Server-sent event streaming is intentionally not required for the first product slice.
|
||||
|
||||
## Authentication
|
||||
|
||||
MCP clients authenticate to Agent Gateway with an opaque agent token.
|
||||
|
||||
Recommended transport options:
|
||||
|
||||
- HTTPS MCP endpoint for general clients;
|
||||
- local stdio connector later if useful;
|
||||
- REST fallback for non-MCP clients.
|
||||
|
||||
Current route:
|
||||
|
||||
```text
|
||||
POST /mcp
|
||||
POST /ontology-mcp
|
||||
```
|
||||
|
||||
`/mcp` accepts only an Ops-purpose token. `/ontology-mcp` accepts only the
|
||||
separately minted Ontology-purpose token and proxies the read-only Ontology MCP.
|
||||
The two endpoints, credentials, and MCP server entries remain independent.
|
||||
|
||||
Required request headers for authenticated tool calls:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <agent-token>
|
||||
Accept: application/json
|
||||
MCP-Protocol-Version: 2025-06-18
|
||||
```
|
||||
|
||||
Token rules:
|
||||
|
||||
- token is opaque;
|
||||
- server stores only token hash;
|
||||
- token is scoped to one agent;
|
||||
- token can be revoked immediately;
|
||||
- token expires;
|
||||
- owner blocked/annulled disables tokens.
|
||||
|
||||
## Tool list
|
||||
|
||||
### `tasker_get_agent_instructions`
|
||||
|
||||
Returns operating rules, allowed projects, card guide, and reporting expectations.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
workspace:read
|
||||
```
|
||||
|
||||
### `tasker_list_projects`
|
||||
|
||||
Lists projects granted to the agent.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
project:read
|
||||
```
|
||||
|
||||
### `tasker_get_project_context`
|
||||
|
||||
Returns project states, labels, members, and card-writing rules.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
project:read
|
||||
```
|
||||
|
||||
### `tasker_search_issues`
|
||||
|
||||
Searches existing work items within granted projects.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:read
|
||||
```
|
||||
|
||||
### `tasker_get_issue`
|
||||
|
||||
Returns one issue with description, structured blocks, labels, state, assignees,
|
||||
full comment bodies, and attachment metadata. The Tasker adapter caps comments at
|
||||
1000 and reports truncation explicitly.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:read
|
||||
```
|
||||
|
||||
### `tasker_create_project`
|
||||
|
||||
Creates or idempotently resolves a project inside an explicitly granted
|
||||
workspace. Only a long-lived agent-scoped session can call it; token-scoped run
|
||||
grants cannot expand themselves. Tasker revalidates that the human owner is an
|
||||
active workspace administrator before creating the project. The new project is
|
||||
then added to the same agent's grants so subsequent card tools can address it.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
project:create
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
workspace_slug
|
||||
name
|
||||
identifier (optional)
|
||||
description (optional)
|
||||
idempotency_key
|
||||
```
|
||||
|
||||
### `tasker_create_issue`
|
||||
|
||||
Creates a work item.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:create
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
project_id
|
||||
name
|
||||
description_html
|
||||
detail_layout
|
||||
state_id
|
||||
priority
|
||||
label_ids
|
||||
assignee_ids
|
||||
start_date
|
||||
target_date
|
||||
parent_id
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- project must be granted;
|
||||
- state must belong to project;
|
||||
- labels must belong to project;
|
||||
- assignees must be active project members;
|
||||
- detail layout must match NODE.DC structured block schema.
|
||||
|
||||
### `tasker_update_issue`
|
||||
|
||||
Patches allowed issue fields.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:update
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
name
|
||||
description_html
|
||||
detail_layout
|
||||
priority
|
||||
start_date
|
||||
target_date
|
||||
parent_id
|
||||
```
|
||||
|
||||
Deletion, archive, and project transfer are not allowed.
|
||||
|
||||
### `tasker_move_issue`
|
||||
|
||||
Changes issue state and optional sort order.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:move
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
state_id
|
||||
sort_order
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- target state must belong to the same project;
|
||||
- cancelled/completed moves are allowed only if the grant permits them.
|
||||
|
||||
### `tasker_set_issue_labels`
|
||||
|
||||
Sets or merges labels.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:label
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- labels must already exist in project for MVP;
|
||||
- creating new labels can be added later under `label:create`.
|
||||
|
||||
### `tasker_assign_issue`
|
||||
|
||||
Sets assignees.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:assign
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- assignees must be active project members;
|
||||
- if the user asks to add a workspace member to the project, use `tasker_add_existing_project_member` first.
|
||||
|
||||
### `tasker_add_existing_project_member`
|
||||
|
||||
Adds an existing workspace member to a granted project.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
project:member:add_existing
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- target user must already be an active workspace member;
|
||||
- target user role cannot exceed workspace role;
|
||||
- launcher-managed workspace rules must be respected;
|
||||
- the request should be traceable to an explicit human instruction or reporting policy.
|
||||
|
||||
### `tasker_append_comment`
|
||||
|
||||
Adds a comment to an issue.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:comment
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
comment_html
|
||||
```
|
||||
|
||||
### `tasker_attach_file`
|
||||
|
||||
Uploads a bounded file to an issue through Tasker's existing FileAsset and
|
||||
S3/MinIO storage path. The MCP accepts base64 content only: it never reads a
|
||||
client or server filesystem path and never fetches a remote URL. Raw content is
|
||||
limited to 5 MiB and Tasker rechecks MIME allowlists, workspace storage limits,
|
||||
project quota, and content deduplication.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:attachment:write
|
||||
```
|
||||
|
||||
Allowed fields:
|
||||
|
||||
```text
|
||||
project_id
|
||||
issue_id
|
||||
file_name
|
||||
content_type
|
||||
content_base64
|
||||
idempotency_key
|
||||
```
|
||||
|
||||
### `tasker_update_structured_blocks`
|
||||
|
||||
Replaces or patches NODE.DC structured blocks in `detail_layout`.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:structured_blocks:write
|
||||
```
|
||||
|
||||
Supported blocks:
|
||||
|
||||
```text
|
||||
text
|
||||
checker
|
||||
```
|
||||
|
||||
Canonical key:
|
||||
|
||||
```text
|
||||
nodedc_structured_blocks
|
||||
```
|
||||
|
||||
The tool should support high-level patch actions:
|
||||
|
||||
```text
|
||||
append_text_block
|
||||
append_checker
|
||||
update_checker_item
|
||||
replace_blocks
|
||||
append_implementation_note
|
||||
```
|
||||
|
||||
### `tasker_start_work_session`
|
||||
|
||||
Starts a reporting session for a project or issue.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:comment
|
||||
```
|
||||
|
||||
This is mainly for enterprise reporting mode.
|
||||
|
||||
### `tasker_finish_work_session`
|
||||
|
||||
Finishes a reporting session and appends summary/validation notes.
|
||||
|
||||
Required scope:
|
||||
|
||||
```text
|
||||
issue:comment
|
||||
issue:structured_blocks:write
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
All write tools must accept:
|
||||
|
||||
```text
|
||||
idempotency_key
|
||||
```
|
||||
|
||||
Agent Gateway requires this value for all write tools. The key is scoped by agent, hashed together with the tool name and normalized arguments, and stored with a processing/completed state.
|
||||
|
||||
Behavior:
|
||||
|
||||
- first successful request stores the tool result for 24 hours;
|
||||
- duplicate key with identical arguments returns the stored result and does not call Tasker again;
|
||||
- duplicate key with different arguments returns `idempotency_key_conflict`;
|
||||
- duplicate key while the first request is still processing returns `idempotency_key_in_progress`;
|
||||
- failed writes release the key so the same operation can be retried.
|
||||
|
||||
## Denied tools
|
||||
|
||||
These should not exist in MVP:
|
||||
|
||||
```text
|
||||
tasker_delete_issue
|
||||
tasker_archive_issue
|
||||
tasker_delete_comment
|
||||
tasker_delete_label
|
||||
tasker_delete_project
|
||||
tasker_invite_workspace_member
|
||||
tasker_raw_api_request
|
||||
tasker_attach_server_path
|
||||
tasker_attach_remote_url
|
||||
```
|
||||
|
||||
## Instruction pack
|
||||
|
||||
`tasker_get_agent_instructions` should include the effective card guide. The local Codex setup file should instruct the agent to call this tool before planning Tasker changes.
|
||||
|
||||
Agent Gateway also exposes:
|
||||
|
||||
```text
|
||||
GET /api/v1/agent-session/setup
|
||||
```
|
||||
|
||||
This endpoint is authenticated by the same bearer token and returns:
|
||||
|
||||
- MCP server config template with `<agent-token>` placeholder;
|
||||
- tools available to the current agent grants;
|
||||
- generated AGENTS.md content with NODE.DC Tasker operating rules;
|
||||
- no raw token echo.
|
||||
|
||||
Minimum instruction:
|
||||
|
||||
```text
|
||||
Before creating or updating Tasker cards, call tasker_get_agent_instructions.
|
||||
Use Tasker for durable project planning, current architecture, planned architecture, stage checkers, implementation notes, and validation results.
|
||||
Do not create multiple top-level cards for substeps of one product topic.
|
||||
Do not delete or archive cards.
|
||||
Do not operate outside granted projects.
|
||||
```
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
# Tasker API Audit
|
||||
|
||||
Last updated: 2026-05-14.
|
||||
|
||||
## Summary
|
||||
|
||||
Current Tasker / Plane fork is partially ready for the Codex Agent API use case through existing REST endpoints and NODE.DC structured blocks. It is not ready as a direct external API for agents because it has broad routes, session-oriented permissions, delete/archive endpoints, and no MCP layer.
|
||||
|
||||
The correct approach is to add a narrow internal Tasker adapter for Agent Gateway instead of exposing raw Plane API to local Codex.
|
||||
|
||||
## Existing useful API surface
|
||||
|
||||
### Issues
|
||||
|
||||
Routes exist for issue list/create/update/retrieve:
|
||||
|
||||
```text
|
||||
GET /api/workspaces/:slug/projects/:project_id/issues/
|
||||
POST /api/workspaces/:slug/projects/:project_id/issues/
|
||||
GET /api/workspaces/:slug/projects/:project_id/issues/:issue_id/
|
||||
PATCH /api/workspaces/:slug/projects/:project_id/issues/:issue_id/
|
||||
```
|
||||
|
||||
The same route also supports `DELETE`, but Agent Gateway must never expose it.
|
||||
|
||||
Existing serializers already support:
|
||||
|
||||
- name;
|
||||
- state;
|
||||
- priority;
|
||||
- dates;
|
||||
- labels;
|
||||
- assignees;
|
||||
- parent issue;
|
||||
- description HTML;
|
||||
- `detail_layout`.
|
||||
|
||||
Validation already checks:
|
||||
|
||||
- assignees are active project members with sufficient role;
|
||||
- labels belong to project;
|
||||
- state belongs to project;
|
||||
- parent belongs to workspace/project;
|
||||
- description HTML is sanitized.
|
||||
|
||||
### Structured blocks
|
||||
|
||||
NODE.DC structured task content lives in:
|
||||
|
||||
```text
|
||||
Issue.detail_layout["nodedc_structured_blocks"]
|
||||
```
|
||||
|
||||
Known block types:
|
||||
|
||||
```text
|
||||
text
|
||||
checker
|
||||
```
|
||||
|
||||
This is the right storage layer for:
|
||||
|
||||
- current architecture;
|
||||
- planned architecture;
|
||||
- stages;
|
||||
- checkers;
|
||||
- implementation notes.
|
||||
|
||||
Tasker already computes checker progress from this structure.
|
||||
|
||||
### Comments
|
||||
|
||||
Routes exist:
|
||||
|
||||
```text
|
||||
GET /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/
|
||||
POST /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/
|
||||
PATCH /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/:comment_id/
|
||||
DELETE /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/:comment_id/
|
||||
```
|
||||
|
||||
Agent Gateway should expose comment creation and possibly own-comment edit later, but not comment deletion.
|
||||
|
||||
### Labels
|
||||
|
||||
Routes exist:
|
||||
|
||||
```text
|
||||
GET /api/workspaces/:slug/projects/:project_id/issue-labels/
|
||||
POST /api/workspaces/:slug/projects/:project_id/issue-labels/
|
||||
PATCH /api/workspaces/:slug/projects/:project_id/issue-labels/:label_id/
|
||||
DELETE /api/workspaces/:slug/projects/:project_id/issue-labels/:label_id/
|
||||
```
|
||||
|
||||
MVP should let agents apply existing labels. Creating labels can be added later under an explicit admin scope.
|
||||
|
||||
### States
|
||||
|
||||
Routes exist:
|
||||
|
||||
```text
|
||||
GET /api/workspaces/:slug/projects/:project_id/states/
|
||||
PATCH /api/workspaces/:slug/projects/:project_id/states/:state_id/
|
||||
```
|
||||
|
||||
Agent Gateway should allow moving issues to existing states. It should not allow state creation/deletion in MVP.
|
||||
|
||||
### Project members
|
||||
|
||||
Routes exist:
|
||||
|
||||
```text
|
||||
GET /api/workspaces/:slug/projects/:project_id/members/
|
||||
POST /api/workspaces/:slug/projects/:project_id/members/
|
||||
PATCH /api/workspaces/:slug/projects/:project_id/members/:member_id/
|
||||
DELETE /api/workspaces/:slug/projects/:project_id/members/:member_id/
|
||||
```
|
||||
|
||||
Current code checks workspace membership and blocks launcher-managed workspace self-management.
|
||||
|
||||
Agent Gateway may expose `add_existing_project_member` only with an explicit scope and only for existing workspace members.
|
||||
|
||||
## Missing MCP layer
|
||||
|
||||
No dedicated MCP server exists in Tasker today. References to MCP are documentation/guideline-oriented, not an operational API server.
|
||||
|
||||
Required new layer:
|
||||
|
||||
```text
|
||||
Agent Gateway MCP tools -> Agent Gateway service -> Tasker internal adapter
|
||||
```
|
||||
|
||||
Tasker should not become the MCP host. Keeping MCP in Agent Gateway preserves standalone Tasker and keeps the external agent surface outside Plane.
|
||||
|
||||
## Required Tasker adapter additions
|
||||
|
||||
Add internal endpoints under a namespace such as:
|
||||
|
||||
```text
|
||||
/api/internal/nodedc/agent/...
|
||||
```
|
||||
|
||||
Current implemented adapter routes:
|
||||
|
||||
```text
|
||||
POST /api/internal/nodedc/agent/projects/resolve
|
||||
POST /api/internal/nodedc/agent/projects
|
||||
GET /api/internal/nodedc/agent/projects/:project_id/context
|
||||
GET /api/internal/nodedc/agent/issues?project_id=...
|
||||
POST /api/internal/nodedc/agent/issues
|
||||
GET /api/internal/nodedc/agent/issues/:issue_id
|
||||
PATCH /api/internal/nodedc/agent/issues/:issue_id
|
||||
POST /api/internal/nodedc/agent/issues/:issue_id/attachments
|
||||
POST /api/internal/nodedc/agent/issues/:issue_id/move
|
||||
POST /api/internal/nodedc/agent/issues/:issue_id/comments
|
||||
PUT /api/internal/nodedc/agent/issues/:issue_id/labels
|
||||
PUT /api/internal/nodedc/agent/issues/:issue_id/assignees
|
||||
```
|
||||
|
||||
The implemented adapter uses NODE.DC internal bearer auth and receives normalized agent metadata in headers:
|
||||
|
||||
```text
|
||||
X-NODEDC-Agent-Id
|
||||
X-NODEDC-Agent-Owner-User-Id
|
||||
X-NODEDC-Agent-Token-Id
|
||||
```
|
||||
|
||||
The current adapter creates or reuses a dedicated bot actor with email `agent+<agent_id>@agents.nodedc.local` and `bot_type=nodedc_codex_agent`.
|
||||
|
||||
Issue detail returns full comment bodies plus attachment metadata. Project
|
||||
creation is workspace-admin gated and idempotent by agent external id.
|
||||
Attachment upload uses Plane's existing FileAsset, S3/MinIO, quota, MIME, and
|
||||
deduplication implementation; it accepts no server path or remote URL.
|
||||
|
||||
These endpoints must use `NODEDC_INTERNAL_ACCESS_TOKEN` / `PLANE_NODEDC_ACCESS_TOKEN` style auth and must be callable only from Agent Gateway.
|
||||
|
||||
Suggested adapter endpoints:
|
||||
|
||||
```text
|
||||
POST /api/internal/nodedc/agent/context/
|
||||
POST /api/internal/nodedc/agent/issues/search/
|
||||
POST /api/internal/nodedc/agent/issues/
|
||||
PATCH /api/internal/nodedc/agent/issues/:issue_id/
|
||||
POST /api/internal/nodedc/agent/issues/:issue_id/comments/
|
||||
POST /api/internal/nodedc/agent/issues/:issue_id/structured-blocks/
|
||||
POST /api/internal/nodedc/agent/projects/:project_id/members/add-existing/
|
||||
```
|
||||
|
||||
The adapter should receive normalized Agent Gateway metadata:
|
||||
|
||||
```text
|
||||
agent_id
|
||||
owner_user_id
|
||||
owner_email
|
||||
workspace_slug
|
||||
project_id
|
||||
scopes
|
||||
idempotency_key
|
||||
```
|
||||
|
||||
The adapter should validate Tasker domain rules and return stable errors rather than leaking raw Plane serializer details.
|
||||
|
||||
## Why raw Plane API is not enough
|
||||
|
||||
Raw Plane API is too broad for external agents:
|
||||
|
||||
- it includes delete/archive routes;
|
||||
- it assumes a session user, not an external agent identity;
|
||||
- it has more fields than agents should control;
|
||||
- it does not know Launcher module entitlements;
|
||||
- it does not have agent idempotency;
|
||||
- it does not produce agent-specific audit by default;
|
||||
- it is not MCP-native.
|
||||
|
||||
## Compatibility note
|
||||
|
||||
Tasker must remain standalone-capable. All agent-specific behavior should be disabled when NODE.DC Agent Gateway env vars are absent.
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# Threat Model
|
||||
|
||||
Last updated: 2026-07-18.
|
||||
|
||||
## Security objective
|
||||
|
||||
Let external local Codex agents maintain Tasker cards without turning Tasker into an open automation surface.
|
||||
|
||||
## Main threats
|
||||
|
||||
### Raw Tasker access leakage
|
||||
|
||||
Risk: a user copies a broad Tasker token or cookie into local Codex, allowing arbitrary API calls.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- never issue Plane session cookies to agents;
|
||||
- never expose raw Tasker API tokens;
|
||||
- use opaque Agent Gateway tokens;
|
||||
- only expose allowlisted MCP tools.
|
||||
|
||||
### Project scope escape
|
||||
|
||||
Risk: an agent writes to another project or workspace.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Agent Gateway grants are project-scoped;
|
||||
- Tasker adapter revalidates workspace/project membership;
|
||||
- every tool requires explicit `project_id`;
|
||||
- gateway rejects projects outside grant set.
|
||||
- project creation requires `project:create` on a workspace grant, an
|
||||
agent-scoped token, and a second workspace-admin check inside Tasker;
|
||||
- token-scoped AI Workspace sessions cannot add projects to their own grants.
|
||||
|
||||
### Attachment exfiltration or storage abuse
|
||||
|
||||
Risk: an agent reads arbitrary host files, fetches a remote payload, or bypasses
|
||||
Tasker storage quotas through the attachment tool.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- MCP accepts explicit base64 bytes only, never filesystem paths or remote URLs;
|
||||
- Agent Gateway limits decoded content to 5 MiB and never logs the base64 body;
|
||||
- Tasker enforces MIME allowlists, workspace storage limits, project quota,
|
||||
S3/MinIO ownership, and content deduplication.
|
||||
|
||||
### Cross-service token confusion
|
||||
|
||||
Risk: an Ops token is replayed against Ontology or an Ontology token gains Tasker
|
||||
write capabilities.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- setup redemption mints two independent opaque token records;
|
||||
- token purpose is persisted and checked on every endpoint;
|
||||
- `/mcp` accepts only `ops`, `/ontology-mcp` only `ontology`;
|
||||
- Ontology upstream access token remains server-side.
|
||||
|
||||
### Destructive action
|
||||
|
||||
Risk: an agent deletes or archives cards, labels, comments, projects, or members.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- no delete/archive MCP tools in MVP;
|
||||
- adapter rejects delete/archive intents;
|
||||
- raw API proxy is forbidden.
|
||||
|
||||
### Privilege confusion
|
||||
|
||||
Risk: an agent acts as the human user and hides automation history.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- create dedicated agent identity;
|
||||
- store owner user separately;
|
||||
- every audit event includes both `agent_id` and `owner_user_id`;
|
||||
- UI displays agent-originated changes.
|
||||
|
||||
### Prompt injection
|
||||
|
||||
Risk: text inside a card tells Codex to exfiltrate token or call forbidden tools.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- MCP tools enforce server-side scopes;
|
||||
- instruction pack says Tasker content is untrusted;
|
||||
- Gateway never exposes secrets through read tools;
|
||||
- deny arbitrary HTTP fetch/proxy tools.
|
||||
|
||||
### Token theft
|
||||
|
||||
Risk: local token leaks from developer machine.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- token hash storage;
|
||||
- expiry;
|
||||
- immediate revoke;
|
||||
- last used metadata;
|
||||
- rate limits;
|
||||
- optional IP/device binding later.
|
||||
|
||||
### Lifecycle API exposure
|
||||
|
||||
Risk: an external caller creates agents, grants projects, or mints tokens without going through Launcher/Tasker entitlement.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- lifecycle routes require `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`;
|
||||
- owner-scoped routes verify `owner_user_id` against the stored agent owner;
|
||||
- external Codex tokens can call only agent-session, setup, tool, and MCP routes;
|
||||
- raw agent token is returned only once on token creation.
|
||||
|
||||
### Owner lifecycle bypass
|
||||
|
||||
Risk: blocked/annulled user keeps active agent token.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Gateway checks Launcher owner status;
|
||||
- blocked/annulled owner disables agent tokens;
|
||||
- periodic sync plus request-time access check.
|
||||
|
||||
### Replay and duplicate writes
|
||||
|
||||
Risk: network retry creates duplicate cards/comments.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- required idempotency keys for write tools;
|
||||
- store operation result by agent and idempotency key;
|
||||
- reject same key with different arguments;
|
||||
- release failed writes so safe retries can run again.
|
||||
|
||||
### Reporting mode false confidence
|
||||
|
||||
Risk: enterprise admin assumes local Codex must report, but the developer bypasses the managed config.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- UI distinguishes `connected`, `stale`, `never connected`;
|
||||
- reporting mode is visibility and policy, not hard enforcement, unless a managed wrapper is used;
|
||||
- CI/workflow checks can require Tasker session updates later.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No database access from Agent Gateway to Tasker DB.
|
||||
- No arbitrary Tasker HTTP proxy.
|
||||
- No user session cookie reuse.
|
||||
- No delete/archive tools in MVP.
|
||||
- No secrets in generated markdown instruction files.
|
||||
- No token logging.
|
||||
- No frontend access to service secrets.
|
||||
- No cross-use between Ops and Ontology bearer tokens.
|
||||
- No attachment from a filesystem path or remote URL.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
ALTER TABLE agent_tokens
|
||||
ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'ops'
|
||||
CHECK (purpose IN ('ops', 'ontology'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_tokens_purpose_idx ON agent_tokens(purpose);
|
||||
|
||||
ALTER TABLE agent_setup_codes
|
||||
ADD COLUMN IF NOT EXISTS used_ontology_token_id uuid REFERENCES agent_tokens(id) ON DELETE SET NULL;
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
||||
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
|
||||
import { AgentsRepository } from "./repositories/agents.js";
|
||||
import { registerAgentRoutes } from "./routes/agents.js";
|
||||
import { registerEngineGatewayRoutes } from "./routes/engine.js";
|
||||
import { registerHealthRoutes } from "./routes/health.js";
|
||||
import { registerInstallerRoutes } from "./routes/install.js";
|
||||
import { registerMcpRoutes } from "./routes/mcp.js";
|
||||
import { registerOntologyGatewayRoutes } from "./routes/ontology.js";
|
||||
import { registerPublicRoutes } from "./routes/public.js";
|
||||
import { registerToolRoutes } from "./routes/tools.js";
|
||||
import { ForbiddenError } from "./security/authorization.js";
|
||||
import { UnauthorizedError } from "./security/bearer.js";
|
||||
import { InternalAuthNotConfiguredError } from "./security/internal.js";
|
||||
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
||||
|
||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
const pool = createPool(config);
|
||||
const agentsRepository = pool ? new AgentsRepository(pool) : null;
|
||||
const taskerClient = new TaskerClient({
|
||||
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
|
||||
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
const app = Fastify({
|
||||
bodyLimit: 10 * 1024 * 1024,
|
||||
logger: {
|
||||
level: config.LOG_LEVEL,
|
||||
},
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await pool?.end();
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (error instanceof ZodError) {
|
||||
void reply.status(400).send({
|
||||
ok: false,
|
||||
error: "validation_error",
|
||||
details: error.issues,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "database_not_configured",
|
||||
message: "DATABASE_URL is required for Agent Gateway persistence endpoints.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
void reply.status(401).send({
|
||||
ok: false,
|
||||
error: "unauthorized",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof InternalAuthNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "internal_auth_not_configured",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ForbiddenError) {
|
||||
void reply.status(403).send({
|
||||
ok: false,
|
||||
error: "forbidden",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ToolExecutionInputError) {
|
||||
void reply.status(error.httpStatus).send({
|
||||
ok: false,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_not_configured",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterError) {
|
||||
void reply.status(error.statusCode).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_error",
|
||||
message: error.message,
|
||||
tasker_status: error.statusCode,
|
||||
tasker_payload: error.payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterUnavailableError) {
|
||||
void reply.status(502).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_unavailable",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
void reply.status(500).send({
|
||||
ok: false,
|
||||
error: "internal_server_error",
|
||||
message: "Agent Gateway request failed.",
|
||||
});
|
||||
});
|
||||
|
||||
await registerPublicRoutes(app);
|
||||
await registerInstallerRoutes(app, { publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL });
|
||||
await registerHealthRoutes(app, config, pool);
|
||||
await registerAgentRoutes(app, {
|
||||
agentsRepository,
|
||||
codexInstallChannel: config.NODEDC_OPS_CODEX_INSTALL_CHANNEL,
|
||||
codexNpmSpec: config.NODEDC_OPS_CODEX_NPM_SPEC,
|
||||
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
|
||||
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
|
||||
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
|
||||
});
|
||||
await registerEngineGatewayRoutes(app, {
|
||||
engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL,
|
||||
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
|
||||
});
|
||||
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||
await registerMcpRoutes(app, { agentsRepository, taskerClient });
|
||||
await registerOntologyGatewayRoutes(app, {
|
||||
agentsRepository,
|
||||
ontologyCoreUrl: config.NODEDC_ONTOLOGY_CORE_URL,
|
||||
ontologyCoreAccessToken: config.NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN ?? config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# NODE.DC Ops Codex
|
||||
|
||||
Installs NODE.DC Ops MCP access into a local Codex Desktop/CLI configuration.
|
||||
|
||||
## Setup
|
||||
|
||||
Generate a one-time setup code in NODE.DC Ops, then run:
|
||||
|
||||
```sh
|
||||
npx --yes @nodedc/ops-codex setup ndcsetup_...
|
||||
```
|
||||
|
||||
The setup command redeems the one-time code, writes independent `nodedc-ops-agent` and read-only `nodedc_ontology` MCP server blocks into `~/.codex/config.toml`, installs the `ops-context` skill, and checks both MCP tool lists.
|
||||
|
||||
Restart Codex Desktop after setup. A new chat is not enough if the existing Desktop process already loaded MCP config.
|
||||
|
||||
The installer auto-detects existing Codex homes. It checks `$CODEX_HOME`, `~/.codex`, common OS app-data Codex folders, and portable-style `.codex` folders near the current directory. If multiple existing Codex homes are found, setup writes all of them. If none are found, setup creates `~/.codex`.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
ops-codex setup <setup-code>
|
||||
ops-codex status
|
||||
ops-codex doctor
|
||||
```
|
||||
|
||||
`doctor` checks the local config, installed skill, and MCP connectivity.
|
||||
|
||||
## Options
|
||||
|
||||
```sh
|
||||
--gateway <url> Ops Agent Gateway URL. Defaults to https://ops-agents.nodedc.ru.
|
||||
--codex-home <path> Codex home directory. Overrides auto-discovery.
|
||||
```
|
||||
|
||||
For an unusual portable Codex layout, run setup from the portable app folder or pass `--codex-home <path>` explicitly.
|
||||
|
|
@ -0,0 +1,792 @@
|
|||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_GATEWAY = "https://ops-agents.nodedc.ru";
|
||||
const SERVER_NAME = "nodedc-ops-agent";
|
||||
const ONTOLOGY_SERVER_NAME = "nodedc_ontology";
|
||||
const SKILL_NAME = "ops-context";
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printHelp(args.command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === "setup") {
|
||||
await runSetup(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === "status") {
|
||||
const report = await inspectLocalInstalls(args.codexHome, { smoke: false });
|
||||
printInstallReport(report);
|
||||
process.exitCode = report.ok ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === "doctor") {
|
||||
const report = await inspectLocalInstalls(args.codexHome, { smoke: true });
|
||||
printInstallReport(report);
|
||||
process.exitCode = report.ok ? 0 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new UsageError(`Unknown command: ${args.command}`);
|
||||
}
|
||||
|
||||
async function runSetup(args) {
|
||||
if (!args.setupCode) {
|
||||
throw new UsageError("Missing setup code. Use: ops-codex setup <ndcsetup_...>");
|
||||
}
|
||||
|
||||
const gateway = String(args.gateway || process.env.NODEDC_OPS_AGENT_GATEWAY || DEFAULT_GATEWAY).replace(/\/+$/, "");
|
||||
const redeemPayload = await redeemSetupCode(gateway, args.setupCode);
|
||||
const setup = redeemPayload.setup || {};
|
||||
const mcpServer = setup.mcp_server || {};
|
||||
const ops = redeemPayload.ops || {
|
||||
serverName: SERVER_NAME,
|
||||
mcpUrl: String(mcpServer.url || `${gateway}/mcp`),
|
||||
token: redeemPayload.token,
|
||||
};
|
||||
const ontology = redeemPayload.ontology;
|
||||
if (!ops.token || !ontology?.token) {
|
||||
throw new Error("Setup code redeem did not return independent Ops and Ontology credentials.");
|
||||
}
|
||||
const opsEndpoint = String(ops.mcpUrl || `${gateway}/mcp`);
|
||||
const ontologyEndpoint = String(ontology.mcpUrl || `${gateway}/ontology-mcp`);
|
||||
const agentsMd = setup.agents_md || defaultSkillBody(opsEndpoint);
|
||||
|
||||
const targets = await resolveCodexTargets(args.codexHome);
|
||||
|
||||
for (const target of targets) {
|
||||
const configPath = path.join(target.codexHome, "config.toml");
|
||||
const skillPath = path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md");
|
||||
await writeCodexConfig(configPath, [
|
||||
{ serverName: String(ops.serverName || SERVER_NAME), endpoint: opsEndpoint, token: ops.token },
|
||||
{
|
||||
serverName: String(ontology.serverName || ONTOLOGY_SERVER_NAME),
|
||||
endpoint: ontologyEndpoint,
|
||||
token: ontology.token,
|
||||
},
|
||||
]);
|
||||
await writeSkill(skillPath, agentsMd, opsEndpoint, ontologyEndpoint);
|
||||
}
|
||||
const opsToolCount = await smokeToolsList(opsEndpoint, ops.token);
|
||||
const ontologyToolCount = await smokeToolsList(ontologyEndpoint, ontology.token);
|
||||
|
||||
console.log("NODE.DC Ops Codex setup complete.");
|
||||
for (const target of targets) {
|
||||
console.log("Config:", path.join(target.codexHome, "config.toml"));
|
||||
console.log("Skill:", path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md"));
|
||||
console.log("Target:", target.reason);
|
||||
}
|
||||
console.log("Ops MCP smoke tools:", opsToolCount);
|
||||
console.log("Ontology MCP smoke tools:", ontologyToolCount);
|
||||
console.log("Run: ops-codex doctor");
|
||||
console.log("Restart Codex Desktop completely before testing. A new chat is not enough if the Desktop process already loaded MCP config.");
|
||||
console.log("In the next session, tool discovery must expose tasker_list_projects. If it does not, Codex has not loaded the NODE.DC Ops MCP server.");
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs) {
|
||||
let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
||||
let command = "setup";
|
||||
|
||||
if (args[0] === "setup" || args[0] === "status" || args[0] === "doctor") {
|
||||
command = args[0];
|
||||
args = args.slice(1);
|
||||
} else if (args[0] === "help") {
|
||||
return { codexHome: "", command: "help", gateway: "", help: true, setupCode: "" };
|
||||
}
|
||||
|
||||
const parsed = {
|
||||
codexHome: "",
|
||||
command,
|
||||
gateway: "",
|
||||
help: false,
|
||||
setupCode: "",
|
||||
};
|
||||
const positional = [];
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--gateway") {
|
||||
parsed.gateway = requireValue(args, ++i, "--gateway");
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--gateway=")) {
|
||||
parsed.gateway = arg.slice("--gateway=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--setup-code" || arg === "--code") {
|
||||
parsed.setupCode = requireValue(args, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--setup-code=")) {
|
||||
parsed.setupCode = arg.slice("--setup-code=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--code=")) {
|
||||
parsed.setupCode = arg.slice("--code=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--codex-home") {
|
||||
parsed.codexHome = requireValue(args, ++i, "--codex-home");
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--codex-home=")) {
|
||||
parsed.codexHome = arg.slice("--codex-home=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
throw new UsageError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
if (parsed.command === "setup") {
|
||||
if (!parsed.setupCode && positional[0]) {
|
||||
parsed.setupCode = positional[0];
|
||||
}
|
||||
if (positional.length > 1) {
|
||||
throw new UsageError(`Unexpected extra argument: ${positional[1]}`);
|
||||
}
|
||||
} else if (positional.length) {
|
||||
throw new UsageError(`${parsed.command} does not accept positional arguments.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireValue(args, index, flag) {
|
||||
const value = args[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new UsageError(`Missing value for ${flag}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function printHelp(command = "") {
|
||||
if (command === "status") {
|
||||
console.log(`Show local NODE.DC Ops Codex install status.
|
||||
|
||||
Usage:
|
||||
ops-codex status [--codex-home <path>]
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "doctor") {
|
||||
console.log(`Check local NODE.DC Ops Codex install and MCP connectivity.
|
||||
|
||||
Usage:
|
||||
ops-codex doctor [--codex-home <path>]
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Install NODE.DC Ops MCP access into local Codex.
|
||||
|
||||
Usage:
|
||||
ops-codex setup <setup-code> [--gateway <url>] [--codex-home <path>]
|
||||
ops-codex setup --code <setup-code> [--gateway <url>] [--codex-home <path>]
|
||||
ops-codex status [--codex-home <path>]
|
||||
ops-codex doctor [--codex-home <path>]
|
||||
|
||||
Registry form after publish:
|
||||
npx --yes @nodedc/ops-codex setup <setup-code>
|
||||
|
||||
Self-host fallback:
|
||||
npm exec --yes --package=<gateway>/install/nodedc-ops-codex-setup.tgz -- ops-codex setup <setup-code>
|
||||
|
||||
Options:
|
||||
--code, --setup-code <code> One-time setup code generated by NODE.DC Ops.
|
||||
--gateway <url> Ops Agent Gateway URL. Defaults to ${DEFAULT_GATEWAY}.
|
||||
--codex-home <path> Codex home directory. Overrides auto-discovery.
|
||||
-h, --help Show this help.
|
||||
|
||||
Codex home discovery:
|
||||
By default, the installer writes every existing Codex-like home it can detect:
|
||||
$CODEX_HOME, ~/.codex, common OS app-data Codex folders, and portable-style
|
||||
.codex folders near the current directory. If none exist, it creates ~/.codex.
|
||||
|
||||
Legacy compatibility:
|
||||
nodedc-ops-codex --setup-code <setup-code>
|
||||
`);
|
||||
}
|
||||
|
||||
async function redeemSetupCode(gateway, setupCode) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${gateway}/api/v1/setup-codes/redeem`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ setup_code: setupCode }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Setup code redeem failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = bodyText ? JSON.parse(bodyText) : {};
|
||||
} catch {
|
||||
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
|
||||
}
|
||||
|
||||
if (!data.ok || !data.token || !data.ontology?.token) {
|
||||
throw new Error(`Setup code redeem failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function resolveCodexTargets(codexHomeArg) {
|
||||
if (codexHomeArg) {
|
||||
return [{ codexHome: expandHome(codexHomeArg), reason: "--codex-home" }];
|
||||
}
|
||||
|
||||
if (process.env.CODEX_HOME) {
|
||||
return [{ codexHome: expandHome(process.env.CODEX_HOME), reason: "CODEX_HOME" }];
|
||||
}
|
||||
|
||||
const candidates = buildCodexHomeCandidates();
|
||||
const existing = [];
|
||||
for (const candidate of candidates) {
|
||||
if (await looksLikeCodexHome(candidate.codexHome)) {
|
||||
existing.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedExisting = dedupeTargets(existing);
|
||||
if (dedupedExisting.length > 0) {
|
||||
return dedupedExisting;
|
||||
}
|
||||
|
||||
return [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
|
||||
}
|
||||
|
||||
function buildCodexHomeCandidates() {
|
||||
const home = os.homedir();
|
||||
const candidates = [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
candidates.push(
|
||||
{
|
||||
codexHome: path.join(home, "Library", "Application Support", "Codex"),
|
||||
reason: "macOS Application Support Codex",
|
||||
},
|
||||
{
|
||||
codexHome: path.join(home, "Library", "Application Support", "OpenAI", "Codex"),
|
||||
reason: "macOS Application Support OpenAI Codex",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
||||
candidates.push(
|
||||
{ codexHome: path.join(appData, "Codex"), reason: "Windows APPDATA Codex" },
|
||||
{ codexHome: path.join(appData, "OpenAI", "Codex"), reason: "Windows APPDATA OpenAI Codex" },
|
||||
{ codexHome: path.join(localAppData, "Codex"), reason: "Windows LOCALAPPDATA Codex" },
|
||||
{ codexHome: path.join(localAppData, "OpenAI", "Codex"), reason: "Windows LOCALAPPDATA OpenAI Codex" }
|
||||
);
|
||||
}
|
||||
|
||||
for (const portableHome of portableCodexHomeCandidates(process.cwd())) {
|
||||
candidates.push(portableHome);
|
||||
}
|
||||
|
||||
return dedupeTargets(candidates);
|
||||
}
|
||||
|
||||
function portableCodexHomeCandidates(startDir) {
|
||||
const candidates = [];
|
||||
let current = path.resolve(startDir);
|
||||
const stopAt = path.parse(current).root;
|
||||
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
candidates.push(
|
||||
{ codexHome: path.join(current, ".codex"), reason: "portable .codex near current directory" },
|
||||
{ codexHome: path.join(current, "data", ".codex"), reason: "portable data/.codex near current directory" },
|
||||
{ codexHome: path.join(current, "profile", ".codex"), reason: "portable profile/.codex near current directory" },
|
||||
{ codexHome: path.join(current, "portable", ".codex"), reason: "portable portable/.codex near current directory" }
|
||||
);
|
||||
|
||||
if (current === stopAt) {
|
||||
break;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function defaultCodexHome() {
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
async function looksLikeCodexHome(codexHome) {
|
||||
if (await pathExists(path.join(codexHome, "config.toml"))) {
|
||||
return true;
|
||||
}
|
||||
if (await pathExists(path.join(codexHome, "skills"))) {
|
||||
return true;
|
||||
}
|
||||
const baseName = path.basename(codexHome).toLowerCase();
|
||||
return baseName === ".codex" && (await pathExists(codexHome));
|
||||
}
|
||||
|
||||
async function pathExists(targetPath) {
|
||||
try {
|
||||
await stat(targetPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeTargets(targets) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const target of targets) {
|
||||
const key = normalizePathKey(target.codexHome);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push({ codexHome: path.resolve(target.codexHome), reason: target.reason });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizePathKey(value) {
|
||||
const resolved = path.resolve(value);
|
||||
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function expandHome(value) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
async function writeCodexConfig(configPath, servers) {
|
||||
await mkdir(path.dirname(configPath), { recursive: true });
|
||||
const original = await readTextIfExists(configPath);
|
||||
let backupPath = "";
|
||||
if (original !== null) {
|
||||
backupPath = `${configPath}.nodedc-bak`;
|
||||
await writeFile(backupPath, original, "utf8");
|
||||
}
|
||||
|
||||
let nextText = stripServerSections(
|
||||
original || "",
|
||||
servers.map((server) => server.serverName)
|
||||
).trimEnd();
|
||||
if (nextText) {
|
||||
nextText += "\n\n";
|
||||
}
|
||||
nextText += servers
|
||||
.map((server) => buildMcpConfigBlock(server.serverName, server.endpoint, server.token))
|
||||
.join("\n\n");
|
||||
nextText += "\n";
|
||||
await writeFile(configPath, nextText, "utf8");
|
||||
|
||||
if (backupPath) {
|
||||
console.log("Backup:", backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextIfExists(filePath) {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stripServerSections(text, serverNames) {
|
||||
const targets = new Set(serverNames.map((serverName) => `mcp_servers.${serverName}`));
|
||||
const kept = [];
|
||||
let skip = false;
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
||||
if (match) {
|
||||
const section = match[1].trim();
|
||||
skip = [...targets].some((target) => section === target || section.startsWith(`${target}.`));
|
||||
}
|
||||
if (!skip) {
|
||||
kept.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function buildMcpConfigBlock(serverName, endpoint, token) {
|
||||
return [
|
||||
`[mcp_servers.${serverName}]`,
|
||||
`url = ${tomlString(endpoint)}`,
|
||||
"enabled = true",
|
||||
"required = false",
|
||||
"startup_timeout_sec = 20",
|
||||
"tool_timeout_sec = 60",
|
||||
"",
|
||||
`[mcp_servers.${serverName}.http_headers]`,
|
||||
`Authorization = ${tomlString(`Bearer ${token}`)}`,
|
||||
`Accept = ${tomlString("application/json")}`,
|
||||
`${tomlString("MCP-Protocol-Version")} = ${tomlString(MCP_PROTOCOL_VERSION)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function tomlString(value) {
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
async function writeSkill(skillPath, agentsMd, endpoint, ontologyEndpoint) {
|
||||
await mkdir(path.dirname(skillPath), { recursive: true });
|
||||
await writeFile(skillPath, buildSkillBody(agentsMd, endpoint, ontologyEndpoint), "utf8");
|
||||
}
|
||||
|
||||
function buildSkillBody(agentsMd, endpoint, ontologyEndpoint) {
|
||||
return [
|
||||
"---",
|
||||
"name: ops-context",
|
||||
"description: Use when working with NODE.DC Ops/Tasker cards, project cards, checkers, labels, comments, or when the user asks for ops-context. Always use the direct nodedc-ops-agent tasker_* MCP tools and never the old codex_apps OPS widgets.",
|
||||
"---",
|
||||
"",
|
||||
"# ops-context",
|
||||
"",
|
||||
"Use this skill when the user asks to work with NODE.DC Ops, Tasker cards, project cards, checkers, labels, comments, or asks for ops-context.",
|
||||
"",
|
||||
"## Hard Rules",
|
||||
"",
|
||||
"- Treat Ops MCP as the source of truth for cards and project context.",
|
||||
"- Treat `nodedc_ontology` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations, or platform routing assumptions.",
|
||||
"- Ontology tools and credentials are never embedded or multiplexed into Ops MCP.",
|
||||
"- Use only the direct nodedc-ops-agent MCP tools for Ops cards. The expected tool names are tasker_get_agent_instructions, tasker_list_projects, tasker_get_project_context, tasker_search_issues, and the other tasker_* tools listed below.",
|
||||
"- Do not use Codex Apps workspace widgets for Ops: never call tools from codex_apps servers named nodedc_ops_readonly, nodedc_ops_gateway_readonly, ops-readonly, or ops_gateway_readonly. They are known-broken for Tasker cards and waste context.",
|
||||
"- Do not open current workspace widgets, CodexPro cards, .ai-bridge handoff files, local thread lists, or broad filesystem searches to answer Ops card requests.",
|
||||
"- MCP tools can be lazy-loaded. If mcp__nodedc_ops_agent.tasker_list_projects is not already visible in the active tools, do not answer yet.",
|
||||
"- First call the available tool discovery/search tool, normally tool_search.tool_search_tool, with query exactly: tasker_list_projects nodedc-ops-agent NODE.DC Ops MCP",
|
||||
"- After discovery returns NODE.DC Ops tools, immediately call mcp__nodedc_ops_agent.tasker_get_agent_instructions, then mcp__nodedc_ops_agent.tasker_list_projects, then mcp__nodedc_ops_agent.tasker_get_project_context for the target project.",
|
||||
"- Resolve workspace/project from tasker_list_projects and granted project context. Do not guess a workspace slug from user wording.",
|
||||
"- Stop only if no discovery/search tool is available, or discovery returns no mcp__nodedc_ops_agent tasker_* tools. Then tell the user: 'NODE.DC Ops MCP is installed in config.toml, but this Codex session did not expose the nodedc-ops-agent tools after tool discovery.' Do not fall back to filesystem search, config.toml parsing, curl, or Codex Apps widgets unless the user explicitly asks to debug installation.",
|
||||
"- Never print Authorization headers, setup codes, or agent tokens.",
|
||||
"- Every write must use the official Tasker MCP write tool and a fresh idempotency_key.",
|
||||
"- Read a card with `tasker_get_issue` before claiming its comments or attachments are unavailable.",
|
||||
"- Create projects only through `tasker_create_project` when the effective workspace grant includes `project:create`.",
|
||||
"- Attach local files only through `tasker_attach_file`; never pass arbitrary server paths or remote URLs.",
|
||||
"- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.",
|
||||
"",
|
||||
"## Installed Endpoint",
|
||||
"",
|
||||
endpoint,
|
||||
"",
|
||||
"## Separate read-only Ontology endpoint",
|
||||
"",
|
||||
ontologyEndpoint,
|
||||
"",
|
||||
"## Gateway Rules Snapshot",
|
||||
"",
|
||||
agentsMd.trim(),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function defaultSkillBody(endpoint) {
|
||||
return [
|
||||
"# NODE.DC Ops Agent Rules",
|
||||
"",
|
||||
`MCP endpoint: ${endpoint}`,
|
||||
"",
|
||||
"Call tasker_get_agent_instructions, tasker_list_projects, and tasker_get_project_context before writing Tasker cards.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function inspectLocalInstall(codexHome, options) {
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
const skillPath = path.join(codexHome, "skills", SKILL_NAME, "SKILL.md");
|
||||
const checks = [];
|
||||
const configText = await readTextIfExists(configPath);
|
||||
const skillText = await readTextIfExists(skillPath);
|
||||
const opsConfig = configText ? parseCodexMcpConfig(configText, SERVER_NAME) : null;
|
||||
const ontologyConfig = configText ? parseCodexMcpConfig(configText, ONTOLOGY_SERVER_NAME) : null;
|
||||
const endpoint = opsConfig?.server.url || "";
|
||||
const authorization = opsConfig?.headers.Authorization || "";
|
||||
const ontologyEndpoint = ontologyConfig?.server.url || "";
|
||||
const ontologyAuthorization = ontologyConfig?.headers.Authorization || "";
|
||||
|
||||
checks.push({ ok: Boolean(configText), name: "config", detail: configPath });
|
||||
checks.push({
|
||||
ok: Boolean(opsConfig?.found),
|
||||
name: "Ops MCP server",
|
||||
detail: opsConfig?.found ? SERVER_NAME : "missing [mcp_servers.nodedc-ops-agent]",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(endpoint),
|
||||
name: "endpoint",
|
||||
detail: endpoint || "missing url",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(parsedConfig?.headers && authorization),
|
||||
name: "authorization",
|
||||
detail: authorization ? "present" : "missing http_headers.Authorization",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(ontologyConfig?.found),
|
||||
name: "Ontology MCP server",
|
||||
detail: ontologyConfig?.found ? ONTOLOGY_SERVER_NAME : "missing [mcp_servers.nodedc_ontology]",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(ontologyEndpoint),
|
||||
name: "Ontology endpoint",
|
||||
detail: ontologyEndpoint || "missing url",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(ontologyAuthorization),
|
||||
name: "Ontology authorization",
|
||||
detail: ontologyAuthorization ? "present" : "missing http_headers.Authorization",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(skillText),
|
||||
name: "skill",
|
||||
detail: skillPath,
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(skillText?.startsWith("---\nname: ops-context")),
|
||||
name: "skill frontmatter",
|
||||
detail: skillText ? "present" : "not checked",
|
||||
});
|
||||
checks.push({
|
||||
ok: Boolean(skillText?.includes("tool_search.tool_search_tool")),
|
||||
name: "lazy discovery rule",
|
||||
detail: skillText ? "present" : "not checked",
|
||||
});
|
||||
|
||||
let smoke = null;
|
||||
if (options.smoke) {
|
||||
if (endpoint && authorization && ontologyEndpoint && ontologyAuthorization) {
|
||||
try {
|
||||
const opsToolCount = await smokeToolsListWithAuthorization(endpoint, authorization);
|
||||
const ontologyToolCount = await smokeToolsListWithAuthorization(ontologyEndpoint, ontologyAuthorization);
|
||||
smoke = { ok: true, detail: `${opsToolCount} Ops tools; ${ontologyToolCount} Ontology tools` };
|
||||
} catch (error) {
|
||||
smoke = { ok: false, detail: error.message || String(error) };
|
||||
}
|
||||
} else {
|
||||
smoke = { ok: false, detail: "Ops/Ontology endpoint or Authorization header is missing" };
|
||||
}
|
||||
checks.push({ ok: smoke.ok, name: "mcp smoke", detail: smoke.detail });
|
||||
}
|
||||
|
||||
return {
|
||||
codexHome,
|
||||
configPath,
|
||||
endpoint,
|
||||
ok: checks.every((check) => check.ok),
|
||||
skillPath,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectLocalInstalls(codexHomeArg, options) {
|
||||
const targets = await resolveCodexTargets(codexHomeArg);
|
||||
const reports = [];
|
||||
for (const target of targets) {
|
||||
reports.push({
|
||||
target,
|
||||
report: await inspectLocalInstall(target.codexHome, options),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: reports.some((entry) => entry.report.ok),
|
||||
reports,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCodexMcpConfig(text, serverName) {
|
||||
const target = `mcp_servers.${serverName}`;
|
||||
const headers = `${target}.http_headers`;
|
||||
let current = "";
|
||||
const result = { found: false, headers: {}, server: {} };
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
current = sectionMatch[1].trim();
|
||||
if (current === target || current === headers) {
|
||||
result.found = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current !== target && current !== headers) {
|
||||
continue;
|
||||
}
|
||||
const keyValue = line.match(/^("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
|
||||
if (!keyValue) {
|
||||
continue;
|
||||
}
|
||||
const key = parseTomlKey(keyValue[1]);
|
||||
const value = parseTomlValue(keyValue[2]);
|
||||
if (current === target) {
|
||||
result.server[key] = value;
|
||||
} else {
|
||||
result.headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTomlKey(value) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseTomlValue(value) {
|
||||
const trimmed = stripTomlInlineComment(value.trim());
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
}
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function stripTomlInlineComment(value) {
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const char = value[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (char === "#" && !inString) {
|
||||
return value.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function printInstallReport(result) {
|
||||
console.log("NODE.DC Ops Codex status");
|
||||
for (const entry of result.reports) {
|
||||
const report = entry.report;
|
||||
console.log("Codex home:", report.codexHome);
|
||||
console.log("Target:", entry.target.reason);
|
||||
for (const check of report.checks) {
|
||||
console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`);
|
||||
}
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log("Result: ready");
|
||||
} else {
|
||||
console.log("Result: needs attention");
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeToolsList(endpoint, token) {
|
||||
return smokeToolsListWithAuthorization(endpoint, `Bearer ${token}`);
|
||||
}
|
||||
|
||||
async function smokeToolsListWithAuthorization(endpoint, authorization) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`MCP smoke check failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = bodyText ? JSON.parse(bodyText) : {};
|
||||
} catch {
|
||||
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
|
||||
}
|
||||
if (data.error) {
|
||||
throw new Error(`MCP smoke check failed: ${JSON.stringify(data.error)}`);
|
||||
}
|
||||
|
||||
const tools = data.result?.tools || [];
|
||||
if (!Array.isArray(tools) || tools.length === 0) {
|
||||
throw new Error("MCP smoke check failed: tools/list returned no tools.");
|
||||
}
|
||||
return tools.length;
|
||||
}
|
||||
|
||||
class UsageError extends Error {}
|
||||
|
||||
main().catch((error) => {
|
||||
if (error instanceof UsageError) {
|
||||
console.error(error.message);
|
||||
console.error("Run ops-codex --help for usage.");
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@nodedc/ops-codex",
|
||||
"version": "0.1.3",
|
||||
"description": "Install scoped NODE.DC Ops MCP and separate read-only Ontology MCP access into local Codex.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ops-codex": "./bin/nodedc-ops-codex.mjs",
|
||||
"nodedc-ops-codex": "./bin/nodedc-ops-codex.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"nodedc",
|
||||
"ops",
|
||||
"tasker",
|
||||
"codex",
|
||||
"mcp"
|
||||
],
|
||||
"license": "UNLICENSED",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
|
||||
const optionalSecret = z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional());
|
||||
const npmPackageSpec = z.string().min(1).max(214).regex(/^\S+$/, "must not contain whitespace");
|
||||
|
||||
const configSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
HOST: z.string().min(1).default("0.0.0.0"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
|
||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
||||
NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"),
|
||||
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(),
|
||||
NODEDC_OPS_CODEX_INSTALL_CHANNEL: z.enum(["tarball", "registry"]).default("tarball"),
|
||||
NODEDC_OPS_CODEX_NPM_SPEC: npmPackageSpec.default("@nodedc/ops-codex"),
|
||||
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200),
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
|
||||
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
|
||||
NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"),
|
||||
NODEDC_ONTOLOGY_CORE_URL: z.string().url().default("http://172.22.0.222:18104"),
|
||||
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: optionalSecret,
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
|
||||
DATABASE_URL: optionalUrl,
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const parsed = configSchema.safeParse(env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
||||
throw new Error(`Invalid Agent Gateway configuration: ${details}`);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
export const allowedAgentScopes = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"project:create",
|
||||
"project:member:add_existing",
|
||||
"issue:read",
|
||||
"issue:create",
|
||||
"issue:update",
|
||||
"issue:move",
|
||||
"issue:comment",
|
||||
"issue:label",
|
||||
"issue:assign",
|
||||
"issue:attachment:write",
|
||||
"issue:structured_blocks:write",
|
||||
] as const;
|
||||
|
||||
export type AgentScope = (typeof allowedAgentScopes)[number];
|
||||
|
||||
export const deniedMvpCapabilities = [
|
||||
"issue:delete",
|
||||
"issue:archive",
|
||||
"comment:delete",
|
||||
"label:delete",
|
||||
"state:create",
|
||||
"state:delete",
|
||||
"project:delete",
|
||||
"workspace:settings",
|
||||
"workspace:member:invite",
|
||||
"workspace:member:remove",
|
||||
"raw_tasker_api",
|
||||
] as const;
|
||||
|
||||
export const taskAuthorPresetScopes: AgentScope[] = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"project:create",
|
||||
"project:member:add_existing",
|
||||
"issue:read",
|
||||
"issue:create",
|
||||
"issue:update",
|
||||
"issue:move",
|
||||
"issue:comment",
|
||||
"issue:label",
|
||||
"issue:assign",
|
||||
"issue:attachment:write",
|
||||
"issue:structured_blocks:write",
|
||||
];
|
||||
|
||||
export const reporterPresetScopes: AgentScope[] = [
|
||||
"workspace:read",
|
||||
"project:read",
|
||||
"issue:read",
|
||||
"issue:update",
|
||||
"issue:comment",
|
||||
"issue:structured_blocks:write",
|
||||
];
|
||||
|
|
@ -0,0 +1,849 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentScope } from "../domain/scopes.js";
|
||||
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
|
||||
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
||||
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
||||
import type { TaskerClient } from "../tasker/client.js";
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
export type McpToolRuntimeDefinition = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiredScopes: AgentScope[];
|
||||
inputSchema: JsonSchema;
|
||||
annotations?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type McpToolResult = {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
structuredContent?: unknown;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type ExecuteToolDeps = {
|
||||
agentsRepository?: AgentsRepository | null;
|
||||
taskerClient: TaskerClient;
|
||||
};
|
||||
|
||||
type ExecuteToolOptions = {
|
||||
source?: "mcp" | "rest";
|
||||
idempotencyKey?: string | null;
|
||||
};
|
||||
|
||||
const emptyInputSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const projectInputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
},
|
||||
required: ["project_id"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const projectAndIssueInputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const structuredBlocksJsonSchema = {
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "text" },
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Visible block title. Put headings here, not inside body markdown.",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Plain block body without a leading markdown heading.",
|
||||
},
|
||||
},
|
||||
required: ["id", "type", "title", "body"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { const: "checker" },
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Visible checklist title. Put headings here, not inside item text.",
|
||||
},
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
checked: { type: "boolean" },
|
||||
},
|
||||
required: ["id", "text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["id", "type", "title", "items"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||
{
|
||||
name: "tasker_get_agent_instructions",
|
||||
title: "NODE.DC Ops: Get Agent Instructions",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return effective Tasker card-writing rules, grants, scopes, and mode expectations. Use this before any Ops card read/write workflow.",
|
||||
requiredScopes: ["workspace:read"],
|
||||
inputSchema: emptyInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_list_projects",
|
||||
title: "NODE.DC Ops: List Granted Projects",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. List Tasker/Ops projects granted to the current Codex agent. Use this instead of Codex Apps workspace widgets.",
|
||||
requiredScopes: ["project:read"],
|
||||
inputSchema: emptyInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_get_project_context",
|
||||
title: "NODE.DC Ops: Get Project Context",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.",
|
||||
requiredScopes: ["project:read"],
|
||||
inputSchema: projectInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_create_project",
|
||||
title: "NODE.DC Ops: Create Project",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create a new Tasker project inside an explicitly granted workspace and extend the current agent grant to the created project.",
|
||||
requiredScopes: ["project:create"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
workspace_slug: { type: "string" },
|
||||
name: { type: "string" },
|
||||
identifier: {
|
||||
type: "string",
|
||||
description: "Optional 1-12 character Tasker project identifier. A unique identifier is generated when omitted.",
|
||||
},
|
||||
description: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["workspace_slug", "name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_search_issues",
|
||||
title: "NODE.DC Ops: Search Cards",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Search Tasker/Ops cards and work items inside one granted project. Use this for 'show cards', 'find cards', and project context requests.",
|
||||
requiredScopes: ["issue:read"],
|
||||
inputSchema: {
|
||||
...projectInputSchema,
|
||||
properties: {
|
||||
...projectInputSchema.properties,
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_get_issue",
|
||||
title: "NODE.DC Ops: Get Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP tool. Return one granted Tasker/Ops card with structured blocks, labels, assignees, full comment bodies, and attachment metadata.",
|
||||
requiredScopes: ["issue:read"],
|
||||
inputSchema: projectAndIssueInputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_create_issue",
|
||||
title: "NODE.DC Ops: Create Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops card with optional NODE.DC structured text/checker blocks.",
|
||||
requiredScopes: ["issue:create"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["project_id", "title"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_update_issue",
|
||||
title: "NODE.DC Ops: Update Card",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card fields without delete, archive, or project transfer.",
|
||||
requiredScopes: ["issue:update"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
workspace_slug: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_update_structured_blocks",
|
||||
title: "NODE.DC Ops: Update Structured Blocks",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Replace NODE.DC structured text/checker blocks in a Tasker/Ops card detail layout.",
|
||||
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
structured_blocks: structuredBlocksJsonSchema,
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "structured_blocks"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_move_issue",
|
||||
title: "NODE.DC Ops: Move Card",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Move a Tasker/Ops card to an existing state in the same granted project.",
|
||||
requiredScopes: ["issue:move"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
state_id: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "state_id"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_append_comment",
|
||||
title: "NODE.DC Ops: Append Comment",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.",
|
||||
requiredScopes: ["issue:comment"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
body: { type: "string" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "body"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_attach_file",
|
||||
title: "NODE.DC Ops: Attach File",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Attach a base64-encoded local file to a granted card through Tasker's existing storage, quota, and deduplication path.",
|
||||
requiredScopes: ["issue:attachment:write"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
file_name: { type: "string", description: "File name only; paths are rejected." },
|
||||
mime_type: { type: "string" },
|
||||
content_base64: {
|
||||
type: "string",
|
||||
maxLength: 7000000,
|
||||
contentEncoding: "base64",
|
||||
description: "Base64 file content. Raw decoded size must not exceed 5 MiB.",
|
||||
},
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "file_name", "mime_type", "content_base64"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
{
|
||||
name: "tasker_ensure_labels",
|
||||
title: "NODE.DC Ops: Ensure Project Labels",
|
||||
description:
|
||||
"Direct NODE.DC Ops MCP write tool. Create missing labels in a granted project and return label ids for subsequent card labeling.",
|
||||
requiredScopes: ["issue:label"],
|
||||
inputSchema: {
|
||||
...projectInputSchema,
|
||||
properties: {
|
||||
...projectInputSchema.properties,
|
||||
labels: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
color: {
|
||||
type: "string",
|
||||
description: "Optional hex color in #RRGGBB format.",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["project_id", "labels"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_set_issue_labels",
|
||||
title: "NODE.DC Ops: Set Card Labels",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Replace card labels with existing or ensured labels from the granted project.",
|
||||
requiredScopes: ["issue:label"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
label_ids: { type: "array", items: { type: "string" } },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "label_ids"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: "tasker_assign_issue",
|
||||
title: "NODE.DC Ops: Assign Card",
|
||||
description: "Direct NODE.DC Ops MCP write tool. Replace card assignees with existing members of the granted project.",
|
||||
requiredScopes: ["issue:assign"],
|
||||
inputSchema: {
|
||||
...projectAndIssueInputSchema,
|
||||
properties: {
|
||||
...projectAndIssueInputSchema.properties,
|
||||
member_ids: { type: "array", items: { type: "string" } },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
required: ["issue_id", "project_id", "member_ids"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
];
|
||||
|
||||
const emptyArgsSchema = z.object({}).default({});
|
||||
const projectArgsSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
});
|
||||
const createProjectArgsSchema = z.object({
|
||||
workspace_slug: z.string().min(1).max(255),
|
||||
name: z.string().min(1).max(255),
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9_]+$/)
|
||||
.optional(),
|
||||
description: z.string().max(20_000).optional(),
|
||||
});
|
||||
const searchIssuesArgsSchema = projectArgsSchema.extend({
|
||||
query: z.string().min(1).optional(),
|
||||
});
|
||||
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
|
||||
const createIssueArgsSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
title: z.string().min(1).max(500),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
});
|
||||
const issueArgsSchema = z.object({
|
||||
issue_id: z.string().min(1),
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
});
|
||||
const updateIssueArgsSchema = issueArgsSchema.extend({
|
||||
title: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
});
|
||||
const structuredBlocksArgsSchema = issueArgsSchema.extend({
|
||||
structured_blocks: structuredBlocksSchema,
|
||||
});
|
||||
const moveIssueArgsSchema = issueArgsSchema.extend({
|
||||
state_id: z.string().min(1),
|
||||
});
|
||||
const commentArgsSchema = issueArgsSchema.extend({
|
||||
body: z.string().min(1).max(20000),
|
||||
});
|
||||
const attachFileArgsSchema = issueArgsSchema
|
||||
.extend({
|
||||
file_name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine((value) => !/[\\/\0]/.test(value), "file_name must not contain a path"),
|
||||
mime_type: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/),
|
||||
content_base64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(7_000_000)
|
||||
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (Buffer.byteLength(input.content_base64, "base64") > 5 * 1024 * 1024) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["content_base64"],
|
||||
message: "Decoded attachment must not exceed 5 MiB.",
|
||||
});
|
||||
}
|
||||
});
|
||||
const ensureLabelsArgsSchema = projectArgsSchema.extend({
|
||||
labels: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
});
|
||||
const labelsArgsSchema = issueArgsSchema.extend({
|
||||
label_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
const assigneesArgsSchema = issueArgsSchema.extend({
|
||||
member_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
|
||||
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
|
||||
}
|
||||
|
||||
export async function executeMcpTool(
|
||||
session: AgentSessionRecord,
|
||||
name: string,
|
||||
rawArguments: unknown,
|
||||
deps: ExecuteToolDeps,
|
||||
options: ExecuteToolOptions = {}
|
||||
): Promise<McpToolResult> {
|
||||
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown MCP tool: ${name}`);
|
||||
}
|
||||
|
||||
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
|
||||
const isWriteTool = tool.annotations?.readOnlyHint !== true;
|
||||
|
||||
if (!isWriteTool) {
|
||||
return executeMcpToolOnce(session, name, args, deps);
|
||||
}
|
||||
|
||||
if (!deps.agentsRepository) {
|
||||
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
|
||||
}
|
||||
|
||||
if (!idempotencyKey) {
|
||||
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
|
||||
}
|
||||
|
||||
const requestHash = hashToolRequest(name, args);
|
||||
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
|
||||
|
||||
if (claim.status === "replay") {
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
});
|
||||
return claim.responseBody as McpToolResult;
|
||||
}
|
||||
|
||||
if (claim.status === "conflict") {
|
||||
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
|
||||
}
|
||||
|
||||
if (claim.status === "in_progress") {
|
||||
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
|
||||
lockedUntil: claim.lockedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeMcpToolOnce(session, name, args, deps);
|
||||
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
arguments: summarizeToolArguments(args),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
|
||||
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
|
||||
source: options.source ?? "mcp",
|
||||
toolName: name,
|
||||
idempotencyKey,
|
||||
error: error instanceof Error ? error.name : "unknown_error",
|
||||
message: error instanceof Error ? error.message : "Unknown tool execution error.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMcpToolOnce(
|
||||
session: AgentSessionRecord,
|
||||
name: string,
|
||||
args: unknown,
|
||||
deps: ExecuteToolDeps
|
||||
): Promise<McpToolResult> {
|
||||
|
||||
switch (name) {
|
||||
case "tasker_get_agent_instructions":
|
||||
emptyArgsSchema.parse(args);
|
||||
requireScope(session, "workspace:read");
|
||||
return asToolResult(buildAgentInstructions(session));
|
||||
case "tasker_list_projects":
|
||||
emptyArgsSchema.parse(args);
|
||||
requireScope(session, "project:read");
|
||||
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
|
||||
case "tasker_get_project_context": {
|
||||
const input = projectArgsSchema.parse(args);
|
||||
requireScope(session, "project:read");
|
||||
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
|
||||
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
|
||||
}
|
||||
case "tasker_create_project": {
|
||||
const input = createProjectArgsSchema.parse(args);
|
||||
if (session.grantSource !== "agent") {
|
||||
throw new ForbiddenError("Project creation requires an agent-scoped local token.");
|
||||
}
|
||||
const sourceGrant = requireWorkspaceScope(session, "project:create", input.workspace_slug);
|
||||
if (!deps.agentsRepository) {
|
||||
throw new ToolExecutionInputError("agent_repository_unavailable", "Agent Gateway persistence is required.", 503);
|
||||
}
|
||||
const payload = await deps.taskerClient.createProject(session, input);
|
||||
const createdProject = extractCreatedProject(payload);
|
||||
await deps.agentsRepository.upsertGrant(session.agent.id, {
|
||||
workspaceSlug: input.workspace_slug,
|
||||
projectId: createdProject.id,
|
||||
scopes: sourceGrant.scopes,
|
||||
mode: sourceGrant.mode,
|
||||
createdByUserId: session.agent.ownerUserId,
|
||||
});
|
||||
return asToolResult(payload);
|
||||
}
|
||||
case "tasker_search_issues": {
|
||||
const input = searchIssuesArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
|
||||
}
|
||||
case "tasker_get_issue": {
|
||||
const input = issueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.getIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_create_issue": {
|
||||
const input = createIssueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.createIssue(session, input));
|
||||
}
|
||||
case "tasker_update_issue": {
|
||||
const input = updateIssueArgsSchema.parse(args);
|
||||
if (input.structured_blocks) {
|
||||
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
||||
} else {
|
||||
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
|
||||
}
|
||||
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_update_structured_blocks": {
|
||||
const input = structuredBlocksArgsSchema.parse(args);
|
||||
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
|
||||
return asToolResult(
|
||||
await deps.taskerClient.updateIssue(session, input.issue_id, {
|
||||
project_id: input.project_id,
|
||||
workspace_slug: input.workspace_slug,
|
||||
structured_blocks: input.structured_blocks,
|
||||
})
|
||||
);
|
||||
}
|
||||
case "tasker_move_issue": {
|
||||
const input = moveIssueArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_append_comment": {
|
||||
const input = commentArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_attach_file": {
|
||||
const input = attachFileArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:attachment:write", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.attachFile(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_ensure_labels": {
|
||||
const input = ensureLabelsArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
|
||||
}
|
||||
case "tasker_set_issue_labels": {
|
||||
const input = labelsArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
|
||||
}
|
||||
case "tasker_assign_issue": {
|
||||
const input = assigneesArgsSchema.parse(args);
|
||||
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
|
||||
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown MCP tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
|
||||
return session.grants.some((grant) => grant.scopes.includes(scope));
|
||||
}
|
||||
|
||||
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
|
||||
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
|
||||
}
|
||||
|
||||
function requireWorkspaceScope(session: AgentSessionRecord, scope: AgentScope, workspaceSlug: string) {
|
||||
requireScope(session, scope);
|
||||
const grant = session.grants.find(
|
||||
(candidate) => candidate.workspaceSlug === workspaceSlug && candidate.scopes.includes(scope)
|
||||
);
|
||||
if (!grant) {
|
||||
throw new ForbiddenError(`Grant for workspace does not include required scope: ${scope}.`);
|
||||
}
|
||||
return grant;
|
||||
}
|
||||
|
||||
function requireProjectScopes(
|
||||
session: AgentSessionRecord,
|
||||
projectId: string,
|
||||
workspaceSlug: string | null | undefined,
|
||||
scopes: AgentScope[]
|
||||
): void {
|
||||
for (const scope of scopes) {
|
||||
requireScope(session, scope);
|
||||
}
|
||||
|
||||
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (!grant.scopes.includes(scope)) {
|
||||
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asToolResult(payload: unknown): McpToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
],
|
||||
structuredContent: payload,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCreatedProject(payload: unknown): { id: string } {
|
||||
if (!isPlainRecord(payload) || !isPlainRecord(payload.project) || typeof payload.project.id !== "string") {
|
||||
throw new ToolExecutionInputError(
|
||||
"tasker_project_response_invalid",
|
||||
"Tasker did not return the created project id.",
|
||||
502
|
||||
);
|
||||
}
|
||||
return { id: payload.project.id };
|
||||
}
|
||||
|
||||
export class ToolExecutionInputError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly httpStatus: number,
|
||||
readonly details?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ToolExecutionInputError";
|
||||
}
|
||||
}
|
||||
|
||||
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
|
||||
if (!isPlainRecord(rawArguments)) {
|
||||
return {
|
||||
args: rawArguments ?? {},
|
||||
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
|
||||
};
|
||||
}
|
||||
|
||||
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
|
||||
|
||||
return {
|
||||
args,
|
||||
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIdempotencyKey(value?: string | null): string | null {
|
||||
const normalized = value?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.length > 200) {
|
||||
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hashToolRequest(name: string, args: unknown): string {
|
||||
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (isPlainRecord(value)) {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function summarizeToolArguments(args: unknown): Record<string, unknown> {
|
||||
if (!isPlainRecord(args)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
workspace_slug: args.workspace_slug,
|
||||
project_id: args.project_id,
|
||||
issue_id: args.issue_id,
|
||||
state_id: args.state_id,
|
||||
project_name: args.name,
|
||||
file_name: args.file_name,
|
||||
attachment_bytes:
|
||||
typeof args.content_base64 === "string" ? Buffer.byteLength(args.content_base64, "base64") : undefined,
|
||||
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
|
||||
has_structured_blocks: Array.isArray(args.structured_blocks),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
|
||||
return {
|
||||
agent: {
|
||||
id: session.agent.id,
|
||||
display_name: session.agent.displayName,
|
||||
owner_user_id: session.agent.ownerUserId,
|
||||
},
|
||||
grants: session.grants.map((grant) => ({
|
||||
workspace_slug: grant.workspaceSlug,
|
||||
project_id: grant.projectId,
|
||||
mode: grant.mode,
|
||||
scopes: grant.scopes,
|
||||
})),
|
||||
grant_source: session.grantSource,
|
||||
rules: {
|
||||
card_structure: [
|
||||
"Keep the main issue description concise and conceptual.",
|
||||
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
|
||||
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
|
||||
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
|
||||
"Use checker blocks with explicit titles for short verifiable phase items.",
|
||||
"After implementation, add a factual implementation block with files touched and validation performed.",
|
||||
],
|
||||
labels: [
|
||||
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
|
||||
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
|
||||
],
|
||||
hard_limits: [
|
||||
"Do not delete or archive issues.",
|
||||
"Create projects only through tasker_create_project when the effective workspace grant includes project:create.",
|
||||
"Attach files only through tasker_attach_file; never upload from arbitrary URLs or server paths.",
|
||||
"Do not create states, workspace invites, or workspace settings changes.",
|
||||
"Only create labels through tasker_ensure_labels inside granted projects.",
|
||||
"Only assign existing project members.",
|
||||
"Only use projects and workspaces present in effective grants.",
|
||||
],
|
||||
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,81 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
import { DatabaseNotConfiguredError } from "../db/pool.js";
|
||||
import type { AgentsRepository } from "../repositories/agents.js";
|
||||
import { parseBearerToken } from "../security/bearer.js";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
type OntologyRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
ontologyCoreUrl: string;
|
||||
ontologyCoreAccessToken?: string;
|
||||
};
|
||||
|
||||
export async function registerOntologyGatewayRoutes(app: FastifyInstance, deps: OntologyRouteDeps): Promise<void> {
|
||||
app.post("/ontology-mcp", async (request, reply) => {
|
||||
if (!deps.agentsRepository) {
|
||||
throw new DatabaseNotConfiguredError();
|
||||
}
|
||||
|
||||
const token = parseBearerToken(request.headers.authorization);
|
||||
const session = await deps.agentsRepository.findActiveSessionByToken(token, "ontology");
|
||||
if (!session) {
|
||||
return reply.status(401).send({
|
||||
ok: false,
|
||||
error: "ontology_agent_unauthorized",
|
||||
message: "Ontology token is inactive, expired, revoked, or has the wrong purpose.",
|
||||
});
|
||||
}
|
||||
|
||||
const upstreamToken = deps.ontologyCoreAccessToken;
|
||||
if (!upstreamToken) {
|
||||
return reply.status(503).send({
|
||||
ok: false,
|
||||
error: "ontology_proxy_not_configured",
|
||||
message: "Ontology Core access token is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(new URL("/mcp", deps.ontologyCoreUrl), {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: readHeader(request.headers.accept) ?? "application/json, text/event-stream",
|
||||
Authorization: `Bearer ${upstreamToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"MCP-Protocol-Version": readHeader(request.headers["mcp-protocol-version"]) ?? MCP_PROTOCOL_VERSION,
|
||||
},
|
||||
body: JSON.stringify(request.body ?? {}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch {
|
||||
return reply.status(503).send({
|
||||
ok: false,
|
||||
error: "ontology_proxy_unavailable",
|
||||
message: "Ontology Core MCP is unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await upstream.text();
|
||||
reply.status(upstream.status);
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reply.header("Content-Type", upstream.headers.get("content-type") ?? "application/json; charset=utf-8");
|
||||
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) {
|
||||
reply.header(header, value);
|
||||
}
|
||||
}
|
||||
return reply.send(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
import { executeMcpTool } from "../mcp/tool-runtime.js";
|
||||
import type { AgentsRepository } from "../repositories/agents.js";
|
||||
import type { TaskerClient } from "../tasker/client.js";
|
||||
import { authenticateAgent } from "./session.js";
|
||||
|
||||
type ToolRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
taskerClient: TaskerClient;
|
||||
};
|
||||
|
||||
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
|
||||
app.post("/api/v1/tools/projects", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_create_project", request.body, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/projects", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { projectId: string };
|
||||
const query = request.query as { workspace_slug?: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_get_project_context",
|
||||
{
|
||||
project_id: params.projectId,
|
||||
workspace_slug: query.workspace_slug,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { projectId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_ensure_labels",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
project_id: params.projectId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
||||
const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request));
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues/:issueId", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const query = request.query as { project_id?: string; workspace_slug?: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_get_issue",
|
||||
{ ...query, issue_id: params.issueId },
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_update_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_move_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_append_comment",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/attachments", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_attach_file",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_set_issue_labels",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const params = request.params as { issueId: string };
|
||||
const result = await executeMcpTool(
|
||||
session,
|
||||
"tasker_assign_issue",
|
||||
{
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
}
|
||||
|
||||
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
|
||||
return {
|
||||
source: "rest",
|
||||
idempotencyKey: readHeader(request.headers["idempotency-key"]),
|
||||
};
|
||||
}
|
||||
|
||||
function requestBodyRecord(body: unknown): Record<string, unknown> {
|
||||
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const TOKEN_PREFIX = "ndcag";
|
||||
const ONTOLOGY_TOKEN_PREFIX = "ndcao";
|
||||
const SETUP_CODE_PREFIX = "ndcsetup";
|
||||
|
||||
export function generateAgentToken(): string {
|
||||
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function generateOntologyAgentToken(): string {
|
||||
return `${ONTOLOGY_TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function hashAgentToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function generateAgentSetupCode(): string {
|
||||
return `${SETUP_CODE_PREFIX}_${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function hashAgentSetupCode(code: string): string {
|
||||
return createHash("sha256").update(code).digest("hex");
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import type { AgentSessionRecord } from "../repositories/agents.js";
|
||||
|
||||
export class TaskerAdapterNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
|
||||
this.name = "TaskerAdapterNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number,
|
||||
readonly payload: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TaskerAdapterError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterUnavailableError extends Error {
|
||||
constructor(readonly causeError: unknown) {
|
||||
super("Tasker internal adapter is unavailable.");
|
||||
this.name = "TaskerAdapterUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskerClientConfig = {
|
||||
baseUrl: string;
|
||||
internalAccessToken?: string;
|
||||
};
|
||||
|
||||
export type TaskerAgentContext = {
|
||||
agentId: string;
|
||||
ownerUserId: string;
|
||||
tokenId: string;
|
||||
};
|
||||
|
||||
export type GrantedProjectInput = {
|
||||
workspace_slug: string;
|
||||
project_id: string | null;
|
||||
mode: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type ListGrantedProjectsInput = {
|
||||
grants: GrantedProjectInput[];
|
||||
};
|
||||
|
||||
export type CreateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type CreateProjectInput = {
|
||||
workspace_slug: string;
|
||||
name: string;
|
||||
identifier?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type GetIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type MoveIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
state_id: string;
|
||||
};
|
||||
|
||||
export type CommentInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type AttachFileInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
content_base64: string;
|
||||
};
|
||||
|
||||
export type SetLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
label_ids: string[];
|
||||
};
|
||||
|
||||
export type EnsureLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
labels: Array<{
|
||||
name: string;
|
||||
color?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AssignIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
member_ids: string[];
|
||||
};
|
||||
|
||||
export class TaskerClient {
|
||||
constructor(private readonly config: TaskerClientConfig) {}
|
||||
|
||||
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/projects/resolve", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: {
|
||||
grants: session.grants.map((grant) => ({
|
||||
workspace_slug: grant.workspaceSlug,
|
||||
project_id: grant.projectId,
|
||||
mode: grant.mode,
|
||||
scopes: grant.scopes,
|
||||
})),
|
||||
} satisfies ListGrantedProjectsInput,
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async createProject(session: AgentSessionRecord, input: CreateProjectInput): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/projects", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams({ project_id: projectId });
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
searchParams.set("query", query);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async getIssue(session: AgentSessionRecord, issueId: string, input: GetIssueInput): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams({ project_id: input.project_id });
|
||||
if (input.workspace_slug) {
|
||||
searchParams.set("workspace_slug", input.workspace_slug);
|
||||
}
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/issues", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
|
||||
method: "PATCH",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async attachFile(session: AgentSessionRecord, issueId: string, input: AttachFileInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/attachments`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<unknown> {
|
||||
if (!this.config.internalAccessToken) {
|
||||
throw new TaskerAdapterNotConfiguredError();
|
||||
}
|
||||
|
||||
const response = await this.fetchTasker(path, input);
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async fetchTasker(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const requestBody = attachAgentMetadata(input.session, input.body);
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
method: input.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.internalAccessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-NODEDC-Agent-Id": input.session.agent.id,
|
||||
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
||||
"X-NODEDC-Agent-Token-Id": input.session.token.id,
|
||||
},
|
||||
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TaskerAdapterUnavailableError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
|
||||
if (body === undefined || !isPlainRecord(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
...body,
|
||||
_agent: {
|
||||
display_name: session.agent.displayName,
|
||||
avatar_url: session.agent.avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,179 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""plane URL Configuration"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path, re_path
|
||||
from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularRedocView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
from plane.authentication.views.nodedc_logout import (
|
||||
NodeDCFrontChannelLogoutEndpoint,
|
||||
NodeDCInternalSessionLogoutEndpoint,
|
||||
)
|
||||
from plane.authentication.views.nodedc_agent_adapter import (
|
||||
NodeDCAgentIssueAssigneesEndpoint,
|
||||
NodeDCAgentIssueAttachmentEndpoint,
|
||||
NodeDCAgentIssueCommentEndpoint,
|
||||
NodeDCAgentIssueLabelsEndpoint,
|
||||
NodeDCAgentIssueListEndpoint,
|
||||
NodeDCAgentIssueMoveEndpoint,
|
||||
NodeDCAgentIssueUpdateEndpoint,
|
||||
NodeDCAgentProjectContextEndpoint,
|
||||
NodeDCAgentProjectCreateEndpoint,
|
||||
NodeDCAgentProjectLabelsEnsureEndpoint,
|
||||
NodeDCAgentProjectResolveEndpoint,
|
||||
)
|
||||
from plane.authentication.views.nodedc_workspace_adapter import (
|
||||
NodeDCInternalProjectMembershipEnsureEndpoint,
|
||||
NodeDCInternalProjectMembershipRemoveEndpoint,
|
||||
NodeDCInternalUserProfileSyncEndpoint,
|
||||
NodeDCInternalWorkspaceInviteApproveEndpoint,
|
||||
NodeDCInternalWorkspaceInviteRejectEndpoint,
|
||||
NodeDCInternalWorkspaceListEndpoint,
|
||||
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
|
||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
|
||||
)
|
||||
|
||||
handler404 = "plane.app.views.error_404.custom_404_view"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"api/internal/nodedc/logout/",
|
||||
NodeDCInternalSessionLogoutEndpoint.as_view(),
|
||||
name="nodedc-internal-session-logout",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspaces/",
|
||||
NodeDCInternalWorkspaceListEndpoint.as_view(),
|
||||
name="nodedc-internal-workspaces",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/users/profile-sync/",
|
||||
NodeDCInternalUserProfileSyncEndpoint.as_view(),
|
||||
name="nodedc-internal-user-profile-sync",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-memberships/ensure/",
|
||||
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-membership-ensure",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-memberships/remove/",
|
||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-membership-remove",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-invite-requests/approve/",
|
||||
NodeDCInternalWorkspaceInviteApproveEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-invite-approve",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-invite-requests/reject/",
|
||||
NodeDCInternalWorkspaceInviteRejectEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-invite-reject",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/project-memberships/ensure/",
|
||||
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),
|
||||
name="nodedc-internal-project-membership-ensure",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/project-memberships/remove/",
|
||||
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
|
||||
name="nodedc-internal-project-membership-remove",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/projects/resolve",
|
||||
NodeDCAgentProjectResolveEndpoint.as_view(),
|
||||
name="nodedc-agent-project-resolve",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/projects",
|
||||
NodeDCAgentProjectCreateEndpoint.as_view(),
|
||||
name="nodedc-agent-project-create",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/projects/<uuid:project_id>/context",
|
||||
NodeDCAgentProjectContextEndpoint.as_view(),
|
||||
name="nodedc-agent-project-context",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/projects/<uuid:project_id>/labels/ensure",
|
||||
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
|
||||
name="nodedc-agent-project-labels-ensure",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues",
|
||||
NodeDCAgentIssueListEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-list",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>",
|
||||
NodeDCAgentIssueUpdateEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-update",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/move",
|
||||
NodeDCAgentIssueMoveEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-move",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments",
|
||||
NodeDCAgentIssueCommentEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-comment",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/attachments",
|
||||
NodeDCAgentIssueAttachmentEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-attachment",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
|
||||
NodeDCAgentIssueLabelsEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-labels",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/assignees",
|
||||
NodeDCAgentIssueAssigneesEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-assignees",
|
||||
),
|
||||
path("api/", include("plane.app.urls")),
|
||||
path("api/public/", include("plane.space.urls")),
|
||||
path("api/instances/", include("plane.license.urls")),
|
||||
path("api/v1/", include("plane.api.urls")),
|
||||
path("auth/", include("plane.authentication.urls")),
|
||||
path(
|
||||
"logout",
|
||||
NodeDCFrontChannelLogoutEndpoint.as_view(),
|
||||
name="nodedc-frontchannel-logout",
|
||||
),
|
||||
path("", include("plane.web.urls")),
|
||||
]
|
||||
|
||||
if settings.ENABLE_DRF_SPECTACULAR:
|
||||
urlpatterns += [
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path(
|
||||
"api/schema/swagger-ui/",
|
||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||
name="swagger-ui",
|
||||
),
|
||||
path(
|
||||
"api/schema/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="schema"),
|
||||
name="redoc",
|
||||
),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns = [re_path(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns
|
||||
except ImportError:
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"schemaVersion": "nodedc.ops-mcp-workspace-tools.release.v1",
|
||||
"release": "20260718-001",
|
||||
"components": {
|
||||
"tasker": {
|
||||
"artifactId": "tasker-ops-mcp-capabilities-20260718-001",
|
||||
"files": [
|
||||
"plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py",
|
||||
"plane-src/apps/api/plane/urls.py",
|
||||
"plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx"
|
||||
],
|
||||
"predecessors": {
|
||||
"plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py": "a7653f4b5d3eff905de6cac0013e44a492a4b38fc075af5330e928a367529581",
|
||||
"plane-src/apps/api/plane/urls.py": "413c33527ea564735b38ce6a5531aaa93018c1dba3d09f6902879f2b98920d85",
|
||||
"plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx": "fdee183d332327f72f000429f3ad3f780937aea72b7c03a47deaba6b4cc2a050"
|
||||
},
|
||||
"newPaths": []
|
||||
},
|
||||
"ops-agents": {
|
||||
"artifactId": "ops-agents-workspace-tools-ontology-20260718-001",
|
||||
"files": [
|
||||
"README.md",
|
||||
"docker-compose.synology.yml",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/MCP_TOOLS_CONTRACT.md",
|
||||
"docs/TASKER_API_AUDIT.md",
|
||||
"docs/THREAT_MODEL.md",
|
||||
"migrations/006_agent_credential_purpose.sql",
|
||||
"src/app.ts",
|
||||
"src/assets/codex-npm/README.md",
|
||||
"src/assets/codex-npm/bin/nodedc-ops-codex.mjs",
|
||||
"src/assets/codex-npm/package.json",
|
||||
"src/config.ts",
|
||||
"src/domain/scopes.ts",
|
||||
"src/mcp/tool-runtime.ts",
|
||||
"src/repositories/agents.ts",
|
||||
"src/routes/agents.ts",
|
||||
"src/routes/ontology.ts",
|
||||
"src/routes/tools.ts",
|
||||
"src/security/agent-access.ts",
|
||||
"src/tasker/client.ts"
|
||||
],
|
||||
"predecessors": {
|
||||
"README.md": "e01239462fe92330fe3532abe4dd8f566e8ff3d47584611723ae1689dec3e7f9",
|
||||
"docker-compose.synology.yml": "944034b86fd5b22acea4314c9217d7e12febee5cd757365c32ebbcaccc64f3a8",
|
||||
"docs/ARCHITECTURE.md": "c2db9552eb8324b429b8d87ffdd7bb527b9b155f9763ad5f8e3629b5c7c8cdcf",
|
||||
"docs/MCP_TOOLS_CONTRACT.md": "6699716ede1fab93216c4c54a52c2a834f4b4f53cae62e5e3ed5bb6d0c5fa854",
|
||||
"docs/TASKER_API_AUDIT.md": "c8febffb41f2fb4e8d0edab9eb0b9f23419aafdfcc676e7976f52a9dfc6075a4",
|
||||
"docs/THREAT_MODEL.md": "b7fb71693e25a8ab84db1e811f95858aafbccea6e6ad2eca8b597b3563a83657",
|
||||
"src/app.ts": "4ebeae1ee45447272c4817667a403f0ed9addc6ff7f27af23ebadbb451a3e17b",
|
||||
"src/assets/codex-npm/README.md": "cc4a7c6558570b0dd93e8a362fc91282a351e71afad161ec44c72473d72490bc",
|
||||
"src/assets/codex-npm/bin/nodedc-ops-codex.mjs": "1aee52f7b748eeccee5fc9e8cea511b60ced879c51fe138e0f820181ee85324f",
|
||||
"src/assets/codex-npm/package.json": "300a88a05265c68098bedda6b60ed9e9cd0d198520bf1eddaa3a1b2679bed2ea",
|
||||
"src/config.ts": "0b65404fa52c23b6fbc628b8641f04213ed57698c8b0192daf9741ed98e508cc",
|
||||
"src/domain/scopes.ts": "85d9400171019cbe10dd4a50959df4952a941f16d0657006d2402b7a9e126672",
|
||||
"src/mcp/tool-runtime.ts": "fac6e5596a1481cdaf8c7b5d275758eb440fc54d2e938fe05dda82b018a928a5",
|
||||
"src/repositories/agents.ts": "9b76cd605225d9781f04d9f51894560216396b02b09ceefb5783e82ffe663fca",
|
||||
"src/routes/agents.ts": "a9115be462b72ac2eeec1e42cd6fc829465cc0f9f16b95a4837fa6bce300876e",
|
||||
"src/routes/tools.ts": "05fbb324e83096535bc4ef7e84302e43705bf7568c80381f1f5f15f98bd04ee2",
|
||||
"src/security/agent-access.ts": "fc734c49d534de9fdec2f2851741b1dc1ea57724d4d7cf363a0da4e4458002cd",
|
||||
"src/tasker/client.ts": "685056d78bb5dbf4c973a6f5505862e0cb012dddeca0ab302580860372e9902e"
|
||||
},
|
||||
"newPaths": [
|
||||
"migrations/006_agent_credential_purpose.sql",
|
||||
"src/routes/ontology.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env python3
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||
BUILDER_PATH = SCRIPT_DIR / "build-ops-mcp-workspace-tools-artifacts.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
SOURCE_ROOT = SCRIPT_DIR / "ops-mcp-workspace-tools"
|
||||
RELEASE = json.loads((SOURCE_ROOT / "release.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader("nodedc_ops_workspace_tools_runner", str(RUNNER_PATH))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class OpsMcpWorkspaceToolsArtifactTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-ops-mcp-workspace-tools-")
|
||||
cls.root = Path(cls.temporary.name)
|
||||
cls.builds = []
|
||||
for index in range(2):
|
||||
output = cls.root / ("build-" + str(index))
|
||||
output.mkdir()
|
||||
env = os.environ.copy()
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER_PATH), RELEASE["release"]],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
cls.builds.append(json.loads(result.stdout))
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def component_result(self, build_index, component):
|
||||
return next(
|
||||
result
|
||||
for result in self.builds[build_index]["results"]
|
||||
if result["component"] == component
|
||||
)
|
||||
|
||||
def test_builder_updates_existing_components_without_runner_change(self):
|
||||
self.assertEqual(self.builds[0]["deployOrder"], ["tasker", "ops-agents"])
|
||||
self.assertFalse(self.builds[0]["runnerChanged"])
|
||||
self.assertEqual(set(RELEASE["components"]), {"tasker", "ops-agents"})
|
||||
|
||||
def test_artifacts_are_byte_reproducible_and_runner_accepted(self):
|
||||
for component, descriptor in RELEASE["components"].items():
|
||||
first = Path(self.component_result(0, component)["artifact"])
|
||||
second = Path(self.component_result(1, component)["artifact"])
|
||||
first_bytes = first.read_bytes()
|
||||
self.assertEqual(first_bytes, second.read_bytes())
|
||||
self.assertEqual(first_bytes[4:8], bytes(4))
|
||||
self.assertEqual(
|
||||
self.component_result(0, component)["artifactSha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
manifest, entries, payload = RUNNER.load_artifact(first, Path(directory))
|
||||
self.assertEqual(manifest["component"], component)
|
||||
self.assertEqual(manifest["id"], descriptor["artifactId"])
|
||||
self.assertEqual(entries, descriptor["files"])
|
||||
for relative_path in entries:
|
||||
self.assertTrue((payload / relative_path).is_file())
|
||||
|
||||
def test_predecessor_and_new_path_partition_is_complete(self):
|
||||
for component, descriptor in RELEASE["components"].items():
|
||||
result = self.component_result(0, component)
|
||||
predecessor_path = Path(result["predecessorChecks"])
|
||||
new_paths_path = Path(result["newPaths"])
|
||||
predecessor_lines = [
|
||||
line for line in predecessor_path.read_text(encoding="utf-8").splitlines() if line
|
||||
]
|
||||
new_path_lines = [
|
||||
line for line in new_paths_path.read_text(encoding="utf-8").splitlines() if line
|
||||
]
|
||||
self.assertEqual(len(predecessor_lines), len(descriptor["predecessors"]))
|
||||
self.assertEqual(len(new_path_lines), len(descriptor["newPaths"]))
|
||||
self.assertEqual(
|
||||
set(descriptor["files"]),
|
||||
set(descriptor["predecessors"]) | set(descriptor["newPaths"]),
|
||||
)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(predecessor_path.read_bytes()).hexdigest(),
|
||||
result["predecessorChecksSha256"],
|
||||
)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(new_paths_path.read_bytes()).hexdigest(),
|
||||
result["newPathsSha256"],
|
||||
)
|
||||
|
||||
def test_ops_contract_contains_isolated_ontology_and_workspace_tools(self):
|
||||
ops = SOURCE_ROOT / "overlays" / "ops-agents"
|
||||
runtime = (ops / "src/mcp/tool-runtime.ts").read_text(encoding="utf-8")
|
||||
routes = (ops / "src/routes/ontology.ts").read_text(encoding="utf-8")
|
||||
migration = (
|
||||
ops / "migrations/006_agent_credential_purpose.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
installer = (
|
||||
ops / "src/assets/codex-npm/bin/nodedc-ops-codex.mjs"
|
||||
).read_text(encoding="utf-8")
|
||||
for tool in ("tasker_get_issue", "tasker_create_project", "tasker_attach_file"):
|
||||
self.assertIn(tool, runtime)
|
||||
self.assertIn('findActiveSessionByToken(token, "ontology")', routes)
|
||||
self.assertIn("purpose IN ('ops', 'ontology')", migration)
|
||||
self.assertIn("ONTOLOGY_SERVER_NAME", installer)
|
||||
self.assertIn("Ops MCP smoke tools", installer)
|
||||
self.assertIn("Ontology MCP smoke tools", installer)
|
||||
|
||||
def test_tasker_contract_uses_existing_storage_and_admin_gate(self):
|
||||
tasker = SOURCE_ROOT / "overlays" / "tasker"
|
||||
adapter = (
|
||||
tasker
|
||||
/ "plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py"
|
||||
).read_text(encoding="utf-8")
|
||||
urls = (tasker / "plane-src/apps/api/plane/urls.py").read_text(encoding="utf-8")
|
||||
self.assertIn("ProjectSerializer", adapter)
|
||||
self.assertIn("workspace_admin_required", adapter)
|
||||
self.assertIn("MAX_AGENT_ATTACHMENT_BYTES = 5 * 1024 * 1024", adapter)
|
||||
self.assertIn("S3Storage(is_server=True)", adapter)
|
||||
self.assertIn("get_project_storage_quota_state", adapter)
|
||||
self.assertIn("finalize_uploaded_file_asset", adapter)
|
||||
self.assertIn("NodeDCAgentIssueAttachmentEndpoint", urls)
|
||||
self.assertNotIn("DELETE", adapter)
|
||||
|
||||
def test_existing_outputs_are_not_overwritten(self):
|
||||
output = Path(self.component_result(0, "tasker")["artifact"]).parent
|
||||
env = os.environ.copy()
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER_PATH), RELEASE["release"]],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("output_already_exists", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in New Issue