From c0a6fac55ff49a02efa4c75735c26e37273d3a3d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 13 Jun 2026 17:30:44 +0300 Subject: [PATCH] Stabilize AI Workspace Ops entitlements --- .env.example | 5 + .env.synology.example | 1 + README.md | 33 +++ docker-compose.local.yml | 1 + docker-compose.synology.yml | 1 + migrations/004_run_grants.sql | 21 ++ src/app.ts | 1 + src/config.ts | 1 + src/mcp/tool-runtime.ts | 1 + src/repositories/agents.ts | 174 +++++++++++++-- src/routes/agents.ts | 387 +++++++++++++++++++++++++++++++++- src/scripts/smoke-gateway.ts | 282 +++++++++++++++++++++++++ 12 files changed, 884 insertions(+), 24 deletions(-) create mode 100644 migrations/004_run_grants.sql diff --git a/.env.example b/.env.example index e38f09d..3cd6096 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,13 @@ PORT=4100 HOST_PORT=4100 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_INTERNAL_TOKEN=replace-with-gateway-internal-token +NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200 NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token diff --git a/.env.synology.example b/.env.synology.example index 774982c..46aaf5a 100644 --- a/.env.synology.example +++ b/.env.synology.example @@ -7,6 +7,7 @@ LOG_LEVEL=info NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru 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_TASKER_INTERNAL_URL=http://172.22.0.222:18090 NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token diff --git a/README.md b/README.md index dc09803..dd07327 100644 --- a/README.md +++ b/README.md @@ -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. - 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. +- 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. - 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. @@ -100,6 +101,38 @@ curl http://127.0.0.1:4100/api/internal/v1/owners//agents \ 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":""}} + }' +``` + +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":""}} + }' +``` + +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: ```bash diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0248923..0728604 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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} 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_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_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} diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml index cec703d..1867291 100644 --- a/docker-compose.synology.yml +++ b/docker-compose.synology.yml @@ -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} 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_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_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN} diff --git a/migrations/004_run_grants.sql b/migrations/004_run_grants.sql new file mode 100644 index 0000000..34ac6fe --- /dev/null +++ b/migrations/004_run_grants.sql @@ -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); diff --git a/src/app.ts b/src/app.ts index 2ebea49..20b8ae3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -132,6 +132,7 @@ export async function buildApp(config: AppConfig): Promise { agentsRepository, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL, internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN, + aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS, }); await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient }); diff --git a/src/config.ts b/src/config.ts index 837d2e9..aa25fe7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ const configSchema = z.object({ 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_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_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"), NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(), diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts index d79cb6a..aa7ac6e 100644 --- a/src/mcp/tool-runtime.ts +++ b/src/mcp/tool-runtime.ts @@ -662,6 +662,7 @@ function buildAgentInstructions(session: AgentSessionRecord): Record(allowedAgentScopes); export type AgentStatus = "active" | "disabled" | "revoked"; export type AgentGrantMode = "voluntary" | "reporting"; export type AgentTokenStatus = "active" | "revoked" | "expired"; +export type AgentTokenGrantScope = "agent" | "token"; export type AgentRecord = { id: string; @@ -39,6 +40,7 @@ export type AgentTokenRecord = { agentId: string; name: string; status: AgentTokenStatus; + grantScope: AgentTokenGrantScope; tokenSuffix: string | null; expiresAt: string | null; lastUsedAt: string | null; @@ -49,6 +51,7 @@ export type AgentSessionRecord = { agent: AgentRecord; token: AgentTokenRecord; grants: AgentGrantRecord[]; + grantSource: AgentTokenGrantScope; }; type AgentRow = { @@ -79,6 +82,7 @@ type TokenRow = { agent_id: string; name: string; status: AgentTokenStatus; + grant_scope: AgentTokenGrantScope; token_suffix: string | null; expires_at: Date | null; last_used_at: Date | null; @@ -89,12 +93,27 @@ type SessionRow = AgentRow & { token_id: string; token_name: string; token_status: AgentTokenStatus; + token_grant_scope: AgentTokenGrantScope; token_suffix: string | null; token_expires_at: Date | null; token_last_used_at: Date | null; 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 = { key: string; agent_id: string; @@ -151,6 +170,16 @@ export type CreateTokenInput = { token: string; name: string; 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 = @@ -193,6 +222,28 @@ export class AgentsRepository { return result.rows.map(mapAgent); } + async listAgentsByOwnerIdentity(ownerUserId?: string, ownerEmail?: string): Promise { + const normalizedOwnerUserId = normalizeNullableText(ownerUserId); + const normalizedOwnerEmail = normalizeNullableText(ownerEmail)?.toLowerCase(); + + if (!normalizedOwnerUserId && !normalizedOwnerEmail) { + return []; + } + + const result = await this.pool.query( + ` + 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 { const result = await this.pool.query("SELECT * FROM agents WHERE id = $1", [agentId]); return result.rows[0] ? mapAgent(result.rows[0]) : null; @@ -435,28 +486,68 @@ export class AgentsRepository { } async createToken(agentId: string, input: CreateTokenInput): Promise { - const result = await this.pool.query( - ` - INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at - `, - [agentId, hashAgentToken(input.token), input.token.slice(-8), input.name, input.expiresAt ?? null] - ); + const tokenGrants = input.tokenGrants ?? []; + const grantScope: AgentTokenGrantScope = tokenGrants.length > 0 ? "token" : "agent"; + const client = await this.pool.connect(); + let tokenRecord: AgentTokenRecord; + + for (const grant of tokenGrants) { + assertAllowedScopes(grant.scopes); + } + + try { + await client.query("BEGIN"); + const result = await client.query( + ` + 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, { - tokenId: result.rows[0].id, + tokenId: tokenRecord.id, name: input.name, expiresAt: input.expiresAt ?? null, + grantScope, + tokenGrantCount: tokenGrants.length, }); - return mapToken(result.rows[0]); + return tokenRecord; } async listTokens(agentId: string): Promise { const result = await this.pool.query( ` - 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 WHERE agent_id = $1 ORDER BY created_at DESC @@ -472,7 +563,7 @@ export class AgentsRepository { UPDATE agent_tokens SET status = 'revoked' 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] ); @@ -500,6 +591,7 @@ export class AgentsRepository { agent_tokens.id AS token_id, agent_tokens.name AS token_name, agent_tokens.status AS token_status, + agent_tokens.grant_scope AS token_grant_scope, agent_tokens.token_suffix AS token_suffix, agent_tokens.expires_at AS token_expires_at, agent_tokens.last_used_at AS token_last_used_at, @@ -525,7 +617,7 @@ export class AgentsRepository { UPDATE agent_tokens SET last_used_at = now() 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] ); @@ -533,10 +625,44 @@ export class AgentsRepository { return { agent: mapAgent(row), 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 { + const result = await this.pool.query( + ` + 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 = {}): Promise { await this.pool.query( ` @@ -643,6 +769,11 @@ function buildIdempotencyStorageKey(agentId: string, idempotencyKey: string): st return `${agentId}:${idempotencyKey}`; } +function normalizeNullableText(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + function assertAllowedScopes(scopes: AgentScope[]): void { const deniedScope = scopes.find((scope) => !allowedScopeSet.has(scope)); @@ -684,9 +815,24 @@ function mapToken(row: TokenRow): AgentTokenRecord { agentId: row.agent_id, name: row.name, status: row.status, + grantScope: row.grant_scope, tokenSuffix: row.token_suffix, expiresAt: row.expires_at?.toISOString() ?? null, lastUsedAt: row.last_used_at?.toISOString() ?? null, 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(), + }; +} diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 8d059ae..8dcf527 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -5,7 +5,14 @@ import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAu import { DatabaseNotConfiguredError } from "../db/pool.js"; import { getToolsForSession } from "../mcp/tool-runtime.js"; import { mcpToolDefinitions } from "../mcp/tools.js"; -import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; +import type { + AgentGrantRecord, + AgentRecord, + AgentSessionRecord, + AgentTokenRecord, + AgentsRepository, + CreateTokenGrantInput, +} from "../repositories/agents.js"; import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; import { requireInternalAccess } from "../security/internal.js"; import { generateAgentToken } from "../security/tokens.js"; @@ -14,9 +21,13 @@ type AgentRouteDeps = { agentsRepository: AgentsRepository | null; publicUrl: string; internalAccessToken?: string; + aiWorkspaceRunTokenTtlSeconds: number; }; 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 { if (value.startsWith("data:image/")) { @@ -118,6 +129,23 @@ const actorBodySchema = z.object({ 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 { app.get("/api/v1/meta/capabilities", async () => ({ 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) => { requireLifecycleAccess(request, 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 { + 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 { + 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 { + 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 { + 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, activeContext?: Record): 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 | 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 | undefined, key: string): Record | 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise { const repository = requireRepository(deps); const token = parseBearerToken(authorization); @@ -705,6 +1068,7 @@ function serializeToken(token: AgentTokenRecord): Record { agent_id: token.agentId, name: token.name, status: token.status, + grant_scope: token.grantScope, token_suffix: token.tokenSuffix, expires_at: token.expiresAt, last_used_at: token.lastUsedAt, @@ -716,6 +1080,7 @@ function serializeAgentSession(session: AgentSessionRecord): Record grant.scopes))], 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 { return buildSetupPacket( { - agent, - grants, - token: { - id: "00000000-0000-0000-0000-000000000000", - agentId: agent.id, - name: "Agent token placeholder", - status: "active", - tokenSuffix: null, - expiresAt: null, + agent, + grants, + grantSource: "agent", + token: { + id: "00000000-0000-0000-0000-000000000000", + agentId: agent.id, + name: "Agent token placeholder", + status: "active", + grantScope: "agent", + tokenSuffix: null, + expiresAt: null, lastUsedAt: null, createdAt: new Date(0).toISOString(), }, diff --git a/src/scripts/smoke-gateway.ts b/src/scripts/smoke-gateway.ts index 95ffe21..566205c 100644 --- a/src/scripts/smoke-gateway.ts +++ b/src/scripts/smoke-gateway.ts @@ -31,6 +31,7 @@ const app = await buildApp(config); try { const suffix = Date.now().toString(36); const projectId = `contract-project-${suffix}`; + const otherProjectId = `contract-other-project-${suffix}`; const workspaceSlug = `contract-workspace-${suffix}`; const deniedLifecycleResponse = await app.inject({ @@ -39,6 +40,28 @@ try { }); 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({ method: "POST", url: "/api/v1/agents", @@ -82,6 +105,259 @@ try { }); 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({ method: "POST", url: `/api/v1/agents/${agentId}/tokens`, @@ -196,6 +472,12 @@ try { checks: { session_auth: "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", owner_lifecycle_api: "passed", denied_without_scope: "passed",