import { Router, type Request } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { createAgentKeySchema, createAgentHireSchema, createAgentSchema, deriveAgentUrlKey, isUuidLike, resetAgentSessionSchema, testAdapterEnvironmentSchema, type InstanceSchedulerHeartbeatAgent, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { agentService, accessService, approvalService, heartbeatService, issueApprovalService, issueService, logActivity, secretService, } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", gemini_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", }; const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const router = Router(); const svc = agentService(db); const access = accessService(db); const approvalsSvc = approvalService(db); const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; function canCreateAgents(agent: { role: string; permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record).canCreateAgents); } async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); if (!allowed) { throw forbidden("Missing permission: agents:create"); } return null; } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); if (!allowedByGrant && !canCreateAgents(actorAgent)) { throw forbidden("Missing permission: can create agents"); } return actorAgent; } async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; return access.canUser(companyId, req.actor.userId, "agents:create"); } if (!req.actor.agentId) return false; const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) return false; const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); return allowedByGrant || canCreateAgents(actorAgent); } async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); if (req.actor.type === "board") return; if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { throw forbidden("Agent key cannot access another company"); } if (actorAgent.id === targetAgent.id) return; if (actorAgent.role === "ceo") return; const allowedByGrant = await access.hasPermission( targetAgent.companyId, "agent", actorAgent.id, "agents:create", ); if (allowedByGrant || canCreateAgents(actorAgent)) return; throw forbidden("Only CEO or agent creators can modify other agents"); } async function resolveCompanyIdForAgentReference(req: Request): Promise { const companyIdQuery = req.query.companyId; const requestedCompanyId = typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0 ? companyIdQuery.trim() : null; if (requestedCompanyId) { assertCompanyAccess(req, requestedCompanyId); return requestedCompanyId; } if (req.actor.type === "agent" && req.actor.companyId) { return req.actor.companyId; } return null; } async function normalizeAgentReference(req: Request, rawId: string): Promise { const raw = rawId.trim(); if (isUuidLike(raw)) return raw; const companyId = await resolveCompanyIdForAgentReference(req); if (!companyId) { throw unprocessable("Agent shortname lookup requires companyId query parameter"); } const resolved = await svc.resolveByReference(companyId, raw); if (resolved.ambiguous) { throw conflict("Agent shortname is ambiguous in this company. Use the agent ID."); } if (!resolved.agent) { throw notFound("Agent not found"); } return resolved.agent.id; } function parseSourceIssueIds(input: { sourceIssueId?: string | null; sourceIssueIds?: string[]; }): string[] { const values: string[] = []; if (Array.isArray(input.sourceIssueIds)) values.push(...input.sourceIssueIds); if (typeof input.sourceIssueId === "string" && input.sourceIssueId.length > 0) { values.push(input.sourceIssueId); } return Array.from(new Set(values)); } function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function asNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function parseBooleanLike(value: unknown): boolean | null { if (typeof value === "boolean") return value; if (typeof value === "number") { if (value === 1) return true; if (value === 0) return false; return null; } if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { return true; } if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { return false; } return null; } function parseNumberLike(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string") return null; const parsed = Number(value.trim()); return Number.isFinite(parsed) ? parsed : null; } function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) { const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {}; return { enabled: parseBooleanLike(heartbeat.enabled) ?? true, intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), }; } function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); } function ensureGatewayDeviceKey( adapterType: string | null | undefined, adapterConfig: Record, ): Record { if (adapterType !== "openclaw_gateway") return adapterConfig; const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; if (disableDeviceAuth) return adapterConfig; if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; } function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, ): Record { const next = { ...adapterConfig }; if (adapterType === "codex_local") { if (!asNonEmptyString(next.model)) { next.model = DEFAULT_CODEX_LOCAL_MODEL; } const hasBypassFlag = typeof next.dangerouslyBypassApprovalsAndSandbox === "boolean" || typeof next.dangerouslyBypassSandbox === "boolean"; if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } return ensureGatewayDeviceKey(adapterType, next); } if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) { next.model = DEFAULT_GEMINI_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } return ensureGatewayDeviceKey(adapterType, next); } async function assertAdapterConfigConstraints( companyId: string, adapterType: string | null | undefined, adapterConfig: Record, ) { if (adapterType !== "opencode_local") return; const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; try { await ensureOpenCodeModelConfiguredAndAvailable({ model: runtimeConfig.model, command: runtimeConfig.command, cwd: runtimeConfig.cwd, env: runtimeEnv, }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); } } function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record) { const trimmed = candidatePath.trim(); if (path.isAbsolute(trimmed)) return trimmed; const cwd = asNonEmptyString(adapterConfig.cwd); if (!cwd) { throw unprocessable( "Relative instructions path requires adapterConfig.cwd to be set to an absolute path", ); } if (!path.isAbsolute(cwd)) { throw unprocessable("adapterConfig.cwd must be an absolute path to resolve relative instructions path"); } return path.resolve(cwd, trimmed); } async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); if (req.actor.type === "board") return; if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { throw forbidden("Agent key cannot access another company"); } if (actorAgent.id === targetAgent.id) return; const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; throw forbidden("Only the target agent or an ancestor manager can update instructions path"); } function summarizeAgentUpdateDetails(patch: Record) { const changedTopLevelKeys = Object.keys(patch).sort(); const details: Record = { changedTopLevelKeys }; const adapterConfigPatch = asRecord(patch.adapterConfig); if (adapterConfigPatch) { details.changedAdapterConfigKeys = Object.keys(adapterConfigPatch).sort(); } const runtimeConfigPatch = asRecord(patch.runtimeConfig); if (runtimeConfigPatch) { details.changedRuntimeConfigKeys = Object.keys(runtimeConfigPatch).sort(); } return details; } function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { ...agent, adapterConfig: {}, runtimeConfig: {}, }; } function redactAgentConfiguration(agent: Awaited>) { if (!agent) return null; return { id: agent.id, companyId: agent.companyId, name: agent.name, role: agent.role, title: agent.title, status: agent.status, reportsTo: agent.reportsTo, adapterType: agent.adapterType, adapterConfig: redactEventPayload(agent.adapterConfig), runtimeConfig: redactEventPayload(agent.runtimeConfig), permissions: agent.permissions, updatedAt: agent.updatedAt, }; } function redactRevisionSnapshot(snapshot: unknown): Record { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {}; const record = snapshot as Record; return { ...record, adapterConfig: redactEventPayload( typeof record.adapterConfig === "object" && record.adapterConfig !== null ? (record.adapterConfig as Record) : {}, ), runtimeConfig: redactEventPayload( typeof record.runtimeConfig === "object" && record.runtimeConfig !== null ? (record.runtimeConfig as Record) : {}, ), metadata: typeof record.metadata === "object" && record.metadata !== null ? redactEventPayload(record.metadata as Record) : record.metadata ?? null, }; } function redactConfigRevision( revision: Record & { beforeConfig: unknown; afterConfig: unknown }, ) { return { ...revision, beforeConfig: redactRevisionSnapshot(revision.beforeConfig), afterConfig: redactRevisionSnapshot(revision.afterConfig), }; } function toLeanOrgNode(node: Record): Record { const reports = Array.isArray(node.reports) ? (node.reports as Array>).map((report) => toLeanOrgNode(report)) : []; return { id: String(node.id), name: String(node.name), role: String(node.role), status: String(node.status), reports, }; } router.param("id", async (req, _res, next, rawId) => { try { req.params.id = await normalizeAgentReference(req, String(rawId)); next(); } catch (err) { next(err); } }); router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const type = req.params.type as string; const models = await listAdapterModels(type); res.json(models); }); router.post( "/companies/:companyId/adapters/:type/test-environment", validate(testAdapterEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; await assertCanReadConfigurations(req, companyId); const adapter = findServerAdapter(type); if (!adapter) { res.status(404).json({ error: `Unknown adapter type: ${type}` }); return; } const inputAdapterConfig = (req.body?.adapterConfig ?? {}) as Record; const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, inputAdapterConfig, { strictMode: strictSecretsMode }, ); const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, ); const result = await adapter.testEnvironment({ companyId, adapterType: type, config: runtimeAdapterConfig, }); res.json(result); }, ); router.get("/companies/:companyId/agents", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const result = await svc.list(companyId); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); if (canReadConfigs || req.actor.type === "board") { res.json(result); return; } res.json(result.map((agent) => redactForRestrictedAgentView(agent))); }); router.get("/instance/scheduler-heartbeats", async (req, res) => { assertBoard(req); const accessConditions = []; if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { const allowedCompanyIds = req.actor.companyIds ?? []; if (allowedCompanyIds.length === 0) { res.json([]); return; } accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds)); } const rows = await db .select({ id: agentsTable.id, companyId: agentsTable.companyId, agentName: agentsTable.name, role: agentsTable.role, title: agentsTable.title, status: agentsTable.status, adapterType: agentsTable.adapterType, runtimeConfig: agentsTable.runtimeConfig, lastHeartbeatAt: agentsTable.lastHeartbeatAt, companyName: companies.name, companyIssuePrefix: companies.issuePrefix, }) .from(agentsTable) .innerJoin(companies, eq(agentsTable.companyId, companies.id)) .where(accessConditions.length > 0 ? and(...accessConditions) : undefined) .orderBy(companies.name, agentsTable.name); const items: InstanceSchedulerHeartbeatAgent[] = rows .map((row) => { const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig); const statusEligible = row.status !== "paused" && row.status !== "terminated" && row.status !== "pending_approval"; return { id: row.id, companyId: row.companyId, companyName: row.companyName, companyIssuePrefix: row.companyIssuePrefix, agentName: row.agentName, agentUrlKey: deriveAgentUrlKey(row.agentName, row.id), role: row.role as InstanceSchedulerHeartbeatAgent["role"], title: row.title, status: row.status as InstanceSchedulerHeartbeatAgent["status"], adapterType: row.adapterType, intervalSec: policy.intervalSec, heartbeatEnabled: policy.enabled, schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0, lastHeartbeatAt: row.lastHeartbeatAt, }; }) .filter((item) => item.intervalSec > 0 && item.status !== "paused" && item.status !== "terminated" && item.status !== "pending_approval", ) .sort((left, right) => { if (left.schedulerActive !== right.schedulerActive) { return left.schedulerActive ? -1 : 1; } const companyOrder = left.companyName.localeCompare(right.companyName); if (companyOrder !== 0) return companyOrder; return left.agentName.localeCompare(right.agentName); }); res.json(items); }); router.get("/companies/:companyId/org", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const tree = await svc.orgForCompany(companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); res.json(leanTree); }); router.get("/companies/:companyId/agent-configurations", async (req, res) => { const companyId = req.params.companyId as string; await assertCanReadConfigurations(req, companyId); const rows = await svc.list(companyId); res.json(rows.map((row) => redactAgentConfiguration(row))); }); router.get("/agents/me", async (req, res) => { if (req.actor.type !== "agent" || !req.actor.agentId) { res.status(401).json({ error: "Agent authentication required" }); return; } const agent = await svc.getById(req.actor.agentId); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json({ ...agent, chainOfCommand }); }); router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); if (req.actor.type === "agent" && req.actor.agentId !== id) { const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); if (!canRead) { const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand }); return; } } const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json({ ...agent, chainOfCommand }); }); router.get("/agents/:id/configuration", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanReadConfigurations(req, agent.companyId); res.json(redactAgentConfiguration(agent)); }); router.get("/agents/:id/config-revisions", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanReadConfigurations(req, agent.companyId); const revisions = await svc.listConfigRevisions(id); res.json(revisions.map((revision) => redactConfigRevision(revision))); }); router.get("/agents/:id/config-revisions/:revisionId", async (req, res) => { const id = req.params.id as string; const revisionId = req.params.revisionId as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanReadConfigurations(req, agent.companyId); const revision = await svc.getConfigRevision(id, revisionId); if (!revision) { res.status(404).json({ error: "Revision not found" }); return; } res.json(redactConfigRevision(revision)); }); router.post("/agents/:id/config-revisions/:revisionId/rollback", async (req, res) => { const id = req.params.id as string; const revisionId = req.params.revisionId as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanUpdateAgent(req, existing); const actor = getActorInfo(req); const updated = await svc.rollbackConfigRevision(id, revisionId, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); if (!updated) { res.status(404).json({ error: "Revision not found" }); return; } await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.config_rolled_back", entityType: "agent", entityId: updated.id, details: { revisionId }, }); res.json(updated); }); router.get("/agents/:id/runtime-state", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); const state = await heartbeat.getRuntimeState(id); res.json(state); }); router.get("/agents/:id/task-sessions", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); const sessions = await heartbeat.listTaskSessions(id); res.json( sessions.map((session) => ({ ...session, sessionParamsJson: redactEventPayload(session.sessionParamsJson ?? null), })), ); }); router.post("/agents/:id/runtime-state/reset-session", validate(resetAgentSessionSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); const taskKey = typeof req.body.taskKey === "string" && req.body.taskKey.trim().length > 0 ? req.body.taskKey.trim() : null; const state = await heartbeat.resetRuntimeSession(id, { taskKey }); await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.runtime_session_reset", entityType: "agent", entityId: id, details: { taskKey: taskKey ?? null }, }); res.json(state); }); router.post("/companies/:companyId/agent-hires", validate(createAgentHireSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId); const sourceIssueIds = parseSourceIssueIds(req.body); const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, requestedAdapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( companyId, hireInput.adapterType, normalizedAdapterConfig, ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, }; const company = await db .select() .from(companies) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); if (!company) { res.status(404).json({ error: "Company not found" }); return; } const requiresApproval = company.requireBoardApprovalForNewAgents; const status = requiresApproval ? "pending_approval" : "idle"; const agent = await svc.create(companyId, { ...normalizedHireInput, status, spentMonthlyCents: 0, lastHeartbeatAt: null, }); let approval: Awaited> | null = null; const actor = getActorInfo(req); if (requiresApproval) { const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; const requestedAdapterConfig = redactEventPayload( (normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record, ) ?? {}; const requestedRuntimeConfig = redactEventPayload( (normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record, ) ?? {}; const requestedMetadata = redactEventPayload( ((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record), ) ?? {}; approval = await approvalsSvc.create(companyId, { type: "hire_agent", requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedByUserId: actor.actorType === "user" ? actor.actorId : null, status: "pending", payload: { name: normalizedHireInput.name, role: normalizedHireInput.role, title: normalizedHireInput.title ?? null, icon: normalizedHireInput.icon ?? null, reportsTo: normalizedHireInput.reportsTo ?? null, capabilities: normalizedHireInput.capabilities ?? null, adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, budgetMonthlyCents: typeof normalizedHireInput.budgetMonthlyCents === "number" ? normalizedHireInput.budgetMonthlyCents : agent.budgetMonthlyCents, metadata: requestedMetadata, agentId: agent.id, requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedConfigurationSnapshot: { adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, }, }, decisionNote: null, decidedByUserId: null, decidedAt: null, updatedAt: new Date(), }); if (sourceIssueIds.length > 0) { await issueApprovalsSvc.linkManyForApproval(approval.id, sourceIssueIds, { agentId: actor.actorType === "agent" ? actor.actorId : null, userId: actor.actorType === "user" ? actor.actorId : null, }); } } await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.hire_created", entityType: "agent", entityId: agent.id, details: { name: agent.name, role: agent.role, requiresApproval, approvalId: approval?.id ?? null, issueIds: sourceIssueIds, }, }); if (approval) { await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "approval.created", entityType: "approval", entityId: approval.id, details: { type: approval.type, linkedAgentId: agent.id }, }); } res.status(201).json({ agent, approval }); }); router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { assertBoard(req); } const requestedAdapterConfig = applyCreateDefaultsByAdapterType( req.body.adapterType, ((req.body.adapterConfig ?? {}) as Record), ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, requestedAdapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( companyId, req.body.adapterType, normalizedAdapterConfig, ); const agent = await svc.create(companyId, { ...req.body, adapterConfig: normalizedAdapterConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, }); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.created", entityType: "agent", entityId: agent.id, details: { name: agent.name, role: agent.role }, }); res.status(201).json(agent); }); router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, existing.companyId); if (req.actor.type === "agent") { const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; if (!actorAgent || actorAgent.companyId !== existing.companyId) { res.status(403).json({ error: "Forbidden" }); return; } if (actorAgent.role !== "ceo") { res.status(403).json({ error: "Only CEO can manage permissions" }); return; } } const agent = await svc.updatePermissions(id, req.body); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.permissions_updated", entityType: "agent", entityId: agent.id, details: req.body, }); res.json(agent); }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanManageInstructionsPath(req, existing); const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ error: `No default instructions path key for adapter type '${existing.adapterType}'. Provide adapterConfigKey.`, }); return; } const nextAdapterConfig: Record = { ...existingAdapterConfig }; if (req.body.path === null) { delete nextAdapterConfig[adapterConfigKey]; } else { nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig); } const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, nextAdapterConfig, { strictMode: strictSecretsMode }, ); const actor = getActorInfo(req); const agent = await svc.update( id, { adapterConfig: normalizedAdapterConfig }, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "instructions_path_patch", }, }, ); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const updatedAdapterConfig = asRecord(agent.adapterConfig) ?? {}; const pathValue = asNonEmptyString(updatedAdapterConfig[adapterConfigKey]); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.instructions_path_updated", entityType: "agent", entityId: agent.id, details: { adapterConfigKey, path: pathValue, cleared: req.body.path === null, }, }); res.json({ agentId: agent.id, adapterType: agent.adapterType, adapterConfigKey, path: pathValue, }); }); router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Agent not found" }); return; } await assertCanUpdateAgent(req, existing); if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) { res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); return; } const patchData = { ...(req.body as Record) }; if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } const changingInstructionsPath = Object.keys(adapterConfig).some((key) => KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), ); if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; } const requestedAdapterType = typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType; const touchesAdapterConfiguration = Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); if (touchesAdapterConfiguration) { const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : (asRecord(existing.adapterConfig) ?? {}); const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( requestedAdapterType, rawEffectiveAdapterConfig, ); const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); patchData.adapterConfig = normalizedEffectiveAdapterConfig; } if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; await assertAdapterConfigConstraints( existing.companyId, requestedAdapterType, effectiveAdapterConfig, ); } const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "patch", }, }); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "agent.updated", entityType: "agent", entityId: agent.id, details: summarizeAgentUpdateDetails(patchData), }); res.json(agent); }); router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.pause(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await heartbeat.cancelActiveForAgent(id); await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.paused", entityType: "agent", entityId: agent.id, }); res.json(agent); }); router.post("/agents/:id/resume", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.resume(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.resumed", entityType: "agent", entityId: agent.id, }); res.json(agent); }); router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.terminate(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await heartbeat.cancelActiveForAgent(id); await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.terminated", entityType: "agent", entityId: agent.id, }); res.json(agent); }); router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.remove(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.deleted", entityType: "agent", entityId: agent.id, }); res.json({ ok: true }); }); router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; const keys = await svc.listKeys(id); res.json(keys); }); router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; const key = await svc.createApiKey(id, req.body.name); const agent = await svc.getById(id); if (agent) { await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.key_created", entityType: "agent", entityId: agent.id, details: { keyId: key.id, name: key.name }, }); } res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); const keyId = req.params.keyId as string; const revoked = await svc.revokeKey(keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } res.json({ ok: true }); }); router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); if (req.actor.type === "agent" && req.actor.agentId !== id) { res.status(403).json({ error: "Agent can only invoke itself" }); return; } const run = await heartbeat.wakeup(id, { source: req.body.source, triggerDetail: req.body.triggerDetail ?? "manual", reason: req.body.reason ?? null, payload: req.body.payload ?? null, idempotencyKey: req.body.idempotencyKey ?? null, requestedByActorType: req.actor.type === "agent" ? "agent" : "user", requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, contextSnapshot: { triggeredBy: req.actor.type, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, }, }); if (!run) { res.status(202).json({ status: "skipped" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "heartbeat.invoked", entityType: "heartbeat_run", entityId: run.id, details: { agentId: id }, }); res.status(202).json(run); }); router.post("/agents/:id/heartbeat/invoke", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); if (req.actor.type === "agent" && req.actor.agentId !== id) { res.status(403).json({ error: "Agent can only invoke itself" }); return; } const run = await heartbeat.invoke( id, "on_demand", { triggeredBy: req.actor.type, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, }, "manual", { actorType: req.actor.type === "agent" ? "agent" : "user", actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, }, ); if (!run) { res.status(202).json({ status: "skipped" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "heartbeat.invoked", entityType: "heartbeat_run", entityId: run.id, details: { agentId: id }, }); res.status(202).json(run); }); router.post("/agents/:id/claude-login", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } assertCompanyAccess(req, agent.companyId); if (agent.adapterType !== "claude_local") { res.status(400).json({ error: "Login is only supported for claude_local agents" }); return; } const config = asRecord(agent.adapterConfig) ?? {}; const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); const result = await runClaudeLogin({ runId: `claude-login-${randomUUID()}`, agent: { id: agent.id, companyId: agent.companyId, name: agent.name, adapterType: agent.adapterType, adapterConfig: agent.adapterConfig, }, config: runtimeConfig, }); res.json(result); }); router.get("/companies/:companyId/heartbeat-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const agentId = req.query.agentId as string | undefined; const limitParam = req.query.limit as string | undefined; const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined; const runs = await heartbeat.list(companyId, agentId, limit); res.json(runs); }); router.get("/companies/:companyId/live-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const minCountParam = req.query.minCount as string | undefined; const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0; const columns = { id: heartbeatRuns.id, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), }; const liveRuns = await db .select(columns) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .where( and( eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ["queued", "running"]), ), ) .orderBy(desc(heartbeatRuns.createdAt)); if (minCount > 0 && liveRuns.length < minCount) { const activeIds = liveRuns.map((r) => r.id); const recentRuns = await db .select(columns) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .where( and( eq(heartbeatRuns.companyId, companyId), not(inArray(heartbeatRuns.status, ["queued", "running"])), ...(activeIds.length > 0 ? [not(inArray(heartbeatRuns.id, activeIds))] : []), ), ) .orderBy(desc(heartbeatRuns.createdAt)) .limit(minCount - liveRuns.length); res.json([...liveRuns, ...recentRuns]); return; } res.json(liveRuns); }); router.get("/heartbeat-runs/:runId", async (req, res) => { const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; } assertCompanyAccess(req, run.companyId); res.json(redactCurrentUserValue(run)); }); router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; const run = await heartbeat.cancelRun(runId); if (run) { await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "heartbeat.cancelled", entityType: "heartbeat_run", entityId: run.id, details: { agentId: run.agentId }, }); } res.json(run); }); router.get("/heartbeat-runs/:runId/events", async (req, res) => { const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; } assertCompanyAccess(req, run.companyId); const afterSeq = Number(req.query.afterSeq ?? 0); const limit = Number(req.query.limit ?? 200); const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200); const redactedEvents = events.map((event) => redactCurrentUserValue({ ...event, payload: redactEventPayload(event.payload), }), ); res.json(redactedEvents); }); router.get("/heartbeat-runs/:runId/log", async (req, res) => { const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; } assertCompanyAccess(req, run.companyId); const offset = Number(req.query.offset ?? 0); const limitBytes = Number(req.query.limitBytes ?? 256000); const result = await heartbeat.readLog(runId, { offset: Number.isFinite(offset) ? offset : 0, limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); res.json(result); }); router.get("/issues/:issueId/live-runs", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const liveRuns = await db .select({ id: heartbeatRuns.id, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, }) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .where( and( eq(heartbeatRuns.companyId, issue.companyId), inArray(heartbeatRuns.status, ["queued", "running"]), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, ), ) .orderBy(desc(heartbeatRuns.createdAt)); res.json(liveRuns); }); router.get("/issues/:issueId/active-run", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; if (run && run.status !== "queued" && run.status !== "running") { run = null; } if (!run && issue.assigneeAgentId && issue.status === "in_progress") { const candidateRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); const candidateContext = asRecord(candidateRun?.contextSnapshot); const candidateIssueId = asNonEmptyString(candidateContext?.issueId); if (candidateRun && candidateIssueId === issue.id) { run = candidateRun; } } if (!run) { res.json(null); return; } const agent = await svc.getById(run.agentId); if (!agent) { res.json(null); return; } res.json({ ...redactCurrentUserValue(run), agentId: agent.id, agentName: agent.name, adapterType: agent.adapterType, }); }); return router; }