API - CODEX AGENTS: secure owner lifecycle endpoints
This commit is contained in:
parent
fd43f503dd
commit
9cb1cd0a9e
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
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/<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>' \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
16
src/app.ts
16
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<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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(",")}`)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue