diff --git a/.env.example b/.env.example index 02a3fc7..cb3f193 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ PORT=4100 LOG_LEVEL=info NODEDC_AGENT_GATEWAY_PUBLIC_URL=http://agents.local.nodedc +NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token diff --git a/README.md b/README.md index 6d09e18..35ac0c0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are - 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. @@ -59,6 +60,7 @@ 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"}' ``` @@ -67,6 +69,7 @@ Create a token and inspect effective agent session: ```bash TOKEN=$(curl -sS -X POST http://127.0.0.1:4100/api/v1/agents//tokens \ + -H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"Local Codex token"}' | jq -r .token) @@ -74,7 +77,14 @@ curl http://127.0.0.1:4100/api/v1/agent-session \ -H "Authorization: Bearer $TOKEN" ``` -Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. +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//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. Generate a local Codex setup packet: @@ -131,6 +141,7 @@ DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-passwo 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='' \ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 75707fc..e1f21d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -86,6 +86,8 @@ Agent Gateway owns: 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. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 10be5db..bd7be5d 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -113,6 +113,8 @@ Acceptance: 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. +Owner lifecycle API is now split from public agent traffic. Management routes require `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`, and Tasker UI should use `/api/internal/v1/owners/:ownerUserId/agents...` through a backend proxy. The owner routes verify that the requested agent belongs to the owner before returning grants, tokens, setup packets, profile updates, or revoke actions. + ## Phase 6. Agent identity Tasker/Gateway integration: diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index cb3e5e9..2e8848e 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -75,6 +75,17 @@ Mitigation: - 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. diff --git a/src/app.ts b/src/app.ts index ad4b548..7af1551 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { registerMcpRoutes } from "./routes/mcp.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 { @@ -58,6 +59,15 @@ export async function buildApp(config: AppConfig): Promise { 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, @@ -115,7 +125,11 @@ export async function buildApp(config: AppConfig): Promise { }); await registerHealthRoutes(app, config, pool); - await registerAgentRoutes(app, { agentsRepository, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL }); + await registerAgentRoutes(app, { + agentsRepository, + publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL, + internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN, + }); await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient }); diff --git a/src/config.ts b/src/config.ts index a0c64ec..34e643d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ const configSchema = z.object({ 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://agents.local.nodedc"), + NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(), 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_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(), @@ -26,4 +27,3 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { return parsed.data; } - diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 48ac843..1b4d04b 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -110,6 +110,12 @@ export type CreateAgentInput = { avatarUrl?: string | null; }; +export type UpdateAgentProfileInput = { + displayName?: string; + avatarUrl?: string | null; + actorUserId?: string; +}; + export type UpsertGrantInput = { workspaceSlug: string; projectId?: string | null; @@ -169,6 +175,33 @@ export class AgentsRepository { return result.rows[0] ? mapAgent(result.rows[0]) : null; } + async updateAgentProfile(agentId: string, input: UpdateAgentProfileInput): Promise { + const currentAgent = await this.getAgent(agentId); + + if (!currentAgent) { + return null; + } + + const displayName = input.displayName ?? currentAgent.displayName; + const avatarUrl = input.avatarUrl === undefined ? currentAgent.avatarUrl : input.avatarUrl; + const result = await this.pool.query( + ` + UPDATE agents + SET display_name = $2, avatar_url = $3, updated_at = now() + WHERE id = $1 + RETURNING * + `, + [agentId, displayName, avatarUrl] + ); + + await this.createAuditEvent(agentId, "agent.profile.updated", input.actorUserId, { + displayName, + avatarUrl, + }); + + return mapAgent(result.rows[0]); + } + async revokeAgent(agentId: string, actorUserId?: string): Promise { const client = await this.pool.connect(); diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 9df0dd7..b782607 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance, FastifyReply } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; @@ -7,21 +7,35 @@ 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"; +import { requireInternalAccess } from "../security/internal.js"; import { generateAgentToken } from "../security/tokens.js"; type AgentRouteDeps = { agentsRepository: AgentsRepository | null; publicUrl: string; + internalAccessToken?: string; }; const agentParamsSchema = z.object({ agentId: z.string().uuid(), }); +const ownerParamsSchema = z.object({ + ownerUserId: z.string().min(1), +}); + +const ownerAgentParamsSchema = ownerParamsSchema.extend({ + agentId: z.string().uuid(), +}); + const tokenParamsSchema = agentParamsSchema.extend({ tokenId: z.string().uuid(), }); +const ownerTokenParamsSchema = ownerAgentParamsSchema.extend({ + tokenId: z.string().uuid(), +}); + const listAgentsQuerySchema = z.object({ owner_user_id: z.string().min(1).optional(), }); @@ -33,6 +47,18 @@ const createAgentBodySchema = z.object({ avatar_url: z.string().url().nullish(), }); +const createOwnerAgentBodySchema = z.object({ + owner_email: z.string().email().nullish(), + display_name: z.string().min(1).max(120), + avatar_url: z.string().url().nullish(), +}); + +const updateOwnerAgentBodySchema = z.object({ + display_name: z.string().min(1).max(120).optional(), + avatar_url: z.string().url().nullable().optional(), + actor_user_id: z.string().min(1).optional(), +}); + const upsertGrantBodySchema = z.object({ workspace_slug: z.string().min(1), project_id: z.string().min(1).nullish(), @@ -81,6 +107,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.post("/api/v1/agents", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const body = createAgentBodySchema.parse(request.body); const agent = await repository.createAgent({ @@ -97,6 +124,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.get("/api/v1/agents", async (request) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const query = listAgentsQuerySchema.parse(request.query); const agents = await repository.listAgents(query.owner_user_id); @@ -108,6 +136,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.get("/api/v1/agents/:agentId", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); @@ -123,6 +152,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.post("/api/v1/agents/:agentId/revoke", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); @@ -139,6 +169,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.post("/api/v1/agents/:agentId/grants", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = upsertGrantBodySchema.parse(request.body); @@ -163,6 +194,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.get("/api/v1/agents/:agentId/grants", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); @@ -179,6 +211,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = createTokenBodySchema.parse(request.body ?? {}); @@ -203,6 +236,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.get("/api/v1/agents/:agentId/tokens", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); @@ -219,6 +253,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); app.post("/api/v1/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { + requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId, tokenId } = tokenParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); @@ -233,6 +268,231 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute token_record: serializeToken(tokenRecord), }; }); + + app.get("/api/internal/v1/owners/:ownerUserId/agents", async (request) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId } = ownerParamsSchema.parse(request.params); + const agents = await repository.listAgents(ownerUserId); + + return { + ok: true, + agents: agents.map(serializeAgent), + }; + }); + + app.post("/api/internal/v1/owners/:ownerUserId/agents", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId } = ownerParamsSchema.parse(request.params); + const body = createOwnerAgentBodySchema.parse(request.body); + const agent = await repository.createAgent({ + ownerUserId, + ownerEmail: body.owner_email, + displayName: body.display_name, + avatarUrl: body.avatar_url, + }); + + return reply.status(201).send({ + ok: true, + agent: serializeAgent(agent), + }); + }); + + app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const [grants, tokens] = await Promise.all([repository.listGrants(agentId), repository.listTokens(agentId)]); + return { + ok: true, + agent: serializeAgent(agent), + grants: grants.map(serializeGrant), + tokens: tokens.map(serializeToken), + }; + }); + + app.patch("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = updateOwnerAgentBodySchema.parse(request.body ?? {}); + const existingAgent = await repository.getAgent(agentId); + + if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const agent = await repository.updateAgentProfile(agentId, { + displayName: body.display_name, + avatarUrl: body.avatar_url, + actorUserId: body.actor_user_id ?? ownerUserId, + }); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + return { + ok: true, + agent: serializeAgent(agent), + }; + }); + + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/revoke", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = actorBodySchema.parse(request.body ?? {}); + const existingAgent = await repository.getAgent(agentId); + + if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const agent = await repository.revokeAgent(agentId, body.actor_user_id ?? ownerUserId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + return { + ok: true, + agent: serializeAgent(agent), + }; + }); + + app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.listGrants(agentId); + return { + ok: true, + grants: grants.map(serializeGrant), + }; + }); + + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = upsertGrantBodySchema.omit({ created_by_user_id: true }).parse(request.body); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const grant = await repository.upsertGrant(agentId, { + workspaceSlug: body.workspace_slug, + projectId: body.project_id, + scopes: body.scopes, + mode: body.mode, + createdByUserId: ownerUserId, + }); + + return reply.status(201).send({ + ok: true, + grant: serializeGrant(grant), + }); + }); + + app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const tokens = await repository.listTokens(agentId); + return { + ok: true, + tokens: tokens.map(serializeToken), + }; + }); + + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = createTokenBodySchema.parse(request.body ?? {}); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const token = generateAgentToken(); + const tokenRecord = await repository.createToken(agentId, { + token, + name: body.name, + expiresAt: body.expires_at, + }); + const grants = await repository.listGrants(agentId); + + return reply.status(201).send({ + ok: true, + token, + token_record: serializeToken(tokenRecord), + setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl), + }); + }); + + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId, tokenId } = ownerTokenParamsSchema.parse(request.params); + const body = actorBodySchema.parse(request.body ?? {}); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id ?? ownerUserId); + + if (!tokenRecord) { + return sendNotFound(reply, "token_not_found"); + } + + return { + ok: true, + token_record: serializeToken(tokenRecord), + }; + }); + + app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.listGrants(agentId); + return { + ok: true, + setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl), + }; + }); } function requireRepository(deps: AgentRouteDeps): AgentsRepository { @@ -243,6 +503,10 @@ function requireRepository(deps: AgentRouteDeps): AgentsRepository { return deps.agentsRepository; } +function requireLifecycleAccess(request: FastifyRequest, deps: AgentRouteDeps): void { + requireInternalAccess(request.headers.authorization, deps.internalAccessToken); +} + function sendNotFound(reply: FastifyReply, error: string): FastifyReply { return reply.status(404).send({ ok: false, @@ -348,6 +612,25 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor }; } +function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[], publicUrl: string): Record { + return buildSetupPacket( + { + agent, + grants, + token: { + id: "00000000-0000-0000-0000-000000000000", + agentId: agent.id, + name: "Agent token placeholder", + status: "active", + expiresAt: null, + lastUsedAt: null, + createdAt: new Date(0).toISOString(), + }, + }, + publicUrl + ); +} + 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(",")}`) diff --git a/src/scripts/smoke-e2e.ts b/src/scripts/smoke-e2e.ts index 3df1505..7fd3f67 100644 --- a/src/scripts/smoke-e2e.ts +++ b/src/scripts/smoke-e2e.ts @@ -16,6 +16,14 @@ if (!config.NODEDC_INTERNAL_ACCESS_TOKEN) { throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is required for e2e smoke test."); } +if (!config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN) { + throw new Error("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is required for e2e smoke test."); +} + +const internalHeaders = { + Authorization: `Bearer ${config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}`, +}; + const migrationPool = new Pool({ connectionString: config.DATABASE_URL }); await runMigrations(migrationPool); await migrationPool.end(); @@ -119,7 +127,7 @@ try { } async function createAgent(suffix: string): Promise { - const payload = await requestJson("POST", "/api/v1/agents", undefined, { + const payload = await requestJson("POST", "/api/v1/agents", internalHeaders, { owner_user_id: `e2e-owner-${suffix}`, owner_email: `e2e-${suffix}@example.test`, display_name: `E2E Codex ${suffix}`, @@ -129,7 +137,7 @@ async function createAgent(suffix: string): Promise { } async function upsertGrant(agentId: string): Promise { - await requestJson("POST", `/api/v1/agents/${agentId}/grants`, undefined, { + await requestJson("POST", `/api/v1/agents/${agentId}/grants`, internalHeaders, { workspace_slug: workspaceSlug, project_id: projectId, scopes: [ @@ -150,7 +158,7 @@ async function upsertGrant(agentId: string): Promise { } async function createToken(agentId: string): Promise { - const payload = await requestJson("POST", `/api/v1/agents/${agentId}/tokens`, undefined, { + const payload = await requestJson("POST", `/api/v1/agents/${agentId}/tokens`, internalHeaders, { name: "E2E smoke token", }); diff --git a/src/scripts/smoke-gateway.ts b/src/scripts/smoke-gateway.ts index b8ce8b6..95ffe21 100644 --- a/src/scripts/smoke-gateway.ts +++ b/src/scripts/smoke-gateway.ts @@ -7,8 +7,16 @@ import { runMigrations } from "../db/migrations.js"; const envWithoutTaskerToken = { ...process.env }; delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN; envWithoutTaskerToken.LOG_LEVEL = "silent"; +envWithoutTaskerToken.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN = envWithoutTaskerToken.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN ?? "smoke-gateway-internal-token"; const config = loadConfig(envWithoutTaskerToken); +const gatewayInternalToken = config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN; +if (!gatewayInternalToken) { + throw new Error("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is required for gateway smoke test."); +} +const internalHeaders = { + Authorization: `Bearer ${gatewayInternalToken}`, +}; if (!config.DATABASE_URL) { throw new Error("DATABASE_URL is required for gateway smoke test."); @@ -25,9 +33,16 @@ try { const projectId = `contract-project-${suffix}`; const workspaceSlug = `contract-workspace-${suffix}`; + const deniedLifecycleResponse = await app.inject({ + method: "GET", + url: "/api/v1/agents", + }); + assertStatus(deniedLifecycleResponse.statusCode, 401, deniedLifecycleResponse.body); + const createAgentResponse = await app.inject({ method: "POST", url: "/api/v1/agents", + headers: internalHeaders, payload: { owner_user_id: `smoke-owner-${suffix}`, owner_email: `smoke-${suffix}@example.test`, @@ -37,10 +52,26 @@ try { assertStatus(createAgentResponse.statusCode, 201, createAgentResponse.body); const createAgentPayload = JSON.parse(createAgentResponse.body); const agentId = createAgentPayload.agent.id as string; + const ownerUserId = createAgentPayload.agent.owner_user_id as string; + + const ownerAgentsResponse = await app.inject({ + method: "GET", + url: `/api/internal/v1/owners/${ownerUserId}/agents`, + headers: internalHeaders, + }); + assertStatus(ownerAgentsResponse.statusCode, 200, ownerAgentsResponse.body); + + const wrongOwnerResponse = await app.inject({ + method: "GET", + url: `/api/internal/v1/owners/wrong-owner/agents/${agentId}`, + headers: internalHeaders, + }); + assertStatus(wrongOwnerResponse.statusCode, 404, wrongOwnerResponse.body); const readGrantResponse = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/grants`, + headers: internalHeaders, payload: { workspace_slug: workspaceSlug, project_id: projectId, @@ -54,6 +85,7 @@ try { const createTokenResponse = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/tokens`, + headers: internalHeaders, payload: { name: "Gateway smoke token", }, @@ -62,6 +94,19 @@ try { const createTokenPayload = JSON.parse(createTokenResponse.body); const token = createTokenPayload.token as string; + const ownerTokenResponse = await app.inject({ + method: "POST", + url: `/api/internal/v1/owners/${ownerUserId}/agents/${agentId}/tokens`, + headers: internalHeaders, + payload: { + name: "Gateway owner lifecycle smoke token", + }, + }); + assertStatus(ownerTokenResponse.statusCode, 201, ownerTokenResponse.body); + const ownerTokenPayload = JSON.parse(ownerTokenResponse.body); + assert(ownerTokenPayload.setup?.mcp_server?.url?.endsWith("/mcp"), "owner token response includes setup packet"); + assert(!JSON.stringify(ownerTokenPayload.setup).includes(ownerTokenPayload.token), "owner setup packet does not echo raw token"); + const sessionResponse = await app.inject({ method: "GET", url: "/api/v1/agent-session", @@ -116,6 +161,7 @@ try { const writeGrantResponse = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/grants`, + headers: internalHeaders, payload: { workspace_slug: workspaceSlug, project_id: projectId, @@ -150,6 +196,8 @@ try { checks: { session_auth: "passed", setup_packet: "passed", + lifecycle_internal_auth: "passed", + owner_lifecycle_api: "passed", denied_without_scope: "passed", idempotency_required: "passed", allowed_request_reaches_tasker_boundary: "passed", diff --git a/src/scripts/smoke-mcp-e2e.ts b/src/scripts/smoke-mcp-e2e.ts index a0ddfb4..de5c252 100644 --- a/src/scripts/smoke-mcp-e2e.ts +++ b/src/scripts/smoke-mcp-e2e.ts @@ -19,6 +19,14 @@ if (!config.NODEDC_INTERNAL_ACCESS_TOKEN) { throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is required for MCP e2e smoke test."); } +if (!config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN) { + throw new Error("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is required for MCP e2e smoke test."); +} + +const internalHeaders = { + Authorization: `Bearer ${config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}`, +}; + const migrationPool = new Pool({ connectionString: config.DATABASE_URL }); await runMigrations(migrationPool); await migrationPool.end(); @@ -153,6 +161,7 @@ async function createAgent(suffix: string): Promise { const response = await app.inject({ method: "POST", url: "/api/v1/agents", + headers: internalHeaders, payload: { owner_user_id: `mcp-e2e-owner-${suffix}`, owner_email: `mcp-e2e-${suffix}@example.test`, @@ -167,6 +176,7 @@ async function upsertGrant(agentId: string): Promise { const response = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/grants`, + headers: internalHeaders, payload: { workspace_slug: workspaceSlug, project_id: projectId, @@ -193,6 +203,7 @@ async function createToken(agentId: string): Promise { const response = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/tokens`, + headers: internalHeaders, payload: { name: "MCP e2e smoke token", }, diff --git a/src/scripts/smoke-mcp.ts b/src/scripts/smoke-mcp.ts index bb5efee..e7c15e8 100644 --- a/src/scripts/smoke-mcp.ts +++ b/src/scripts/smoke-mcp.ts @@ -7,8 +7,16 @@ import { runMigrations } from "../db/migrations.js"; const envWithoutTaskerToken = { ...process.env }; delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN; envWithoutTaskerToken.LOG_LEVEL = "silent"; +envWithoutTaskerToken.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN = envWithoutTaskerToken.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN ?? "smoke-mcp-internal-token"; const config = loadConfig(envWithoutTaskerToken); +const gatewayInternalToken = config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN; +if (!gatewayInternalToken) { + throw new Error("NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is required for MCP smoke test."); +} +const internalHeaders = { + Authorization: `Bearer ${gatewayInternalToken}`, +}; if (!config.DATABASE_URL) { throw new Error("DATABASE_URL is required for MCP smoke test."); @@ -170,6 +178,7 @@ async function createAgent(suffix: string): Promise { const response = await app.inject({ method: "POST", url: "/api/v1/agents", + headers: internalHeaders, payload: { owner_user_id: `mcp-owner-${suffix}`, owner_email: `mcp-${suffix}@example.test`, @@ -184,6 +193,7 @@ async function upsertGrant(agentId: string, workspaceSlug: string, projectId: st const response = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/grants`, + headers: internalHeaders, payload: { workspace_slug: workspaceSlug, project_id: projectId, @@ -199,6 +209,7 @@ async function createToken(agentId: string): Promise { const response = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/tokens`, + headers: internalHeaders, payload: { name: "MCP smoke token", }, diff --git a/src/security/internal.ts b/src/security/internal.ts new file mode 100644 index 0000000..ea618a5 --- /dev/null +++ b/src/security/internal.ts @@ -0,0 +1,29 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +import { parseBearerToken, UnauthorizedError } from "./bearer.js"; + +export class InternalAuthNotConfiguredError extends Error { + constructor(message = "NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is required for internal lifecycle endpoints.") { + super(message); + this.name = "InternalAuthNotConfiguredError"; + } +} + +export function requireInternalAccess(authorizationHeader: string | undefined, configuredToken: string | undefined): void { + if (!configuredToken) { + throw new InternalAuthNotConfiguredError(); + } + + const token = parseBearerToken(authorizationHeader); + + if (!safeEqual(token, configuredToken)) { + throw new UnauthorizedError("Invalid internal bearer token."); + } +} + +function safeEqual(left: string, right: string): boolean { + const leftHash = createHash("sha256").update(left).digest(); + const rightHash = createHash("sha256").update(right).digest(); + + return timingSafeEqual(leftHash, rightHash); +}