1346 lines
44 KiB
TypeScript
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");
|
|
}
|