API - CODEX AGENTS: setup packet for local Codex

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 20:15:42 +03:00
parent 2c1e83dd37
commit fd43f503dd
6 changed files with 155 additions and 9 deletions

View File

@ -26,6 +26,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
- Internal REST endpoints for agent profile, grant, and token lifecycle. - Internal REST endpoints for agent profile, grant, and token lifecycle.
- Opaque agent tokens are generated once and stored only as SHA-256 hashes. - 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. - 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.
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter. - 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. - MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes. - Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes.
@ -75,6 +76,20 @@ curl http://127.0.0.1:4100/api/v1/agent-session \
Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added.
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: Call MCP tools through the JSON-RPC endpoint:
```bash ```bash

View File

@ -111,7 +111,7 @@ Acceptance:
- local Codex can move card state; - local Codex can move card state;
- local Codex cannot delete/archive. - local Codex cannot delete/archive.
Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, idempotency handling, audit events, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay. Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, idempotency handling, audit events, and Tasker adapter calls. `/api/v1/agent-session/setup` returns the MCP config template and generated AGENTS.md instruction pack. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay.
## Phase 6. Agent identity ## Phase 6. Agent identity

View File

@ -336,6 +336,19 @@ tasker_raw_api_request
`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. `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: Minimum instruction:
```text ```text

View File

@ -115,7 +115,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
}); });
await registerHealthRoutes(app, config, pool); await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, { agentsRepository }); await registerAgentRoutes(app, { agentsRepository, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL });
await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient });

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
import { DatabaseNotConfiguredError } from "../db/pool.js"; import { DatabaseNotConfiguredError } from "../db/pool.js";
import { getToolsForSession } from "../mcp/tool-runtime.js";
import { mcpToolDefinitions } from "../mcp/tools.js"; import { mcpToolDefinitions } from "../mcp/tools.js";
import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js";
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
@ -10,6 +11,7 @@ import { generateAgentToken } from "../security/tokens.js";
type AgentRouteDeps = { type AgentRouteDeps = {
agentsRepository: AgentsRepository | null; agentsRepository: AgentsRepository | null;
publicUrl: string;
}; };
const agentParamsSchema = z.object({ const agentParamsSchema = z.object({
@ -61,13 +63,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
})); }));
app.get("/api/v1/agent-session", async (request) => { app.get("/api/v1/agent-session", async (request) => {
const repository = requireRepository(deps); const session = await authenticateAgentSession(request.headers.authorization, deps);
const token = parseBearerToken(request.headers.authorization);
const session = await repository.findActiveSessionByToken(token);
if (!session) {
throw new UnauthorizedError("Agent token is inactive, expired, or revoked.");
}
return { return {
ok: true, ok: true,
@ -75,6 +71,15 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
}; };
}); });
app.get("/api/v1/agent-session/setup", async (request) => {
const session = await authenticateAgentSession(request.headers.authorization, deps);
return {
ok: true,
setup: buildSetupPacket(session, deps.publicUrl),
};
});
app.post("/api/v1/agents", async (request, reply) => { app.post("/api/v1/agents", async (request, reply) => {
const repository = requireRepository(deps); const repository = requireRepository(deps);
const body = createAgentBodySchema.parse(request.body); const body = createAgentBodySchema.parse(request.body);
@ -245,6 +250,18 @@ function sendNotFound(reply: FastifyReply, error: string): FastifyReply {
}); });
} }
async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise<AgentSessionRecord> {
const repository = requireRepository(deps);
const token = parseBearerToken(authorization);
const session = await repository.findActiveSessionByToken(token);
if (!session) {
throw new UnauthorizedError("Agent token is inactive, expired, or revoked.");
}
return session;
}
function serializeAgent(agent: AgentRecord): Record<string, unknown> { function serializeAgent(agent: AgentRecord): Record<string, unknown> {
return { return {
id: agent.id, id: agent.id,
@ -293,3 +310,84 @@ function serializeAgentSession(session: AgentSessionRecord): Record<string, unkn
modes: [...new Set(session.grants.map((grant) => grant.mode))], modes: [...new Set(session.grants.map((grant) => grant.mode))],
}; };
} }
function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Record<string, unknown> {
const endpoint = new URL("/mcp", publicUrl).toString();
const tokenPlaceholder = "<agent-token>";
return {
mcp_server: {
name: "nodedc_tasker",
transport: "streamable_http_json_rpc",
url: endpoint,
headers: {
Authorization: `Bearer ${tokenPlaceholder}`,
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
},
},
codex_config_template: {
mcp_servers: {
nodedc_tasker: {
url: endpoint,
headers: {
Authorization: `Bearer ${tokenPlaceholder}`,
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
},
},
},
},
available_tools: getToolsForSession(session).map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
required_scopes: tool.requiredScopes,
})),
agents_md: buildAgentsMd(session, endpoint),
};
}
function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
const grants = session.grants
.map((grant) => `- workspace=${grant.workspaceSlug}; project=${grant.projectId ?? "*"}; mode=${grant.mode}; scopes=${grant.scopes.join(",")}`)
.join("\n");
const tools = getToolsForSession(session)
.map((tool) => `- ${tool.name}: ${tool.description}`)
.join("\n");
return [
"# NODE.DC Tasker Agent Rules",
"",
`MCP endpoint: ${endpoint}`,
"",
"## Startup",
"",
"- Call `tasker_get_agent_instructions` before creating or changing Tasker cards.",
"- Call `tasker_list_projects` and `tasker_get_project_context` before writing into a project.",
"- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.",
"",
"## Write Safety",
"",
"- Every write tool call must include a unique `idempotency_key`.",
"- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.",
"- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.",
"- Only assign existing project members returned by project context.",
"",
"## Card Writing",
"",
"- Keep card titles concise and operational.",
"- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.",
"- Put short verifiable work items into checker blocks.",
"- After code work, update the related card with factual files touched and validation performed.",
"",
"## Effective Grants",
"",
grants || "- No grants available.",
"",
"## Available Tools",
"",
tools || "- No tools available for current grants.",
"",
].join("\n");
}

