API - CODEX AGENTS: secure owner lifecycle endpoints

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 20:57:02 +03:00
parent fd43f503dd
commit 9cb1cd0a9e
14 changed files with 471 additions and 7 deletions

View File

@ -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

View File

@ -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/<agent-id>/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/<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.
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='<project-id>' \

View File

@ -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.

View File

@ -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:

View File

@ -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.

View File

@ -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<FastifyInstance> {
@ -58,6 +59,15 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
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<FastifyInstance> {
});
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 });

View File

@ -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;
}

View File

@ -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<AgentRecord | null> {
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<AgentRow>(
`
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<AgentRecord | null> {
const client = await this.pool.connect();

View File

@ -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<string, unknown> {
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(",")}`)

View File

@ -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<string> {
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<string> {
}
async function upsertGrant(agentId: string): Promise<void> {
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<void> {
}
async function createToken(agentId: string): Promise<string> {
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",
});

View File

@ -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",

View File

@ -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<string> {
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<void> {
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<string> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/tokens`,
headers: internalHeaders,
payload: {
name: "MCP e2e smoke token",
},

View File

@ -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<string> {
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<string> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/tokens`,
headers: internalHeaders,
payload: {
name: "MCP smoke token",
},

29
src/security/internal.ts Normal file
View File

@ -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);
}