Merge CROSSASSISTANT AI Workspace stabilization
This commit is contained in:
commit
3acf758743
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
33
README.md
33
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.
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue