Compare commits
2 Commits
6b3ecd6e1f
...
3acf758743
| Author | SHA1 | Date |
|---|---|---|
|
|
3acf758743 | |
|
|
c0a6fac55f |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue