NODEDC_TASKMANAGER_CODEXAPI/src/routes/agents.ts

1346 lines
44 KiB
TypeScript

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<void> {
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<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);
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
const endpoint = new URL("/mcp", publicUrl).toString();
const tokenPlaceholder = "<agent-token>";
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<string, unknown> {
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");
}