Merge CROSSASSISTANT AI Workspace stabilization

This commit is contained in:
DCCONSTRUCTIONS 2026-06-13 17:58:15 +03:00
commit 3acf758743
12 changed files with 884 additions and 24 deletions

View File

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

View File

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

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.
- 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/<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.
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:
```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}
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}

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

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

View File

@ -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(),

View File

@ -662,6 +662,7 @@ function buildAgentInstructions(session: AgentSessionRecord): Record<string, unk
mode: grant.mode,
scopes: grant.scopes,
})),
grant_source: session.grantSource,
rules: {
card_structure: [
"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 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<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> {
const result = await this.pool.query<AgentRow>("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<AgentTokenRecord> {
const result = await this.pool.query<TokenRow>(
`
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<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, {
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<AgentTokenRecord[]> {
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
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<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> {
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(),
};
}

View File

@ -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<void> {
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<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> {
const repository = requireRepository(deps);
const token = parseBearerToken(authorization);
@ -705,6 +1068,7 @@ function serializeToken(token: AgentTokenRecord): Record<string, unknown> {
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<string, unkn
return {
agent: serializeAgent(session.agent),
token: serializeToken(session.token),
grant_source: session.grantSource,
grants: session.grants.map(serializeGrant),
effective_scopes: [...new Set(session.grants.flatMap((grant) => 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<string, unknown> {
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(),
},

View File

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