View File

@ -71,6 +71,19 @@ try {
}); });
assertStatus(sessionResponse.statusCode, 200, sessionResponse.body); assertStatus(sessionResponse.statusCode, 200, sessionResponse.body);
const setupResponse = await app.inject({
method: "GET",
url: "/api/v1/agent-session/setup",
headers: {
Authorization: `Bearer ${token}`,
},
});
assertStatus(setupResponse.statusCode, 200, setupResponse.body);
const setupPayload = JSON.parse(setupResponse.body);
assert(setupPayload.setup?.mcp_server?.url?.endsWith("/mcp"), "setup packet includes MCP endpoint");
assert(setupPayload.setup?.agents_md?.includes("idempotency_key"), "setup packet includes idempotency rules");
assert(!JSON.stringify(setupPayload).includes(token), "setup packet does not echo raw token");
const deniedCreateResponse = await app.inject({ const deniedCreateResponse = await app.inject({
method: "POST", method: "POST",
url: "/api/v1/tools/issues", url: "/api/v1/tools/issues",
@ -136,6 +149,7 @@ try {
token_prefix: token.split("_")[0], token_prefix: token.split("_")[0],
checks: { checks: {
session_auth: "passed", session_auth: "passed",
setup_packet: "passed",
denied_without_scope: "passed", denied_without_scope: "passed",
idempotency_required: "passed", idempotency_required: "passed",
allowed_request_reaches_tasker_boundary: "passed", allowed_request_reaches_tasker_boundary: "passed",
@ -154,3 +168,9 @@ function assertStatus(actual: number, expected: number, body: string): void {
throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`); throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`);
} }
} }
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(`Gateway smoke assertion failed: ${message}`);
}
}