API - CODEX AGENTS: setup packet for local Codex
This commit is contained in:
parent
2c1e83dd37
commit
fd43f503dd
15
README.md
15
README.md
|
|
@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Acceptance:
|
|||
- local Codex can move card state;
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||
});
|
||||
|
||||
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 registerMcpRoutes(app, { agentsRepository, taskerClient });
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from "zod";
|
|||
|
||||
import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
|
||||
import { DatabaseNotConfiguredError } from "../db/pool.js";
|
||||
import { getToolsForSession } from "../mcp/tool-runtime.js";
|
||||
import { mcpToolDefinitions } from "../mcp/tools.js";
|
||||
import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js";
|
||||
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
||||
|
|
@ -10,6 +11,7 @@ import { generateAgentToken } from "../security/tokens.js";
|
|||
|
||||
type AgentRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
publicUrl: string;
|
||||
};
|
||||
|
||||
const agentParamsSchema = z.object({
|
||||
|
|
@ -61,13 +63,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
|
|||
}));
|
||||
|
||||
app.get("/api/v1/agent-session", async (request) => {
|
||||
const repository = requireRepository(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.");
|
||||
}
|
||||
const session = await authenticateAgentSession(request.headers.authorization, deps);
|
||||
|
||||
return {
|
||||
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) => {
|
||||
const repository = requireRepository(deps);
|
||||
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> {
|
||||
return {
|
||||
id: agent.id,
|
||||
|
|
@ -293,3 +310,84 @@ function serializeAgentSession(session: AgentSessionRecord): Record<string, unkn
|
|||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,19 @@ try {
|
|||
});
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/issues",
|
||||
|
|
@ -136,6 +149,7 @@ try {
|
|||
token_prefix: token.split("_")[0],
|
||||
checks: {
|
||||
session_auth: "passed",
|
||||
setup_packet: "passed",
|
||||
denied_without_scope: "passed",
|
||||
idempotency_required: "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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`Gateway smoke assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue