Stabilize AI Workspace Ops entitlements

This commit is contained in:
DCCONSTRUCTIONS 2026-06-13 17:30:44 +03:00
parent 6b3ecd6e1f
commit c0a6fac55f
12 changed files with 884 additions and 24 deletions

View File

@ -4,8 +4,13 @@ PORT=4100
HOST_PORT=4100 HOST_PORT=4100
LOG_LEVEL=info LOG_LEVEL=info
# Public URL returned inside MCP setup packets and AI Workspace run profiles.
# For true local e2e this must be reachable from the executing Codex worker.
# Keep the deployed URL for prod-bridge/manual remote-worker tests; use an
# explicit localhost/LAN/Tailscale URL only for a named local/tunnel profile.
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
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

View File

@ -7,6 +7,7 @@ LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080 NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090 NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token

View File

@ -28,6 +28,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
- 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.
- Internal AI Workspace entitlement endpoint issues short-lived run tokens and returns a dynamic Ops MCP server profile for the Platform run profile.
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter. - Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
- MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints. - MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes. - Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes.
@ -100,6 +101,38 @@ curl http://127.0.0.1:4100/api/internal/v1/owners/<owner-user-id>/agents \
The internal API verifies the owner path against the stored agent owner before returning agent detail, grants, tokens, setup packets, or revoke responses. The internal API verifies the owner path against the stored agent owner before returning agent detail, grants, tokens, setup packets, or revoke responses.
Issue an AI Workspace entitlement for a Codex run:
```bash
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/entitlements \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"schemaVersion":"ai-workspace.entitlement-request.v1",
"appId":"ops",
"owner":{"userId":"local-user","email":"local@example.test"},
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
}'
```
The response uses the AI Workspace adapter contract and returns `appGrants.ops.mcpServers[]` with a bearer token narrowed to the matching active agent grant(s) for the requested Ops workspace/project. The token expires after `NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS` seconds, is marked as `grant_scope=token`, and the response reports `grantMode=token-scoped-run-grants`. Existing long-lived agent tokens keep the legacy `grant_scope=agent` behavior for manually installed local Codex clients.
Run the same check without issuing a token:
```bash
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/preflight \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"schemaVersion":"ai-workspace.entitlement-request.v1",
"appId":"ops",
"owner":{"userId":"local-user","email":"local@example.test"},
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
}'
```
Preflight returns redacted diagnostics: selected agent, matching grants, scopes, `grantMode`, token TTL, and MCP endpoint metadata. It does not create or return a bearer token.
Generate a local Codex setup packet: Generate a local Codex setup packet:
```bash ```bash

View File

@ -27,6 +27,7 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-local-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-local-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway}
NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN:-local-dev-codex-agent-gateway-token-change-me} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN:-local-dev-codex-agent-gateway-token-change-me}
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://launcher.local.nodedc} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://launcher.local.nodedc}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-local-dev-nodedc-internal-token-change-me} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-local-dev-nodedc-internal-token-change-me}

View File

@ -25,6 +25,7 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway}
NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}

View File

@ -0,0 +1,21 @@
ALTER TABLE agent_tokens
ADD COLUMN IF NOT EXISTS grant_scope text NOT NULL DEFAULT 'agent'
CHECK (grant_scope IN ('agent', 'token'));
CREATE TABLE IF NOT EXISTS agent_token_grants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_id uuid NOT NULL REFERENCES agent_tokens(id) ON DELETE CASCADE,
workspace_slug text NOT NULL,
project_id text NOT NULL DEFAULT '',
scopes text[] NOT NULL DEFAULT '{}',
mode text NOT NULL DEFAULT 'voluntary' CHECK (mode IN ('voluntary', 'reporting')),
created_by_user_id text NOT NULL,
source_grant_id uuid REFERENCES agent_grants(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(token_id, workspace_slug, project_id)
);
CREATE INDEX IF NOT EXISTS agent_token_grants_token_id_idx ON agent_token_grants(token_id);
CREATE INDEX IF NOT EXISTS agent_token_grants_source_grant_id_idx ON agent_token_grants(source_grant_id);
CREATE INDEX IF NOT EXISTS agent_token_grants_workspace_slug_idx ON agent_token_grants(workspace_slug);

View File

@ -132,6 +132,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
agentsRepository, agentsRepository,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN, internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
}); });
await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient });

View File

