import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; import { DatabaseNotConfiguredError } from "../db/pool.js"; import { getToolsForSession } from "../mcp/tool-runtime.js"; import { mcpToolDefinitions } from "../mcp/tools.js"; import type { AgentGrantRecord, AgentRecord, AgentSetupCodeRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository, CreateTokenGrantInput, } from "../repositories/agents.js"; import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; import { requireInternalAccess } from "../security/internal.js"; import { generateAgentSetupCode, generateAgentToken } from "../security/agent-access.js"; type AgentRouteDeps = { agentsRepository: AgentsRepository | null; codexInstallChannel: "registry" | "tarball"; codexNpmSpec: string; 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"; const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent"; const CODEX_NPM_PACKAGE_VERSION = "0.1.2"; const DEFAULT_OPS_AGENT_GATEWAY = "https://ops-agents.nodedc.ru"; const DEFAULT_SETUP_CODE_TTL_SECONDS = 10 * 60; const MAX_SETUP_CODE_TTL_SECONDS = 24 * 60 * 60; function isAllowedAvatarUrl(value: string): boolean { if (value.startsWith("data:image/")) { return /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(value); } try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } const agentAvatarUrlSchema = z .string() .max(MAX_AGENT_AVATAR_URL_LENGTH) .refine(isAllowedAvatarUrl, "avatar_url must be http(s) URL or image data URL"); const agentParamsSchema = z.object({ agentId: z.string().uuid(), }); const ownerParamsSchema = z.object({ ownerUserId: z.string().min(1), }); const ownerAgentParamsSchema = ownerParamsSchema.extend({ agentId: z.string().uuid(), }); const tokenParamsSchema = agentParamsSchema.extend({ tokenId: z.string().uuid(), }); const ownerTokenParamsSchema = ownerAgentParamsSchema.extend({ tokenId: z.string().uuid(), }); const listAgentsQuerySchema = z.object({ owner_user_id: z.string().min(1).optional(), }); const createAgentBodySchema = z.object({ owner_user_id: z.string().min(1), owner_email: z.string().email().nullish(), display_name: z.string().min(1).max(120), avatar_url: agentAvatarUrlSchema.nullish(), }); const createOwnerAgentBodySchema = z.object({ owner_email: z.string().email().nullish(), display_name: z.string().min(1).max(120), avatar_url: agentAvatarUrlSchema.nullish(), }); const updateOwnerAgentBodySchema = z.object({ display_name: z.string().min(1).max(120).optional(), avatar_url: agentAvatarUrlSchema.nullable().optional(), actor_user_id: z.string().min(1).optional(), }); const upsertGrantBodySchema = z.object({ workspace_slug: z.string().min(1), project_id: z.string().min(1).nullish(), scopes: z.array(z.enum(allowedAgentScopes)).min(1), mode: z.enum(["voluntary", "reporting"]).default("voluntary"), created_by_user_id: z.string().min(1), }); const replaceWorkspaceGrantsBodySchema = z.object({ workspace_slug: z.string().min(1), project_ids: z.array(z.string().min(1)).min(1), scopes: z.array(z.enum(allowedAgentScopes)).min(1), mode: z.enum(["voluntary", "reporting"]).default("voluntary"), created_by_user_id: z.string().min(1), }); const replaceProjectGrantsBodySchema = z.object({ grants: z .array( z.object({ workspace_slug: z.string().min(1), project_id: z.string().min(1), }) ) .min(1), scopes: z.array(z.enum(allowedAgentScopes)).min(1), mode: z.enum(["voluntary", "reporting"]).default("voluntary"), created_by_user_id: z.string().min(1), }); const createTokenBodySchema = z.object({ name: z.string().min(1).max(120).default("Local Codex token"), expires_at: z.string().datetime().nullish(), }); const createSetupCodeBodySchema = z.object({ expires_in_seconds: z.number().int().min(60).max(MAX_SETUP_CODE_TTL_SECONDS).default(DEFAULT_SETUP_CODE_TTL_SECONDS), token_name: z.string().min(1).max(120).default("Local Codex token"), }); const redeemSetupCodeBodySchema = z.object({ setup_code: z.string().min(16).max(160), }); const actorBodySchema = z.object({ actor_user_id: z.string().min(1).optional(), }); const aiWorkspaceEntitlementBodySchema = z.object({ schemaVersion: z.literal("ai-workspace.entitlement-request.v1").optional(), appId: z.string().min(1).default(OPS_APP_ID), owner: z .object({ ownerKey: z.string().min(1).optional(), userId: z.string().min(1).optional(), email: z.string().email().optional(), role: z.string().min(1).optional(), groups: z.array(z.string().min(1)).optional(), }) .default({}), activeContext: z.record(z.string(), z.unknown()).optional(), runContext: z.record(z.string(), z.unknown()).optional(), requestedAt: z.string().datetime().optional(), }); export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRouteDeps): Promise { app.get("/api/v1/meta/capabilities", async () => ({ ok: true, presets: { task_author: taskAuthorPresetScopes, reporter: reporterPresetScopes, }, allowed_scopes: allowedAgentScopes, denied_mvp_capabilities: deniedMvpCapabilities, mcp_tools: mcpToolDefinitions, })); app.get("/api/v1/agent-session", async (request) => { const session = await authenticateAgentSession(request.headers.authorization, deps); return { ok: true, agent_session: serializeAgentSession(session), }; }); app.get("/api/v1/agent-session/setup", async (request) => { const session = await authenticateAgentSession(request.headers.authorization, deps); return { ok: true, setup: buildSetupPacket(session, deps.publicUrl), }; }); app.post("/api/v1/setup-codes/redeem", async (request, reply) => { const repository = requireRepository(deps); const body = redeemSetupCodeBodySchema.parse(request.body ?? {}); const token = generateAgentToken(); const result = await repository.redeemSetupCode(body.setup_code, token); if (result.status !== "redeemed") { return reply.status(400).send({ ok: false, error: "setup_code_not_redeemable", status: result.status, message: "Setup code is invalid, expired, used, or revoked.", }); } return { ok: true, token, token_record: serializeToken(result.token), agent: serializeAgent(result.agent), setup_code: serializeSetupCode(result.setupCode), setup: buildSetupPacketForAgent(result.agent, result.grants, deps.publicUrl), }; }); 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); const body = createAgentBodySchema.parse(request.body); const agent = await repository.createAgent({ ownerUserId: body.owner_user_id, ownerEmail: body.owner_email, displayName: body.display_name, avatarUrl: body.avatar_url, }); return reply.status(201).send({ ok: true, agent: serializeAgent(agent), }); }); app.get("/api/v1/agents", async (request) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const query = listAgentsQuerySchema.parse(request.query); const agents = await repository.listAgents(query.owner_user_id); return { ok: true, agents: agents.map(serializeAgent), }; }); app.get("/api/v1/agents/:agentId", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } return { ok: true, agent: serializeAgent(agent), }; }); app.post("/api/v1/agents/:agentId/revoke", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); const agent = await repository.revokeAgent(agentId, body.actor_user_id); if (!agent) { return sendNotFound(reply, "agent_not_found"); } return { ok: true, agent: serializeAgent(agent), }; }); app.post("/api/v1/agents/:agentId/grants", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = upsertGrantBodySchema.parse(request.body); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const grant = await repository.upsertGrant(agentId, { workspaceSlug: body.workspace_slug, projectId: body.project_id, scopes: body.scopes, mode: body.mode, createdByUserId: body.created_by_user_id, }); return reply.status(201).send({ ok: true, grant: serializeGrant(grant), }); }); app.get("/api/v1/agents/:agentId/grants", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.listGrants(agentId); return { ok: true, grants: grants.map(serializeGrant), }; }); app.post("/api/v1/agents/:agentId/grants/replace", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = replaceWorkspaceGrantsBodySchema.parse(request.body); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.replaceWorkspaceProjectGrants(agentId, { workspaceSlug: body.workspace_slug, projectIds: body.project_ids, scopes: body.scopes, mode: body.mode, actorUserId: body.created_by_user_id, }); return reply.status(200).send({ ok: true, grants: grants.map(serializeGrant), }); }); app.post("/api/v1/agents/:agentId/grants/replace-projects", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = replaceProjectGrantsBodySchema.parse(request.body); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.replaceProjectGrants(agentId, { grants: body.grants.map((grant) => ({ workspaceSlug: grant.workspace_slug, projectId: grant.project_id, })), scopes: body.scopes, mode: body.mode, actorUserId: body.created_by_user_id, }); return reply.status(200).send({ ok: true, grants: grants.map(serializeGrant), }); }); app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const body = createTokenBodySchema.parse(request.body ?? {}); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const token = generateAgentToken(); const tokenRecord = await repository.createToken(agentId, { token, name: body.name, expiresAt: body.expires_at, }); return reply.status(201).send({ ok: true, token, token_record: serializeToken(tokenRecord), }); }); app.get("/api/v1/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId } = agentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } const tokens = await repository.listTokens(agentId); return { ok: true, tokens: tokens.map(serializeToken), }; }); app.post("/api/v1/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { agentId, tokenId } = tokenParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id); if (!tokenRecord) { return sendNotFound(reply, "token_not_found"); } return { ok: true, token_record: serializeToken(tokenRecord), }; }); app.get("/api/internal/v1/owners/:ownerUserId/agents", async (request) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId } = ownerParamsSchema.parse(request.params); const agents = await repository.listAgents(ownerUserId); return { ok: true, agents: agents.map(serializeAgent), }; }); app.post("/api/internal/v1/owners/:ownerUserId/agents", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId } = ownerParamsSchema.parse(request.params); const body = createOwnerAgentBodySchema.parse(request.body); const agent = await repository.createAgent({ ownerUserId, ownerEmail: body.owner_email, displayName: body.display_name, avatarUrl: body.avatar_url, }); return reply.status(201).send({ ok: true, agent: serializeAgent(agent), }); }); app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const [grants, tokens] = await Promise.all([repository.listGrants(agentId), repository.listTokens(agentId)]); return { ok: true, agent: serializeAgent(agent), grants: grants.map(serializeGrant), tokens: tokens.map(serializeToken), }; }); app.patch("/api/internal/v1/owners/:ownerUserId/agents/:agentId", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = updateOwnerAgentBodySchema.parse(request.body ?? {}); const existingAgent = await repository.getAgent(agentId); if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const agent = await repository.updateAgentProfile(agentId, { displayName: body.display_name, avatarUrl: body.avatar_url, actorUserId: body.actor_user_id ?? ownerUserId, }); if (!agent) { return sendNotFound(reply, "agent_not_found"); } return { ok: true, agent: serializeAgent(agent), }; }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/revoke", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); const existingAgent = await repository.getAgent(agentId); if (!existingAgent || existingAgent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const agent = await repository.revokeAgent(agentId, body.actor_user_id ?? ownerUserId); if (!agent) { return sendNotFound(reply, "agent_not_found"); } return { ok: true, agent: serializeAgent(agent), }; }); app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.listGrants(agentId); return { ok: true, grants: grants.map(serializeGrant), }; }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = upsertGrantBodySchema.omit({ created_by_user_id: true }).parse(request.body); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const grant = await repository.upsertGrant(agentId, { workspaceSlug: body.workspace_slug, projectId: body.project_id, scopes: body.scopes, mode: body.mode, createdByUserId: ownerUserId, }); return reply.status(201).send({ ok: true, grant: serializeGrant(grant), }); }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = replaceWorkspaceGrantsBodySchema.omit({ created_by_user_id: true }).parse(request.body); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.replaceWorkspaceProjectGrants(agentId, { workspaceSlug: body.workspace_slug, projectIds: body.project_ids, scopes: body.scopes, mode: body.mode, actorUserId: ownerUserId, }); return reply.status(200).send({ ok: true, grants: grants.map(serializeGrant), }); }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace-projects", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = replaceProjectGrantsBodySchema.omit({ created_by_user_id: true }).parse(request.body); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.replaceProjectGrants(agentId, { grants: body.grants.map((grant) => ({ workspaceSlug: grant.workspace_slug, projectId: grant.project_id, })), scopes: body.scopes, mode: body.mode, actorUserId: ownerUserId, }); return reply.status(200).send({ ok: true, grants: grants.map(serializeGrant), }); }); app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const tokens = await repository.listTokens(agentId); return { ok: true, tokens: tokens.map(serializeToken), }; }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = createTokenBodySchema.parse(request.body ?? {}); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const token = generateAgentToken(); const tokenRecord = await repository.createToken(agentId, { token, name: body.name, expiresAt: body.expires_at, }); const grants = await repository.listGrants(agentId); return reply.status(201).send({ ok: true, token, token_record: serializeToken(tokenRecord), setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl), }); }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup-codes", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const body = createSetupCodeBodySchema.parse(request.body ?? {}); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const setupCode = generateAgentSetupCode(); const expiresAt = new Date(Date.now() + body.expires_in_seconds * 1000).toISOString(); const setupCodeRecord = await repository.createSetupCode(agentId, { code: setupCode, createdByUserId: ownerUserId, expiresAt, tokenName: body.token_name, }); return reply.status(201).send({ ok: true, setup_code: setupCode, setup_code_record: serializeSetupCode(setupCodeRecord), install: buildCodexInstallerPacket(setupCode, deps), }); }); app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId, tokenId } = ownerTokenParamsSchema.parse(request.params); const body = actorBodySchema.parse(request.body ?? {}); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id ?? ownerUserId); if (!tokenRecord) { return sendNotFound(reply, "token_not_found"); } return { ok: true, token_record: serializeToken(tokenRecord), }; }); app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/setup", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); const agent = await repository.getAgent(agentId); if (!agent || agent.ownerUserId !== ownerUserId) { return sendNotFound(reply, "agent_not_found"); } const grants = await repository.listGrants(agentId); return { ok: true, setup: buildSetupPacketForAgent(agent, grants, deps.publicUrl), }; }); } function requireRepository(deps: AgentRouteDeps): AgentsRepository { if (!deps.agentsRepository) { throw new DatabaseNotConfiguredError(); } return deps.agentsRepository; } function requireLifecycleAccess(request: FastifyRequest, deps: AgentRouteDeps): void { requireInternalAccess(request.headers.authorization, deps.internalAccessToken); } function sendNotFound(reply: FastifyReply, error: string): FastifyReply { return reply.status(404).send({ ok: false, error, }); } type RequestedGrantContext = { workspaceSlug: string | null; projectId: string | null; }; type EntitlementCandidate = { agent: AgentRecord; allGrants: AgentGrantRecord[]; matchingGrants: AgentGrantRecord[]; }; type BuildOpsAppGrantInput = EntitlementCandidate & { requestedContext: RequestedGrantContext; token: string; tokenRecord: AgentTokenRecord; publicUrl: string; ttlSeconds: number; }; type BuildOpsPreflightDiagnosticsInput = { agents: AgentRecord[]; entitlementCandidate: EntitlementCandidate | null; requestedContext: RequestedGrantContext; publicUrl: string; ttlSeconds: number; }; async function findEntitlementCandidate( repository: AgentsRepository, agents: AgentRecord[], requestedContext: RequestedGrantContext ): Promise { for (const agent of agents) { if (agent.status !== "active") { continue; } const allGrants = await repository.listGrants(agent.id); const matchingGrants = filterMatchingGrants(allGrants, requestedContext); if (matchingGrants.length > 0) { return { agent, allGrants, matchingGrants, }; } } return null; } function buildOpsAppGrant(input: BuildOpsAppGrantInput): Record { const endpoint = new URL("/mcp", input.publicUrl).toString(); return { appId: OPS_APP_ID, appTitle: OPS_APP_TITLE, status: "granted", source: "ops-agent-gateway", grantMode: "token-scoped-run-grants", tokenExpiresAt: input.tokenRecord.expiresAt, tokenTtlSeconds: input.ttlSeconds, scopes: uniqueStrings(input.matchingGrants.flatMap((grant) => grant.scopes)), context: { requestedWorkspaceSlug: input.requestedContext.workspaceSlug, requestedProjectId: input.requestedContext.projectId, agent: serializeAgent(input.agent), matchingGrants: input.matchingGrants.map(serializeGrant), allGrantIds: input.allGrants.map((grant) => grant.id), }, mcpServers: [ { appId: OPS_APP_ID, appTitle: OPS_APP_TITLE, serverName: OPS_MCP_SERVER_NAME, name: OPS_MCP_SERVER_NAME, transport: "streamable_http_json_rpc", url: endpoint, required: false, startupTimeoutSec: 20, toolTimeoutSec: 60, httpHeaders: { Authorization: `Bearer ${input.token}`, Accept: "application/json", "MCP-Protocol-Version": "2025-06-18", }, }, ], }; } function buildOpsPreflightDiagnostics(input: BuildOpsPreflightDiagnosticsInput): Record { const endpoint = new URL("/mcp", input.publicUrl).toString(); const matchingGrants = input.entitlementCandidate?.matchingGrants ?? []; return { source: "ops-agent-gateway", requestedWorkspaceSlug: input.requestedContext.workspaceSlug, requestedProjectId: input.requestedContext.projectId, targetContextPresent: Boolean(input.requestedContext.workspaceSlug || input.requestedContext.projectId), activeAgentCount: input.agents.filter((agent) => agent.status === "active").length, selectedAgent: input.entitlementCandidate ? serializeAgent(input.entitlementCandidate.agent) : null, matchingGrantCount: matchingGrants.length, matchingGrants: matchingGrants.map(serializeGrant), scopes: uniqueStrings(matchingGrants.flatMap((grant) => grant.scopes)), grantMode: "token-scoped-run-grants", tokenGrantScope: "token", tokenTtlSeconds: input.ttlSeconds, wouldIssueToken: Boolean(input.entitlementCandidate), mcpServer: { serverName: OPS_MCP_SERVER_NAME, url: endpoint, required: false, startupTimeoutSec: 20, toolTimeoutSec: 60, }, }; } function buildEmptyOpsAppGrant(requestedContext: RequestedGrantContext, status: string): Record { return { appId: OPS_APP_ID, appTitle: OPS_APP_TITLE, status, source: "ops-agent-gateway", grantMode: "token-scoped-run-grants", scopes: [], context: { requestedWorkspaceSlug: requestedContext.workspaceSlug, requestedProjectId: requestedContext.projectId, }, mcpServers: [], }; } function filterMatchingGrants(grants: AgentGrantRecord[], requestedContext: RequestedGrantContext): AgentGrantRecord[] { if (!requestedContext.workspaceSlug && !requestedContext.projectId) { return []; } return grants.filter((grant) => { if (requestedContext.workspaceSlug && grant.workspaceSlug !== requestedContext.workspaceSlug) { return false; } if (requestedContext.projectId) { if (grant.projectId === requestedContext.projectId) { return true; } return Boolean(requestedContext.workspaceSlug && grant.projectId === null); } return grant.projectId === null; }); } function buildRunTokenGrants(grants: AgentGrantRecord[], createdByUserId: string): CreateTokenGrantInput[] { return grants.map((grant) => ({ workspaceSlug: grant.workspaceSlug, projectId: grant.projectId, scopes: grant.scopes, mode: grant.mode, createdByUserId, sourceGrantId: grant.id, })); } function extractRequestedGrantContext(runContext?: Record, activeContext?: Record): RequestedGrantContext { const contextSources = [ getRecordValue(runContext, "ops"), getRecordValue(runContext, "opsContext"), getRecordValue(runContext, "activeContext"), runContext, getRecordValue(activeContext, "ops"), getRecordValue(activeContext, "opsContext"), activeContext, ]; return { workspaceSlug: firstTextValue(contextSources, ["workspaceSlug", "workspace_slug", "opsWorkspaceSlug", "ops_workspace_slug"]), projectId: firstTextValue(contextSources, ["projectId", "project_id", "opsProjectId", "ops_project_id"]), }; } function firstTextValue(sources: Array | undefined>, keys: string[]): string | null { for (const source of sources) { if (!source) { continue; } for (const key of keys) { const value = normalizeText(source[key]); if (value) { return value; } } } return null; } function getRecordValue(source: Record | undefined, key: string): Record | undefined { const value = source?.[key]; return isRecord(value) ? value : undefined; } function normalizeText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function uniqueStrings(values: string[]): string[] { return [...new Set(values)]; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise { const repository = requireRepository(deps); const token = parseBearerToken(authorization); const session = await repository.findActiveSessionByToken(token); if (!session) { throw new UnauthorizedError("Agent token is inactive, expired, or revoked."); } return session; } function serializeAgent(agent: AgentRecord): Record { return { id: agent.id, owner_user_id: agent.ownerUserId, owner_email: agent.ownerEmail, display_name: agent.displayName, avatar_url: agent.avatarUrl, status: agent.status, created_at: agent.createdAt, updated_at: agent.updatedAt, }; } function serializeGrant(grant: AgentGrantRecord): Record { return { id: grant.id, agent_id: grant.agentId, workspace_slug: grant.workspaceSlug, project_id: grant.projectId, scopes: grant.scopes, mode: grant.mode, created_by_user_id: grant.createdByUserId, created_at: grant.createdAt, updated_at: grant.updatedAt, }; } function serializeToken(token: AgentTokenRecord): Record { return { id: token.id, 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, created_at: token.createdAt, }; } function serializeSetupCode(setupCode: AgentSetupCodeRecord): Record { return { id: setupCode.id, agent_id: setupCode.agentId, code_suffix: setupCode.codeSuffix, status: setupCode.status, token_name: setupCode.tokenName, created_by_user_id: setupCode.createdByUserId, expires_at: setupCode.expiresAt, used_at: setupCode.usedAt, used_token_id: setupCode.usedTokenId, created_at: setupCode.createdAt, }; } function buildCodexInstallerPacket(setupCode: string, deps: AgentRouteDeps): Record { const gatewayUrl = new URL(deps.publicUrl).origin; const packageUrl = new URL("/install/nodedc-ops-codex-setup.tgz", gatewayUrl); packageUrl.searchParams.set("v", CODEX_NPM_PACKAGE_VERSION); packageUrl.searchParams.set("s", setupCode.slice(-8)); const packageUrlString = packageUrl.toString(); const installerUrl = new URL("/install/codex.py", gatewayUrl).toString(); const registryCommand = [ "npx", "--yes", shellWord(deps.codexNpmSpec), "setup", shellWord(setupCode), ...(gatewayUrl === DEFAULT_OPS_AGENT_GATEWAY ? [] : ["--gateway", shellWord(gatewayUrl)]), ].join(" "); const fallbackCommand = [ "npm", "exec", "--yes", `--package=${shellWord(packageUrlString)}`, "--", "ops-codex", "setup", shellWord(setupCode), "--gateway", shellWord(gatewayUrl), ].join(" "); const command = deps.codexInstallChannel === "registry" ? registryCommand : fallbackCommand; return { platform: "macos_linux", installer_kind: deps.codexInstallChannel === "registry" ? "npm_registry" : "npm_tarball", installer_url: packageUrlString, npm_spec: deps.codexNpmSpec, package_url: packageUrlString, legacy_python_installer_url: installerUrl, registry_command: registryCommand, fallback_command: fallbackCommand, mcp_server_name: CODEX_MCP_SERVER_NAME, skill_name: "ops-context", command, }; } function shellWord(value: string): string { if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { return value; } return shellSingleQuote(value); } function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } function serializeAgentSession(session: AgentSessionRecord): Record { 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))], }; } function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Record { const endpoint = new URL("/mcp", publicUrl).toString(); const tokenPlaceholder = ""; return { mcp_server: { name: CODEX_MCP_SERVER_NAME, transport: "streamable_http_json_rpc", url: endpoint, headers: { Authorization: `Bearer ${tokenPlaceholder}`, Accept: "application/json", "MCP-Protocol-Version": "2025-06-18", }, }, codex_config_template: { mcp_servers: { [CODEX_MCP_SERVER_NAME]: { url: endpoint, enabled: true, required: false, startup_timeout_sec: 20, tool_timeout_sec: 60, headers: { Authorization: `Bearer ${tokenPlaceholder}`, Accept: "application/json", "MCP-Protocol-Version": "2025-06-18", }, }, }, }, available_tools: getToolsForSession(session).map((tool) => ({ name: tool.name, title: tool.title, description: tool.description, required_scopes: tool.requiredScopes, })), agents_md: buildAgentsMd(session, endpoint), }; } function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[], publicUrl: string): Record { return buildSetupPacket( { 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(), }, }, publicUrl ); } function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string { const grants = session.grants .map((grant) => `- workspace=${grant.workspaceSlug}; project=${grant.projectId ?? "*"}; mode=${grant.mode}; scopes=${grant.scopes.join(",")}`) .join("\n"); const tools = getToolsForSession(session) .map((tool) => `- ${tool.name}: ${tool.description}`) .join("\n"); return [ "# NODE.DC Ops Agent Rules", "", `MCP endpoint: ${endpoint}`, "", "## Startup", "", "- Call `tasker_get_agent_instructions` before creating or changing Tasker cards.", "- Call `tasker_list_projects` and `tasker_get_project_context` before writing into a project.", "- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.", "", "## Write Safety", "", "- Every write tool call must include a unique `idempotency_key`.", "- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.", "- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.", "- Only assign existing project members returned by project context.", "- If a needed label is missing, call `tasker_ensure_labels` first and then use returned ids with `tasker_set_issue_labels`.", "", "## Card Writing", "", "- Keep card titles concise and operational.", "- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.", "- Put each structured block heading into the block `title` field. Do not put markdown headings like `## Status` inside block body.", "- Do not put headings inside block body. Wrong: body starts with `## Текущая архитектура`. Correct: `title: \"Текущая архитектура\"`, body contains only content.", "- Put short verifiable work items into checker blocks with explicit titles.", "- After code work, update the related card with factual files touched and validation performed.", "", "## Labels", "", "- Before assigning a new marker/label, check project context labels.", "- If the label does not exist, call `tasker_ensure_labels` with the granted `project_id`.", "- Use only label ids returned by project context or `tasker_ensure_labels` when calling `tasker_set_issue_labels`.", "", "## Effective Grants", "", grants || "- No grants available.", "", "## Available Tools", "", tools || "- No tools available for current grants.", "", ].join("\n"); }