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
|
LOG_LEVEL=info
|
||||||
|
|
||||||
NODEDC_AGENT_GATEWAY_PUBLIC_URL=http://agents.local.nodedc
|
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_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc
|
||||||
NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc
|
NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token
|
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.
|
- Fastify service with `/healthz`, `/readyz`, and capability metadata.
|
||||||
- Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys.
|
- Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys.
|
||||||
- Internal REST endpoints for agent profile, grant, and token lifecycle.
|
- 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.
|
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
|
||||||
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
|
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
|
||||||
- Agent setup endpoint returns an MCP config template and AGENTS.md instruction pack without echoing the raw token.
|
- 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
|
```bash
|
||||||
curl -X POST http://127.0.0.1:4100/api/v1/agents \
|
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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"owner_user_id":"local-user","owner_email":"local@example.test","display_name":"Local Codex"}'
|
-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
|
```bash
|
||||||
TOKEN=$(curl -sS -X POST http://127.0.0.1:4100/api/v1/agents/<agent-id>/tokens \
|
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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"name":"Local Codex token"}' | jq -r .token)
|
-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"
|
-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:
|
Generate a local Codex setup packet:
|
||||||
|
|
||||||
|
|
@ -131,6 +141,7 @@ DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-passwo
|
||||||
NODE_ENV=development \
|
NODE_ENV=development \
|
||||||
LOG_LEVEL=silent \
|
LOG_LEVEL=silent \
|
||||||
NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \
|
NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \
|
||||||
|
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN='replace-with-gateway-internal-token' \
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \
|
NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \
|
||||||
SMOKE_WORKSPACE_SLUG='nodedc' \
|
SMOKE_WORKSPACE_SLUG='nodedc' \
|
||||||
SMOKE_PROJECT_ID='<project-id>' \
|
SMOKE_PROJECT_ID='<project-id>' \
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ Agent Gateway owns:
|
||||||
|
|
||||||
It should not execute user code and should not run Codex itself.
|
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
|
### 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.
|
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.
|
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
|
## Phase 6. Agent identity
|
||||||
|
|
||||||
Tasker/Gateway integration:
|
Tasker/Gateway integration:
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,17 @@ Mitigation:
|
||||||
- rate limits;
|
- rate limits;
|
||||||
- optional IP/device binding later.
|
- 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
|
### Owner lifecycle bypass
|
||||||
|
|
||||||
Risk: blocked/annulled user keeps active agent token.
|
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 { registerToolRoutes } from "./routes/tools.js";
|
||||||
import { ForbiddenError } from "./security/authorization.js";
|
import { ForbiddenError } from "./security/authorization.js";
|
||||||
import { UnauthorizedError } from "./security/bearer.js";
|
import { UnauthorizedError } from "./security/bearer.js";
|
||||||
|
import { InternalAuthNotConfiguredError } from "./security/internal.js";
|
||||||
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
||||||
|
|
||||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
|
|
@ -58,6 +59,15 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
return;
|
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) {
|
if (error instanceof ForbiddenError) {
|
||||||
void reply.status(403).send({
|
void reply.status(403).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -115,7 +125,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerHealthRoutes(app, config, pool);
|
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 registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||||
await registerMcpRoutes(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),
|
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
|
||||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
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_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_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
|
||||||
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
|
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
|
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;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,12 @@ export type CreateAgentInput = {
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateAgentProfileInput = {
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
actorUserId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpsertGrantInput = {
|
export type UpsertGrantInput = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
@ -169,6 +175,33 @@ export class AgentsRepository {
|
||||||
return result.rows[0] ? mapAgent(result.rows[0]) : null;
|
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> {
|
async revokeAgent(agentId: string, actorUserId?: string): Promise<AgentRecord | null> {
|
||||||
const client = await this.pool.connect();
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
|
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 { mcpToolDefinitions } from "../mcp/tools.js";
|
||||||
import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js";
|
import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js";
|
||||||
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
||||||
|
import { requireInternalAccess } from "../security/internal.js";
|
||||||
import { generateAgentToken } from "../security/tokens.js";
|
import { generateAgentToken } from "../security/tokens.js";
|
||||||
|
|
||||||
type AgentRouteDeps = {
|
type AgentRouteDeps = {
|
||||||
agentsRepository: AgentsRepository | null;
|
agentsRepository: AgentsRepository | null;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
|
internalAccessToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentParamsSchema = z.object({
|
const agentParamsSchema = z.object({
|
||||||
agentId: z.string().uuid(),
|
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({
|
const tokenParamsSchema = agentParamsSchema.extend({
|
||||||
tokenId: z.string().uuid(),
|
tokenId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ownerTokenParamsSchema = ownerAgentParamsSchema.extend({
|
||||||
|
tokenId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
const listAgentsQuerySchema = z.object({
|
const listAgentsQuerySchema = z.object({
|
||||||
owner_user_id: z.string().min(1).optional(),
|
owner_user_id: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -33,6 +47,18 @@ const createAgentBodySchema = z.object({
|
||||||
avatar_url: z.string().url().nullish(),
|
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({
|
const upsertGrantBodySchema = z.object({
|
||||||
workspace_slug: z.string().min(1),
|
workspace_slug: z.string().min(1),
|
||||||
project_id: z.string().min(1).nullish(),
|
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) => {
|
app.post("/api/v1/agents", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const body = createAgentBodySchema.parse(request.body);
|
const body = createAgentBodySchema.parse(request.body);
|
||||||
const agent = await repository.createAgent({
|
const agent = await repository.createAgent({
|
||||||
|
|
@ -97,6 +124,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/agents", async (request) => {
|
app.get("/api/v1/agents", async (request) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const query = listAgentsQuerySchema.parse(request.query);
|
const query = listAgentsQuerySchema.parse(request.query);
|
||||||
const agents = await repository.listAgents(query.owner_user_id);
|
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) => {
|
app.get("/api/v1/agents/:agentId", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const agent = await repository.getAgent(agentId);
|
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) => {
|
app.post("/api/v1/agents/:agentId/revoke", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const body = actorBodySchema.parse(request.body ?? {});
|
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) => {
|
app.post("/api/v1/agents/:agentId/grants", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const body = upsertGrantBodySchema.parse(request.body);
|
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) => {
|
app.get("/api/v1/agents/:agentId/grants", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const agent = await repository.getAgent(agentId);
|
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) => {
|
app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const body = createTokenBodySchema.parse(request.body ?? {});
|
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) => {
|
app.get("/api/v1/agents/:agentId/tokens", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId } = agentParamsSchema.parse(request.params);
|
const { agentId } = agentParamsSchema.parse(request.params);
|
||||||
const agent = await repository.getAgent(agentId);
|
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) => {
|
app.post("/api/v1/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => {
|
||||||
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
const { agentId, tokenId } = tokenParamsSchema.parse(request.params);
|
const { agentId, tokenId } = tokenParamsSchema.parse(request.params);
|
||||||
const body = actorBodySchema.parse(request.body ?? {});
|
const body = actorBodySchema.parse(request.body ?? {});
|
||||||
|
|
@ -233,6 +268,231 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
|
||||||
token_record: serializeToken(tokenRecord),
|
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 {
|
function requireRepository(deps: AgentRouteDeps): AgentsRepository {
|
||||||
|
|
@ -243,6 +503,10 @@ function requireRepository(deps: AgentRouteDeps): AgentsRepository {
|
||||||
return deps.agentsRepository;
|
return deps.agentsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireLifecycleAccess(request: FastifyRequest, deps: AgentRouteDeps): void {
|
||||||
|
requireInternalAccess(request.headers.authorization, deps.internalAccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
function sendNotFound(reply: FastifyReply, error: string): FastifyReply {
|
function sendNotFound(reply: FastifyReply, error: string): FastifyReply {
|
||||||
return reply.status(404).send({
|
return reply.status(404).send({
|
||||||
ok: false,
|
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 {
|
function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string {
|
||||||
const grants = session.grants
|
const grants = session.grants
|
||||||
.map((grant) => `- workspace=${grant.workspaceSlug}; project=${grant.projectId ?? "*"}; mode=${grant.mode}; scopes=${grant.scopes.join(",")}`)
|
.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.");
|
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 });
|
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
|
||||||
await runMigrations(migrationPool);
|
await runMigrations(migrationPool);
|
||||||
await migrationPool.end();
|
await migrationPool.end();
|
||||||
|
|
@ -119,7 +127,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAgent(suffix: string): Promise<string> {
|
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_user_id: `e2e-owner-${suffix}`,
|
||||||
owner_email: `e2e-${suffix}@example.test`,
|
owner_email: `e2e-${suffix}@example.test`,
|
||||||
display_name: `E2E Codex ${suffix}`,
|
display_name: `E2E Codex ${suffix}`,
|
||||||
|
|
@ -129,7 +137,7 @@ async function createAgent(suffix: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertGrant(agentId: string): Promise<void> {
|
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,
|
workspace_slug: workspaceSlug,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
scopes: [
|
scopes: [
|
||||||
|
|
@ -150,7 +158,7 @@ async function upsertGrant(agentId: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createToken(agentId: string): Promise<string> {
|
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",
|
name: "E2E smoke token",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,16 @@ import { runMigrations } from "../db/migrations.js";
|
||||||
const envWithoutTaskerToken = { ...process.env };
|
const envWithoutTaskerToken = { ...process.env };
|
||||||
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
|
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
|
||||||
envWithoutTaskerToken.LOG_LEVEL = "silent";
|
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 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) {
|
if (!config.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL is required for gateway smoke test.");
|
throw new Error("DATABASE_URL is required for gateway smoke test.");
|
||||||
|
|
@ -25,9 +33,16 @@ try {
|
||||||
const projectId = `contract-project-${suffix}`;
|
const projectId = `contract-project-${suffix}`;
|
||||||
const workspaceSlug = `contract-workspace-${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({
|
const createAgentResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/v1/agents",
|
url: "/api/v1/agents",
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
owner_user_id: `smoke-owner-${suffix}`,
|
owner_user_id: `smoke-owner-${suffix}`,
|
||||||
owner_email: `smoke-${suffix}@example.test`,
|
owner_email: `smoke-${suffix}@example.test`,
|
||||||
|
|
@ -37,10 +52,26 @@ try {
|
||||||
assertStatus(createAgentResponse.statusCode, 201, createAgentResponse.body);
|
assertStatus(createAgentResponse.statusCode, 201, createAgentResponse.body);
|
||||||
const createAgentPayload = JSON.parse(createAgentResponse.body);
|
const createAgentPayload = JSON.parse(createAgentResponse.body);
|
||||||
const agentId = createAgentPayload.agent.id as string;
|
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({
|
const readGrantResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/grants`,
|
url: `/api/v1/agents/${agentId}/grants`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -54,6 +85,7 @@ try {
|
||||||
const createTokenResponse = await app.inject({
|
const createTokenResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/tokens`,
|
url: `/api/v1/agents/${agentId}/tokens`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
name: "Gateway smoke token",
|
name: "Gateway smoke token",
|
||||||
},
|
},
|
||||||
|
|
@ -62,6 +94,19 @@ try {
|
||||||
const createTokenPayload = JSON.parse(createTokenResponse.body);
|
const createTokenPayload = JSON.parse(createTokenResponse.body);
|
||||||
const token = createTokenPayload.token as string;
|
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({
|
const sessionResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/api/v1/agent-session",
|
url: "/api/v1/agent-session",
|
||||||
|
|
@ -116,6 +161,7 @@ try {
|
||||||
const writeGrantResponse = await app.inject({
|
const writeGrantResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/grants`,
|
url: `/api/v1/agents/${agentId}/grants`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -150,6 +196,8 @@ try {
|
||||||
checks: {
|
checks: {
|
||||||
session_auth: "passed",
|
session_auth: "passed",
|
||||||
setup_packet: "passed",
|
setup_packet: "passed",
|
||||||
|
lifecycle_internal_auth: "passed",
|
||||||
|
owner_lifecycle_api: "passed",
|
||||||
denied_without_scope: "passed",
|
denied_without_scope: "passed",
|
||||||
idempotency_required: "passed",
|
idempotency_required: "passed",
|
||||||
allowed_request_reaches_tasker_boundary: "passed",
|
allowed_request_reaches_tasker_boundary: "passed",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ if (!config.NODEDC_INTERNAL_ACCESS_TOKEN) {
|
||||||
throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is required for MCP e2e smoke test.");
|
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 });
|
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
|
||||||
await runMigrations(migrationPool);
|
await runMigrations(migrationPool);
|
||||||
await migrationPool.end();
|
await migrationPool.end();
|
||||||
|
|
@ -153,6 +161,7 @@ async function createAgent(suffix: string): Promise<string> {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/v1/agents",
|
url: "/api/v1/agents",
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
owner_user_id: `mcp-e2e-owner-${suffix}`,
|
owner_user_id: `mcp-e2e-owner-${suffix}`,
|
||||||
owner_email: `mcp-e2e-${suffix}@example.test`,
|
owner_email: `mcp-e2e-${suffix}@example.test`,
|
||||||
|
|
@ -167,6 +176,7 @@ async function upsertGrant(agentId: string): Promise<void> {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/grants`,
|
url: `/api/v1/agents/${agentId}/grants`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -193,6 +203,7 @@ async function createToken(agentId: string): Promise<string> {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/tokens`,
|
url: `/api/v1/agents/${agentId}/tokens`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
name: "MCP e2e smoke token",
|
name: "MCP e2e smoke token",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,16 @@ import { runMigrations } from "../db/migrations.js";
|
||||||
const envWithoutTaskerToken = { ...process.env };
|
const envWithoutTaskerToken = { ...process.env };
|
||||||
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
|
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
|
||||||
envWithoutTaskerToken.LOG_LEVEL = "silent";
|
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 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) {
|
if (!config.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL is required for MCP smoke test.");
|
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({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/v1/agents",
|
url: "/api/v1/agents",
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
owner_user_id: `mcp-owner-${suffix}`,
|
owner_user_id: `mcp-owner-${suffix}`,
|
||||||
owner_email: `mcp-${suffix}@example.test`,
|
owner_email: `mcp-${suffix}@example.test`,
|
||||||
|
|
@ -184,6 +193,7 @@ async function upsertGrant(agentId: string, workspaceSlug: string, projectId: st
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/grants`,
|
url: `/api/v1/agents/${agentId}/grants`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -199,6 +209,7 @@ async function createToken(agentId: string): Promise<string> {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/tokens`,
|
url: `/api/v1/agents/${agentId}/tokens`,
|
||||||
|
headers: internalHeaders,
|
||||||
payload: {
|
payload: {
|
||||||
name: "MCP smoke token",
|
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