@ -9,6 +9,7 @@ const configSchema = z.object({
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://ops-agents.local.nodedc"), NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"),
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(), NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(),
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200),
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(),

View File

@ -662,6 +662,7 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
mode: grant.mode, mode: grant.mode,
scopes: grant.scopes, scopes: grant.scopes,
})), })),
grant_source: session.grantSource,
rules: { rules: {
card_structure: [ card_structure: [
"Keep the main issue description concise and conceptual.", "Keep the main issue description concise and conceptual.",

View File

@ -10,6 +10,7 @@ const allowedScopeSet = new Set<string>(allowedAgentScopes);
export type AgentStatus = "active" | "disabled" | "revoked"; export type AgentStatus = "active" | "disabled" | "revoked";
export type AgentGrantMode = "voluntary" | "reporting"; export type AgentGrantMode = "voluntary" | "reporting";
export type AgentTokenStatus = "active" | "revoked" | "expired"; export type AgentTokenStatus = "active" | "revoked" | "expired";
export type AgentTokenGrantScope = "agent" | "token";
export type AgentRecord = { export type AgentRecord = {
id: string; id: string;
@ -39,6 +40,7 @@ export type AgentTokenRecord = {
agentId: string; agentId: string;
name: string; name: string;
status: AgentTokenStatus; status: AgentTokenStatus;
grantScope: AgentTokenGrantScope;
tokenSuffix: string | null; tokenSuffix: string | null;
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
@ -49,6 +51,7 @@ export type AgentSessionRecord = {
agent: AgentRecord; agent: AgentRecord;
token: AgentTokenRecord; token: AgentTokenRecord;
grants: AgentGrantRecord[]; grants: AgentGrantRecord[];
grantSource: AgentTokenGrantScope;
}; };
type AgentRow = { type AgentRow = {
@ -79,6 +82,7 @@ type TokenRow = {
agent_id: string; agent_id: string;
name: string; name: string;
status: AgentTokenStatus; status: AgentTokenStatus;
grant_scope: AgentTokenGrantScope;
token_suffix: string | null; token_suffix: string | null;
expires_at: Date | null; expires_at: Date | null;
last_used_at: Date | null; last_used_at: Date | null;
@ -89,12 +93,27 @@ type SessionRow = AgentRow & {
token_id: string; token_id: string;
token_name: string; token_name: string;
token_status: AgentTokenStatus; token_status: AgentTokenStatus;
token_grant_scope: AgentTokenGrantScope;
token_suffix: string | null; token_suffix: string | null;
token_expires_at: Date | null; token_expires_at: Date | null;
token_last_used_at: Date | null; token_last_used_at: Date | null;
token_created_at: Date; token_created_at: Date;
}; };
type TokenGrantRow = {
id: string;
token_id: string;
agent_id: string;
workspace_slug: string;
project_id: string;
scopes: AgentScope[];
mode: AgentGrantMode;
created_by_user_id: string;
source_grant_id: string | null;
created_at: Date;
updated_at: Date;
};
type IdempotencyRow = { type IdempotencyRow = {
key: string; key: string;
agent_id: string; agent_id: string;
@ -151,6 +170,16 @@ export type CreateTokenInput = {
token: string; token: string;
name: string; name: string;
expiresAt?: string | null; expiresAt?: string | null;
tokenGrants?: CreateTokenGrantInput[];
};
export type CreateTokenGrantInput = {
workspaceSlug: string;
projectId?: string | null;
scopes: AgentScope[];
mode: AgentGrantMode;
createdByUserId: string;
sourceGrantId?: string | null;
}; };
export type IdempotencyClaim = export type IdempotencyClaim =
@ -193,6 +222,28 @@ export class AgentsRepository {
return result.rows.map(mapAgent); return result.rows.map(mapAgent);
} }
async listAgentsByOwnerIdentity(ownerUserId?: string, ownerEmail?: string): Promise<AgentRecord[]> {
const normalizedOwnerUserId = normalizeNullableText(ownerUserId);
const normalizedOwnerEmail = normalizeNullableText(ownerEmail)?.toLowerCase();
if (!normalizedOwnerUserId && !normalizedOwnerEmail) {
return [];
}
const result = await this.pool.query<AgentRow>(
`
SELECT *
FROM agents
WHERE ($1::text IS NOT NULL AND owner_user_id = $1)
OR ($2::text IS NOT NULL AND owner_email IS NOT NULL AND lower(owner_email) = $2)
ORDER BY created_at DESC
`,
[normalizedOwnerUserId, normalizedOwnerEmail]
);
return result.rows.map(mapAgent);
}
async getAgent(agentId: string): Promise<AgentRecord | null> { async getAgent(agentId: string): Promise<AgentRecord | null> {
const result = await this.pool.query<AgentRow>("SELECT * FROM agents WHERE id = $1", [agentId]); const result = await this.pool.query<AgentRow>("SELECT * FROM agents WHERE id = $1", [agentId]);
return result.rows[0] ? mapAgent(result.rows[0]) : null; return result.rows[0] ? mapAgent(result.rows[0]) : null;
@ -435,28 +486,68 @@ export class AgentsRepository {
} }
async createToken(agentId: string, input: CreateTokenInput): Promise<AgentTokenRecord> { async createToken(agentId: string, input: CreateTokenInput): Promise<AgentTokenRecord> {
const result = await this.pool.query<TokenRow>( const tokenGrants = input.tokenGrants ?? [];
` const grantScope: AgentTokenGrantScope = tokenGrants.length > 0 ? "token" : "agent";
INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at) const client = await this.pool.connect();
VALUES ($1, $2, $3, $4, $5) let tokenRecord: AgentTokenRecord;
RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at
`, for (const grant of tokenGrants) {
[agentId, hashAgentToken(input.token), input.token.slice(-8), input.name, input.expiresAt ?? null] assertAllowedScopes(grant.scopes);
); }
try {
await client.query("BEGIN");
const result = await client.query<TokenRow>(
`
INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at, grant_scope)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
`,
[agentId, hashAgentToken(input.token), input.token.slice(-8), input.name, input.expiresAt ?? null, grantScope]
);
tokenRecord = mapToken(result.rows[0]);
for (const grant of tokenGrants) {
await client.query(
`
INSERT INTO agent_token_grants(token_id, workspace_slug, project_id, scopes, mode, created_by_user_id, source_grant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
tokenRecord.id,
grant.workspaceSlug,
grant.projectId ?? "",
grant.scopes,
grant.mode,
grant.createdByUserId,
grant.sourceGrantId ?? null,
]
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
await this.createAuditEvent(agentId, "agent.token.created", undefined, { await this.createAuditEvent(agentId, "agent.token.created", undefined, {
tokenId: result.rows[0].id, tokenId: tokenRecord.id,
name: input.name, name: input.name,
expiresAt: input.expiresAt ?? null, expiresAt: input.expiresAt ?? null,
grantScope,
tokenGrantCount: tokenGrants.length,
}); });
return mapToken(result.rows[0]); return tokenRecord;
} }
async listTokens(agentId: string): Promise<AgentTokenRecord[]> { async listTokens(agentId: string): Promise<AgentTokenRecord[]> {
const result = await this.pool.query<TokenRow>( const result = await this.pool.query<TokenRow>(
` `
SELECT id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at SELECT id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
FROM agent_tokens FROM agent_tokens
WHERE agent_id = $1 WHERE agent_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@ -472,7 +563,7 @@ export class AgentsRepository {
UPDATE agent_tokens UPDATE agent_tokens
SET status = 'revoked' SET status = 'revoked'
WHERE agent_id = $1 AND id = $2 WHERE agent_id = $1 AND id = $2
RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
`, `,
[agentId, tokenId] [agentId, tokenId]
); );
@ -500,6 +591,7 @@ export class AgentsRepository {
agent_tokens.id AS token_id, agent_tokens.id AS token_id,
agent_tokens.name AS token_name, agent_tokens.name AS token_name,
agent_tokens.status AS token_status, agent_tokens.status AS token_status,
agent_tokens.grant_scope AS token_grant_scope,
agent_tokens.token_suffix AS token_suffix, agent_tokens.token_suffix AS token_suffix,
agent_tokens.expires_at AS token_expires_at, agent_tokens.expires_at AS token_expires_at,
agent_tokens.last_used_at AS token_last_used_at, agent_tokens.last_used_at AS token_last_used_at,
@ -525,7 +617,7 @@ export class AgentsRepository {
UPDATE agent_tokens UPDATE agent_tokens
SET last_used_at = now() SET last_used_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at RETURNING id, agent_id, name, status, grant_scope, token_suffix, expires_at, last_used_at, created_at
`, `,
[row.token_id] [row.token_id]
); );
@ -533,10 +625,44 @@ export class AgentsRepository {
return { return {
agent: mapAgent(row), agent: mapAgent(row),
token: mapToken(updatedToken.rows[0]), token: mapToken(updatedToken.rows[0]),
grants: await this.listGrants(row.id), grants: row.token_grant_scope === "token" ? await this.listTokenGrants(row.token_id) : await this.listGrants(row.id),
grantSource: row.token_grant_scope,
}; };
} }
async listTokenGrants(tokenId: string): Promise<AgentGrantRecord[]> {
const result = await this.pool.query<TokenGrantRow>(
`
SELECT
agent_token_grants.id,
agent_token_grants.token_id,
agent_tokens.agent_id,
agent_token_grants.workspace_slug,
agent_token_grants.project_id,
ARRAY(
SELECT token_scope
FROM unnest(agent_token_grants.scopes) AS token_scope
WHERE token_scope = ANY(COALESCE(agent_grants.scopes, agent_token_grants.scopes))
ORDER BY token_scope
) AS scopes,
COALESCE(agent_grants.mode, agent_token_grants.mode) AS mode,
agent_token_grants.created_by_user_id,
agent_token_grants.source_grant_id,
agent_token_grants.created_at,
agent_token_grants.updated_at
FROM agent_token_grants
JOIN agent_tokens ON agent_tokens.id = agent_token_grants.token_id
LEFT JOIN agent_grants ON agent_grants.id = agent_token_grants.source_grant_id
WHERE agent_token_grants.token_id = $1
AND (agent_token_grants.source_grant_id IS NULL OR agent_grants.id IS NOT NULL)
ORDER BY agent_token_grants.created_at DESC
`,
[tokenId]
);
return result.rows.map(mapTokenGrant);
}
async createAuditEvent(agentId: string | null, eventType: string, actorUserId?: string, metadata: Record<string, unknown> = {}): Promise<void> { async createAuditEvent(agentId: string | null, eventType: string, actorUserId?: string, metadata: Record<string, unknown> = {}): Promise<void> {
await this.pool.query( await this.pool.query(
` `
@ -643,6 +769,11 @@ function buildIdempotencyStorageKey(agentId: string, idempotencyKey: string): st
return `${agentId}:${idempotencyKey}`; return `${agentId}:${idempotencyKey}`;
} }
function normalizeNullableText(value: string | undefined): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function assertAllowedScopes(scopes: AgentScope[]): void { function assertAllowedScopes(scopes: AgentScope[]): void {
const deniedScope = scopes.find((scope) => !allowedScopeSet.has(scope)); const deniedScope = scopes.find((scope) => !allowedScopeSet.has(scope));
@ -684,9 +815,24 @@ function mapToken(row: TokenRow): AgentTokenRecord {
agentId: row.agent_id, agentId: row.agent_id,
name: row.name, name: row.name,
status: row.status, status: row.status,
grantScope: row.grant_scope,
tokenSuffix: row.token_suffix, tokenSuffix: row.token_suffix,
expiresAt: row.expires_at?.toISOString() ?? null, expiresAt: row.expires_at?.toISOString() ?? null,
lastUsedAt: row.last_used_at?.toISOString() ?? null, lastUsedAt: row.last_used_at?.toISOString() ?? null,
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
}; };
} }
function mapTokenGrant(row: TokenGrantRow): AgentGrantRecord {
return {
id: row.id,
agentId: row.agent_id,
workspaceSlug: row.workspace_slug,
projectId: row.project_id === "" ? null : row.project_id,
scopes: row.scopes,
mode: row.mode,
createdByUserId: row.created_by_user_id,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}

View File

@ -5,7 +5,14 @@ import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAu
import { DatabaseNotConfiguredError } from "../db/pool.js"; import { DatabaseNotConfiguredError } from "../db/pool.js";
import { getToolsForSession } from "../mcp/tool-runtime.js"; 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,
CreateTokenGrantInput,
} 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 { requireInternalAccess } from "../security/internal.js";
import { generateAgentToken } from "../security/tokens.js"; import { generateAgentToken } from "../security/tokens.js";
@ -14,9 +21,13 @@ type AgentRouteDeps = {
agentsRepository: AgentsRepository | null; agentsRepository: AgentsRepository | null;
publicUrl: string; publicUrl: string;
internalAccessToken?: string; internalAccessToken?: string;
aiWorkspaceRunTokenTtlSeconds: number;
}; };
const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000; const MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000;
const OPS_APP_ID = "ops";
const OPS_APP_TITLE = "NODE.DC Ops";
const OPS_MCP_SERVER_NAME = "nodedc_ops_agent";
function isAllowedAvatarUrl(value: string): boolean { function isAllowedAvatarUrl(value: string): boolean {
if (value.startsWith("data:image/")) { if (value.startsWith("data:image/")) {
@ -118,6 +129,23 @@ const actorBodySchema = z.object({
actor_user_id: z.string().min(1).optional(), actor_user_id: z.string().min(1).optional(),
}); });
const aiWorkspaceEntitlementBodySchema = z.object({
schemaVersion: z.literal("ai-workspace.entitlement-request.v1").optional(),
appId: z.string().min(1).default(OPS_APP_ID),
owner: z
.object({
ownerKey: z.string().min(1).optional(),
userId: z.string().min(1).optional(),
email: z.string().email().optional(),
role: z.string().min(1).optional(),
groups: z.array(z.string().min(1)).optional(),
})
.default({}),
activeContext: z.record(z.string(), z.unknown()).optional(),
runContext: z.record(z.string(), z.unknown()).optional(),
requestedAt: z.string().datetime().optional(),
});
export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRouteDeps): Promise<void> { export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRouteDeps): Promise<void> {
app.get("/api/v1/meta/capabilities", async () => ({ app.get("/api/v1/meta/capabilities", async () => ({
ok: true, ok: true,
@ -148,6 +176,122 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute
}; };
}); });
app.post("/api/internal/v1/ai-workspace/entitlements", async (request, reply) => {
requireLifecycleAccess(request, deps);
const repository = requireRepository(deps);
const body = aiWorkspaceEntitlementBodySchema.parse(request.body ?? {});
const requestedAppId = body.appId.trim().toLowerCase();
if (requestedAppId !== OPS_APP_ID) {
return reply.status(400).send({
ok: false,
error: "unsupported_app_id",
message: `Agent Gateway can issue only ${OPS_APP_ID} entitlements.`,
});
}
const ownerUserId = normalizeText(body.owner.userId ?? body.owner.ownerKey);
const ownerEmail = normalizeText(body.owner.email)?.toLowerCase();
if (!ownerUserId && !ownerEmail) {
return reply.status(400).send({
ok: false,
error: "owner_identity_required",
message: "owner.userId, owner.ownerKey, or owner.email is required.",
});
}
const requestedContext = extractRequestedGrantContext(body.runContext, body.activeContext);
const agents = await repository.listAgentsByOwnerIdentity(ownerUserId ?? undefined, ownerEmail ?? undefined);
const entitlementCandidate = await findEntitlementCandidate(repository, agents, requestedContext);
if (!entitlementCandidate) {
return {
ok: true,
appGrants: {
[OPS_APP_ID]: buildEmptyOpsAppGrant(requestedContext, agents.length === 0 ? "agent_not_found" : "matching_grant_not_found"),
},
};
}
const token = generateAgentToken();
const expiresAt = new Date(Date.now() + deps.aiWorkspaceRunTokenTtlSeconds * 1000).toISOString();
const tokenRecord = await repository.createToken(entitlementCandidate.agent.id, {
token,
name: `AI Workspace run ${new Date().toISOString()}`,
expiresAt,
tokenGrants: buildRunTokenGrants(entitlementCandidate.matchingGrants, ownerUserId ?? ownerEmail ?? "ai-workspace"),
});
await repository.createAuditEvent(entitlementCandidate.agent.id, "ai_workspace.entitlement.issued", ownerUserId ?? ownerEmail ?? undefined, {
appId: OPS_APP_ID,
requestedContext,
selectedGrantIds: entitlementCandidate.matchingGrants.map((grant) => grant.id),
tokenId: tokenRecord.id,
expiresAt,
grantMode: "token-scoped-run-grants",
});
return {
ok: true,
appGrants: {
[OPS_APP_ID]: buildOpsAppGrant({
agent: entitlementCandidate.agent,
allGrants: entitlementCandidate.allGrants,
matchingGrants: entitlementCandidate.matchingGrants,
requestedContext,
token,
tokenRecord,
publicUrl: deps.publicUrl,
ttlSeconds: deps.aiWorkspaceRunTokenTtlSeconds,
}),
},
};
});
app.post("/api/internal/v1/ai-workspace/preflight", async (request, reply) => {
requireLifecycleAccess(request, deps);
const repository = requireRepository(deps);
const body = aiWorkspaceEntitlementBodySchema.parse(request.body ?? {});
const requestedAppId = body.appId.trim().toLowerCase();
if (requestedAppId !== OPS_APP_ID) {
return reply.status(400).send({
ok: false,
error: "unsupported_app_id",
message: `Agent Gateway can preflight only ${OPS_APP_ID} entitlements.`,
});
}
const ownerUserId = normalizeText(body.owner.userId ?? body.owner.ownerKey);
const ownerEmail = normalizeText(body.owner.email)?.toLowerCase();
if (!ownerUserId && !ownerEmail) {
return reply.status(400).send({
ok: false,
error: "owner_identity_required",
message: "owner.userId, owner.ownerKey, or owner.email is required.",
});
}
const requestedContext = extractRequestedGrantContext(body.runContext, body.activeContext);
const agents = await repository.listAgentsByOwnerIdentity(ownerUserId ?? undefined, ownerEmail ?? undefined);
const entitlementCandidate = await findEntitlementCandidate(repository, agents, requestedContext);
return {
ok: true,
appId: OPS_APP_ID,
status: entitlementCandidate ? "ready" : agents.length === 0 ? "agent_not_found" : "matching_grant_not_found",
diagnostics: buildOpsPreflightDiagnostics({
agents,
entitlementCandidate,
requestedContext,
publicUrl: deps.publicUrl,
ttlSeconds: deps.aiWorkspaceRunTokenTtlSeconds,
}),
};
});
app.post("/api/v1/agents", async (request, reply) => { app.post("/api/v1/agents", async (request, reply) => {
requireLifecycleAccess(request, deps); requireLifecycleAccess(request, deps);
const repository = requireRepository(deps); const repository = requireRepository(deps);
@ -660,6 +804,225 @@ function sendNotFound(reply: FastifyReply, error: string): FastifyReply {
}); });
} }
type RequestedGrantContext = {
workspaceSlug: string | null;
projectId: string | null;
};
type EntitlementCandidate = {
agent: AgentRecord;
allGrants: AgentGrantRecord[];
matchingGrants: AgentGrantRecord[];
};
type BuildOpsAppGrantInput = EntitlementCandidate & {
requestedContext: RequestedGrantContext;
token: string;
tokenRecord: AgentTokenRecord;
publicUrl: string;
ttlSeconds: number;
};
type BuildOpsPreflightDiagnosticsInput = {
agents: AgentRecord[];
entitlementCandidate: EntitlementCandidate | null;
requestedContext: RequestedGrantContext;
publicUrl: string;
ttlSeconds: number;
};
async function findEntitlementCandidate(
repository: AgentsRepository,
agents: AgentRecord[],
requestedContext: RequestedGrantContext
): Promise<EntitlementCandidate | null> {
for (const agent of agents) {
if (agent.status !== "active") {
continue;
}
const allGrants = await repository.listGrants(agent.id);
const matchingGrants = filterMatchingGrants(allGrants, requestedContext);
if (matchingGrants.length > 0) {
return {
agent,
allGrants,
matchingGrants,
};
}
}
return null;
}
function buildOpsAppGrant(input: BuildOpsAppGrantInput): Record<string, unknown> {
const endpoint = new URL("/mcp", input.publicUrl).toString();
return {
appId: OPS_APP_ID,
appTitle: OPS_APP_TITLE,
status: "granted",
source: "ops-agent-gateway",
grantMode: "token-scoped-run-grants",
tokenExpiresAt: input.tokenRecord.expiresAt,
tokenTtlSeconds: input.ttlSeconds,
scopes: uniqueStrings(input.matchingGrants.flatMap((grant) => grant.scopes)),
context: {
requestedWorkspaceSlug: input.requestedContext.workspaceSlug,
requestedProjectId: input.requestedContext.projectId,
agent: serializeAgent(input.agent),
matchingGrants: input.matchingGrants.map(serializeGrant),
allGrantIds: input.allGrants.map((grant) => grant.id),
},
mcpServers: [
{
appId: OPS_APP_ID,
appTitle: OPS_APP_TITLE,
serverName: OPS_MCP_SERVER_NAME,
name: OPS_MCP_SERVER_NAME,
transport: "streamable_http_json_rpc",
url: endpoint,
required: false,
startupTimeoutSec: 20,
toolTimeoutSec: 60,
httpHeaders: {
Authorization: `Bearer ${input.token}`,
Accept: "application/json",
"MCP-Protocol-Version": "2025-06-18",
},
},
],
};
}
function buildOpsPreflightDiagnostics(input: BuildOpsPreflightDiagnosticsInput): Record<string, unknown> {
const endpoint = new URL("/mcp", input.publicUrl).toString();
const matchingGrants = input.entitlementCandidate?.matchingGrants ?? [];
return {
source: "ops-agent-gateway",
requestedWorkspaceSlug: input.requestedContext.workspaceSlug,
requestedProjectId: input.requestedContext.projectId,
targetContextPresent: Boolean(input.requestedContext.workspaceSlug || input.requestedContext.projectId),
activeAgentCount: input.agents.filter((agent) => agent.status === "active").length,
selectedAgent: input.entitlementCandidate ? serializeAgent(input.entitlementCandidate.agent) : null,
matchingGrantCount: matchingGrants.length,
matchingGrants: matchingGrants.map(serializeGrant),
scopes: uniqueStrings(matchingGrants.flatMap((grant) => grant.scopes)),
grantMode: "token-scoped-run-grants",
tokenGrantScope: "token",
tokenTtlSeconds: input.ttlSeconds,
wouldIssueToken: Boolean(input.entitlementCandidate),
mcpServer: {
serverName: OPS_MCP_SERVER_NAME,
url: endpoint,
required: false,
startupTimeoutSec: 20,
toolTimeoutSec: 60,
},
};
}
function buildEmptyOpsAppGrant(requestedContext: RequestedGrantContext, status: string): Record<string, unknown> {
return {
appId: OPS_APP_ID,
appTitle: OPS_APP_TITLE,
status,
source: "ops-agent-gateway",
grantMode: "token-scoped-run-grants",
scopes: [],
context: {
requestedWorkspaceSlug: requestedContext.workspaceSlug,
requestedProjectId: requestedContext.projectId,
},
mcpServers: [],
};
}
function filterMatchingGrants(grants: AgentGrantRecord[], requestedContext: RequestedGrantContext): AgentGrantRecord[] {
if (!requestedContext.workspaceSlug && !requestedContext.projectId) {
return [];
}
return grants.filter((grant) => {
if (requestedContext.workspaceSlug && grant.workspaceSlug !== requestedContext.workspaceSlug) {
return false;
}
if (requestedContext.projectId) {
if (grant.projectId === requestedContext.projectId) {
return true;
}
return Boolean(requestedContext.workspaceSlug && grant.projectId === null);
}
return grant.projectId === null;
});
}
function buildRunTokenGrants(grants: AgentGrantRecord[], createdByUserId: string): CreateTokenGrantInput[] {
return grants.map((grant) => ({
workspaceSlug: grant.workspaceSlug,
projectId: grant.projectId,
scopes: grant.scopes,
mode: grant.mode,
createdByUserId,
sourceGrantId: grant.id,
}));
}
function extractRequestedGrantContext(runContext?: Record<string, unknown>, activeContext?: Record<string, unknown>): RequestedGrantContext {
const contextSources = [
getRecordValue(runContext, "ops"),
getRecordValue(runContext, "opsContext"),
getRecordValue(runContext, "activeContext"),
runContext,
getRecordValue(activeContext, "ops"),
getRecordValue(activeContext, "opsContext"),
activeContext,
];
return {
workspaceSlug: firstTextValue(contextSources, ["workspaceSlug", "workspace_slug", "opsWorkspaceSlug", "ops_workspace_slug"]),
projectId: firstTextValue(contextSources, ["projectId", "project_id", "opsProjectId", "ops_project_id"]),
};
}
function firstTextValue(sources: Array<Record<string, unknown> | undefined>, keys: string[]): string | null {
for (const source of sources) {
if (!source) {
continue;
}
for (const key of keys) {
const value = normalizeText(source[key]);
if (value) {
return value;
}
}
}
return null;
}
function getRecordValue(source: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
const value = source?.[key];
return isRecord(value) ? value : undefined;
}
function normalizeText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise<AgentSessionRecord> { async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise<AgentSessionRecord> {
const repository = requireRepository(deps); const repository = requireRepository(deps);
const token = parseBearerToken(authorization); const token = parseBearerToken(authorization);
@ -705,6 +1068,7 @@ function serializeToken(token: AgentTokenRecord): Record<string, unknown> {
agent_id: token.agentId, agent_id: token.agentId,
name: token.name, name: token.name,
status: token.status, status: token.status,
grant_scope: token.grantScope,
token_suffix: token.tokenSuffix, token_suffix: token.tokenSuffix,
expires_at: token.expiresAt, expires_at: token.expiresAt,
last_used_at: token.lastUsedAt, last_used_at: token.lastUsedAt,
@ -716,6 +1080,7 @@ function serializeAgentSession(session: AgentSessionRecord): Record<string, unkn
return { return {
agent: serializeAgent(session.agent), agent: serializeAgent(session.agent),
token: serializeToken(session.token), token: serializeToken(session.token),
grant_source: session.grantSource,
grants: session.grants.map(serializeGrant), grants: session.grants.map(serializeGrant),
effective_scopes: [...new Set(session.grants.flatMap((grant) => grant.scopes))], effective_scopes: [...new Set(session.grants.flatMap((grant) => grant.scopes))],
modes: [...new Set(session.grants.map((grant) => grant.mode))], modes: [...new Set(session.grants.map((grant) => grant.mode))],
@ -766,15 +1131,17 @@ function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Recor
function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[], publicUrl: string): Record<string, unknown> { function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[], publicUrl: string): Record<string, unknown> {
return buildSetupPacket( return buildSetupPacket(
{ {
agent, agent,
grants, grants,
token: { grantSource: "agent",
id: "00000000-0000-0000-0000-000000000000", token: {
agentId: agent.id, id: "00000000-0000-0000-0000-000000000000",
name: "Agent token placeholder", agentId: agent.id,
status: "active", name: "Agent token placeholder",
tokenSuffix: null, status: "active",
expiresAt: null, grantScope: "agent",
tokenSuffix: null,
expiresAt: null,
lastUsedAt: null, lastUsedAt: null,
createdAt: new Date(0).toISOString(), createdAt: new Date(0).toISOString(),
}, },

View File

@ -31,6 +31,7 @@ const app = await buildApp(config);
try { try {
const suffix = Date.now().toString(36); const suffix = Date.now().toString(36);
const projectId = `contract-project-${suffix}`; const projectId = `contract-project-${suffix}`;
const otherProjectId = `contract-other-project-${suffix}`;
const workspaceSlug = `contract-workspace-${suffix}`; const workspaceSlug = `contract-workspace-${suffix}`;
const deniedLifecycleResponse = await app.inject({ const deniedLifecycleResponse = await app.inject({
@ -39,6 +40,28 @@ try {
}); });
assertStatus(deniedLifecycleResponse.statusCode, 401, deniedLifecycleResponse.body); assertStatus(deniedLifecycleResponse.statusCode, 401, deniedLifecycleResponse.body);
const deniedPreflightResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/preflight",
payload: {
appId: "ops",
owner: { userId: `smoke-owner-${suffix}` },
runContext: {},
},
});
assertStatus(deniedPreflightResponse.statusCode, 401, deniedPreflightResponse.body);
const deniedEntitlementResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/entitlements",
payload: {
appId: "ops",
owner: { userId: `smoke-owner-${suffix}` },
runContext: {},
},
});
assertStatus(deniedEntitlementResponse.statusCode, 401, deniedEntitlementResponse.body);
const createAgentResponse = await app.inject({ const createAgentResponse = await app.inject({
method: "POST", method: "POST",
url: "/api/v1/agents", url: "/api/v1/agents",
@ -82,6 +105,259 @@ try {
}); });
assertStatus(readGrantResponse.statusCode, 201, readGrantResponse.body); assertStatus(readGrantResponse.statusCode, 201, readGrantResponse.body);
const otherProjectWriteGrantResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
headers: internalHeaders,
payload: {
workspace_slug: workspaceSlug,
project_id: otherProjectId,
scopes: ["workspace:read", "project:read", "issue:read", "issue:create"],
mode: "voluntary",
created_by_user_id: "smoke-admin",
},
});
assertStatus(otherProjectWriteGrantResponse.statusCode, 201, otherProjectWriteGrantResponse.body);
const preflightResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/preflight",
headers: internalHeaders,
payload: {
schemaVersion: "ai-workspace.entitlement-request.v1",
appId: "ops",
owner: {
userId: ownerUserId,
email: createAgentPayload.agent.owner_email,
},
runContext: {
ops: {
workspaceSlug,
projectId,
},
},
},
});
assertStatus(preflightResponse.statusCode, 200, preflightResponse.body);
const preflightPayload = JSON.parse(preflightResponse.body);
assert(preflightPayload.status === "ready", "AI Workspace preflight returns ready for matching grant");
assert(preflightPayload.diagnostics?.wouldIssueToken === true, "AI Workspace preflight reports token would be issued");
assert(preflightPayload.diagnostics?.grantMode === "token-scoped-run-grants", "AI Workspace preflight reports token-scoped mode");
assert(preflightPayload.diagnostics?.mcpServer?.url?.endsWith("/mcp"), "AI Workspace preflight reports MCP endpoint without token");
assert(!preflightResponse.body.includes("ndcag_"), "AI Workspace preflight does not expose a run token");
assert(!preflightResponse.body.includes("Authorization"), "AI Workspace preflight does not expose authorization headers");
const entitlementResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/entitlements",
headers: internalHeaders,
payload: {
schemaVersion: "ai-workspace.entitlement-request.v1",
appId: "ops",
owner: {
userId: ownerUserId,
email: createAgentPayload.agent.owner_email,
},
runContext: {
ops: {
workspaceSlug,
projectId,
},
},
requestedAt: new Date().toISOString(),
},
});
assertStatus(entitlementResponse.statusCode, 200, entitlementResponse.body);
const entitlementPayload = JSON.parse(entitlementResponse.body);
const entitlementGrant = entitlementPayload.appGrants?.ops;
const entitlementTokenHeader = entitlementGrant?.mcpServers?.[0]?.httpHeaders?.Authorization as string | undefined;
assert(entitlementGrant?.status === "granted", "AI Workspace entitlement returns granted Ops app grant");
assert(entitlementGrant?.grantMode === "token-scoped-run-grants", "AI Workspace entitlement uses token-scoped grants");
assert(entitlementGrant?.tokenTtlSeconds === config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS, "AI Workspace entitlement reports configured token TTL");
assert(typeof entitlementGrant?.tokenExpiresAt === "string", "AI Workspace entitlement reports token expiry");
assert(entitlementGrant?.mcpServers?.[0]?.url?.endsWith("/mcp"), "AI Workspace entitlement returns MCP endpoint");
assert(entitlementTokenHeader?.startsWith("Bearer ndcag_"), "AI Workspace entitlement returns run token header");
const entitlementSessionResponse = await app.inject({
method: "GET",
url: "/api/v1/agent-session",
headers: {
Authorization: entitlementTokenHeader,
},
});
assertStatus(entitlementSessionResponse.statusCode, 200, entitlementSessionResponse.body);
const entitlementSessionPayload = JSON.parse(entitlementSessionResponse.body);
assert(entitlementSessionPayload.agent_session?.grant_source === "token", "AI Workspace entitlement token uses token grant source");
assert(entitlementSessionPayload.agent_session?.token?.grant_scope === "token", "AI Workspace entitlement token is marked token-scoped");
assert(entitlementSessionPayload.agent_session?.grants?.length === 1, "AI Workspace entitlement token is narrowed to matching grants");
assert(
entitlementSessionPayload.agent_session?.grants?.[0]?.project_id === projectId,
"AI Workspace entitlement token grants only requested project"
);
assert(
!entitlementSessionPayload.agent_session?.effective_scopes?.includes("issue:create"),
"AI Workspace entitlement token does not inherit write scope from other project"
);
const entitlementToolsResponse = await app.inject({
method: "POST",
url: "/mcp",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"MCP-Protocol-Version": "2025-06-18",
Authorization: entitlementTokenHeader,
},
payload: {
jsonrpc: "2.0",
id: "tools",
method: "tools/list",
params: {},
},
});
assertStatus(entitlementToolsResponse.statusCode, 200, entitlementToolsResponse.body);
const entitlementToolsPayload = JSON.parse(entitlementToolsResponse.body);
const entitlementToolNames = entitlementToolsPayload.result?.tools?.map((tool: { name: string }) => tool.name) ?? [];
assert(entitlementToolNames.includes("tasker_search_issues"), "AI Workspace entitlement token includes read tools");
assert(!entitlementToolNames.includes("tasker_create_issue"), "AI Workspace entitlement token excludes write tools without matching scope");
const entitlementCrossProjectWriteResponse = await app.inject({
method: "POST",
url: "/api/v1/tools/issues",
headers: {
Authorization: entitlementTokenHeader,
"Idempotency-Key": `gateway-entitlement-denied-cross-project-${suffix}`,
},
payload: {
project_id: otherProjectId,
workspace_slug: workspaceSlug,
title: "Should be denied because entitlement token is project-scoped",
},
});
assertStatus(entitlementCrossProjectWriteResponse.statusCode, 403, entitlementCrossProjectWriteResponse.body);
const replaceSourceGrantResponse = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants/replace-projects`,
headers: internalHeaders,
payload: {
grants: [
{
workspace_slug: workspaceSlug,
project_id: otherProjectId,
},
],
scopes: ["workspace:read", "project:read", "issue:read", "issue:create"],
mode: "voluntary",
created_by_user_id: "smoke-admin",
},
});
assertStatus(replaceSourceGrantResponse.statusCode, 200, replaceSourceGrantResponse.body);
const entitlementSessionAfterGrantReplaceResponse = await app.inject({
method: "GET",
url: "/api/v1/agent-session",
headers: {
Authorization: entitlementTokenHeader,
},
});
assertStatus(entitlementSessionAfterGrantReplaceResponse.statusCode, 200, entitlementSessionAfterGrantReplaceResponse.body);
const entitlementSessionAfterGrantReplacePayload = JSON.parse(entitlementSessionAfterGrantReplaceResponse.body);
assert(
entitlementSessionAfterGrantReplacePayload.agent_session?.grants?.length === 0,
"AI Workspace entitlement token loses source-linked grants after source grant replacement"
);
assert(
!entitlementSessionAfterGrantReplacePayload.agent_session?.effective_scopes?.includes("issue:read"),
"AI Workspace entitlement token loses effective scopes after source grant replacement"
);
const entitlementToolsAfterGrantReplaceResponse = await app.inject({
method: "POST",
url: "/mcp",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"MCP-Protocol-Version": "2025-06-18",
Authorization: entitlementTokenHeader,
},
payload: {
jsonrpc: "2.0",
id: "tools-after-grant-replace",
method: "tools/list",
params: {},
},
});
assertStatus(entitlementToolsAfterGrantReplaceResponse.statusCode, 200, entitlementToolsAfterGrantReplaceResponse.body);
const entitlementToolsAfterGrantReplacePayload = JSON.parse(entitlementToolsAfterGrantReplaceResponse.body);
const entitlementToolNamesAfterGrantReplace =
entitlementToolsAfterGrantReplacePayload.result?.tools?.map((tool: { name: string }) => tool.name) ?? [];
assert(
!entitlementToolNamesAfterGrantReplace.includes("tasker_search_issues"),
"AI Workspace entitlement token loses read tools after source grant replacement"
);
assert(
!entitlementToolNamesAfterGrantReplace.includes("tasker_create_issue"),
"AI Workspace entitlement token still excludes write tools after source grant replacement"
);
const broadContextEntitlementResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/entitlements",
headers: internalHeaders,
payload: {
appId: "ops",
owner: {
userId: ownerUserId,
},
runContext: {},
},
});
assertStatus(broadContextEntitlementResponse.statusCode, 200, broadContextEntitlementResponse.body);
const broadContextEntitlementPayload = JSON.parse(broadContextEntitlementResponse.body);
assert(
broadContextEntitlementPayload.appGrants?.ops?.status === "matching_grant_not_found",
"AI Workspace entitlement without target context does not issue broad token"
);
const broadContextPreflightResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/preflight",
headers: internalHeaders,
payload: {
appId: "ops",
owner: {
userId: ownerUserId,
},
runContext: {},
},
});
assertStatus(broadContextPreflightResponse.statusCode, 200, broadContextPreflightResponse.body);
const broadContextPreflightPayload = JSON.parse(broadContextPreflightResponse.body);
assert(broadContextPreflightPayload.status === "matching_grant_not_found", "AI Workspace preflight rejects broad context");
assert(broadContextPreflightPayload.diagnostics?.wouldIssueToken === false, "AI Workspace preflight does not issue broad token");
const missingEntitlementResponse = await app.inject({
method: "POST",
url: "/api/internal/v1/ai-workspace/entitlements",
headers: internalHeaders,
payload: {
appId: "ops",
owner: {
userId: `missing-owner-${suffix}`,
},
runContext: {
ops: {
workspaceSlug,
projectId,
},
},
},
});
assertStatus(missingEntitlementResponse.statusCode, 200, missingEntitlementResponse.body);
const missingEntitlementPayload = JSON.parse(missingEntitlementResponse.body);
assert(missingEntitlementPayload.appGrants?.ops?.status === "agent_not_found", "missing entitlement is explicit and non-fatal");
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`,
@ -196,6 +472,12 @@ try {
checks: { checks: {
session_auth: "passed", session_auth: "passed",
setup_packet: "passed", setup_packet: "passed",
ai_workspace_entitlement: "passed",
token_scoped_entitlement: "passed",
ai_workspace_preflight: "passed",
internal_entitlement_auth: "passed",
preflight_no_token_leak: "passed",
source_linked_token_grant_revocation: "passed",
lifecycle_internal_auth: "passed", lifecycle_internal_auth: "passed",
owner_lifecycle_api: "passed", owner_lifecycle_api: "passed",
denied_without_scope: "passed", denied_without_scope: "passed",