import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js"; import { pipeline } from "node:stream/promises"; import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared"; import { listOpenRouterModels } from "../services/openrouter-models.js"; import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js"; import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding, aiRuntimeConnectionBindingSchema, type AiRuntimeConnectionBinding } from "@paperclipai/shared"; import { toolConnections } from "@paperclipai/db"; import { aiConnectionService } from "../services/ai-connections.js"; import { defaultAiConnectionForHire } from "../services/agent-ai-connection-default.js"; import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest, validateAiApiKey } from "./ai-connections.js"; import { isAiConnectionCompatible } from "@paperclipai/shared"; import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js"; import { getExecutionBlocker } from "../services/execution-blocker.js"; import { canRetryStoppedRun } from "../services/cancelled-native-startup.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type NativeReviewAssignmentContext } from "../services/native-runtime/native-review-participant.js"; import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils"; import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js"; import { selectDashboardRunIds } from "../services/dashboard-run-selection.js"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import type { ChatChannelService } from "../services/chat-channels.js"; import { activityLog, agents as agentsTable, chatConversations, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { sha256Digest } from "../services/feedback-redaction.js"; import { agentSkillSyncSchema, agentMineInboxQuerySchema, ADAPTER_AGNOSTIC_KEYS, AGENT_DEFAULT_MAX_CONCURRENT_RUNS, createAgentKeySchema, createAgentHireSchema, createAgentSchema, deriveAgentUrlKey, isUuidLike, normalizeIssueIdentifier, resetAgentSessionSchema, testAdapterEnvironmentSchema, type AgentDesiredSkillEntry, type AgentSkillAssignmentMode, type AgentSkillSnapshot, type InstanceSchedulerHeartbeatAgent, upsertAgentInstructionsFileSchema, restoreAgentInstructionSchema, resolveAgentInstructionCandidateSchema, updateAgentInstructionsBundleSchema, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, supportedEnvironmentDriversForAdapter, LOW_TRUST_REVIEW_PRESET, startAdapterAuthSessionRequestSchema, startClaudeSetupTokenSessionRequestSchema, submitBrowserCodeRequestSchema, toAccountHandle, type AgentAdapterType, } from "@paperclipai/shared"; import { isForbiddenConfigEnvKey, normalizePaperclipRunnerAdapterConfig, PAPERCLIP_OPERATIONAL_SKILL_KEY, parseObject, resolvePaperclipInstanceRootForAdapter, readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; import { trackAgentCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js"; import { authorizeInstructionRead } from "../services/agent-instruction-authorization.js"; import { instructionPath } from "../services/agent-instruction-files.js"; import { agentInstructionsBundleMode, deriveBundleState } from "../services/agent-instructions.js"; import { agentService, agentInstructionsService, accessService, approvalService, builtInAgentService, companySkillService, budgetService, heartbeatService, ISSUE_LIST_DEFAULT_LIMIT, issueApprovalService, issueRecoveryActionService, issueService, logActivity, syncInstructionsBundleConfigFromFilePath, workspaceOperationService, } from "../services/index.js"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { ONBOARDING_FIRST_TASK_SKILL_KEY, PAPERCLIP_CORE_SKILL_KEYS } from "../services/company-skills.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js"; import { isLoginCommandSupportedAdapterType } from "../services/login-command.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectAgentAdapterWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server"; import type { AdapterAuthSignal, AdapterAuthSignalResponse, CodexAccountBindingClaim } from "@paperclipai/shared"; import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js"; import { isFixedClaudeOAuthBinding, secretService } from "../services/secrets.js"; import { authorizationDeniedDetails } from "../services/authorization.js"; import { providerTraceStore } from "../services/provider-trace-store.js"; import { persistReprojectedWorkspaceDiffs, projectCodexWorkspaceDiffsFromTrace, type WorkspaceDiffReprojectionSkipReason, } from "../services/provider-trace-workspace-diff-reprojection.js"; import { detectAdapterModel, findActiveServerAdapter, findServerAdapter, listServerAdapters, listAdapterModels, refreshAdapterModels, requireServerAdapter, } from "../adapters/index.js"; import { REDACTED_EVENT_VALUE, redactAgentAdapterConfig, redactEventPayload, } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { HarnessRuntimeRequestResolutionError, parseHarnessRuntimeRequestResolution, type HarnessRuntimeRequestKind, type HarnessRuntimeRequestResolution, } from "../vendor/paperclip-runner/index.js"; import { queueRunnerPrpRuntimeRequestResolution, RunnerPrpRuntimeRequestResolutionError, } from "../realtime/runner-prp-ws.js"; import { assertNativeRuntimeRequestResolverAuthorized, NativeRuntimeRequestResolutionAuthorizationError, readPendingNativeRuntimeRequest, type NativeRuntimeRequestResolver, } from "../services/native-runtime/runtime-request-resolution-authority.js"; import { NativeRuntimeRequestResolutionError, resolveNativeRuntimeRequest, } from "../services/native-runtime/native-session-executor.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { instanceSettingsService, isTruthyRuntimeEnvValue, resolveWorktreeRunExecutionActivationState, } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { createInviteRateLimiter } from "../services/invite-rate-limit.js"; import { SetupTokenSessionService, SetupTokenSessionError, assessConfidentialStartup, evaluateConfidentialTransport, isTerminalSessionState, SETUP_TOKEN_START_FAILED, SETUP_TOKEN_SESSION_NOT_FOUND, SETUP_TOKEN_PROVIDER_UNSUPPORTED, SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE, type ConfidentialTransportConfig, type SetupTokenCleanupRecord, type SetupTokenCleanupStore, type SetupTokenLease, type SetupTokenLeaseManager, type SetupTokenLoginProcessFactory, type SetupTokenSecretWriter, type SetupTokenSessionScope, type SetupTokenSessionState, type SetupTokenSessionDescriptor, SETUP_TOKEN_ADAPTER_TYPE, } from "../services/setup-token-session.js"; import type { DeploymentMode, AdapterAuthSessionStatus, AdapterAuthSessionFailure, ClaudeSetupTokenSessionResponse, ClaudeSetupTokenSessionOwnerResponse, ClaudeSetupTokenSessionPrompt, ClaudeSetupTokenCompletionResponse, ClaudeOAuthTokenStatusResponse, ClaudeSetupTokenOverwrite, SetupTokenTransportAdvisory, } from "@paperclipai/shared"; import { SETUP_TOKEN_TRANSPORT_ADVISORY_CODE } from "@paperclipai/shared"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { checkStagedCredentialReadiness, promoteDeviceLoginCredential, readSubscriptionAccountId, resolveManagedCodexHomeDir, withAccountHomeSecretMutationLock, withCodexAccountHomePromotionLock, } from "@paperclipai/adapter-codex-local/server"; import { checkStagedGrokCredentialReadiness, promoteGrokDeviceLoginCredential, } from "@paperclipai/adapter-grok-local/server"; import { AdapterAuthSessionConflictError, createDeviceLoginService, createWorkerBoundLoginPtyOpener, createDbAdapterAuthSessionStore, createProductionLoginSessionRuntime, DEVICE_LOGIN_PROVIDER_UNSUPPORTED, DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, type CredentialPromotion, } from "../services/device-login-service.js"; import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server"; import { loadDefaultAgentInstructionsBundle, resolveDefaultAgentInstructionsBundleRole, } from "../services/default-agent-instructions.js"; import { buildOnboardingFirstAgentInstructionsBundle } from "../services/onboarding-first-task-assets.js"; import { getTelemetryClient } from "../telemetry.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { recoveryService } from "../services/recovery/service.js"; import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; import { readObject } from "../lib/objects.js"; import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js"; import { logger } from "../middleware/logger.js"; import { AGENT_PROFILE_CHANGE_CONSENT_FIELDS, agentInstructionsChangeTargetKey, agentProfileChangeTargetKey, changeConsentGateService, touchesAgentProfileChangeConsentFields, } from "../services/change-consent-gate.js"; import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerProviderProfile, } from "../services/native-runtime/provider-profile.js"; import { managedAgentProfileService } from "../services/managed-agent-profiles.js"; import { remoteAgentProfileService } from "../services/remote-agent-profiles.js"; const AGENT_SKILL_ASSIGNMENT_MODES = ["add", "remove", "replace"] as const; function requireAgentSkillAssignmentMode(req: Request, _res: Response, next: NextFunction) { if (!AGENT_SKILL_ASSIGNMENT_MODES.includes(req.body?.mode)) { throw unprocessable( 'Skill sync requires mode: "add", "remove", or "replace". ' + 'Use "replace" only to overwrite the complete desired skill set.', ); } next(); } function mergeDesiredSkillEntries( current: AgentDesiredSkillEntry[], requested: AgentDesiredSkillEntry[], mode: AgentSkillAssignmentMode, ) { if (mode === "replace") return requested; const requestedKeys = new Set(requested.map((entry) => entry.key)); if (mode === "remove") { return current.filter((entry) => !requestedKeys.has(entry.key)); } const merged = new Map(current.map((entry) => [entry.key, entry])); for (const entry of requested) merged.set(entry.key, entry); return Array.from(merged.values()); } const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; function readRunLogLimitBytes(value: unknown) { const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed))); } function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; if (parsed <= 0) return fallback; return Math.min(max, Math.trunc(parsed)); } function readRunIssueId(context: Record | null) { const directIssueId = context?.issueId; if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId; const paperclipIssue = readObject(context?.paperclipIssue); const nestedIssueId = paperclipIssue?.id; return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null; } // Confirms a pre-existing `CODEX_HOME_` secret still names this // account's own home before a device login treats the secret's presence as a // successful, idempotent login. The secret name alone is not proof of a // match: a stale value from before the cache root moved, or a value a user // entered by hand, would otherwise let the login report success while a // bound agent reads the wrong (or a missing) credential home. Fails loud on // a mismatch, so the login fails instead of silently pointing agents at the // wrong home. // // Each caller must run this function inside `withAccountHomeSecretMutationLock`, // the same lock a `local_encrypted` secret rotate holds for its whole write. // A caller that only checks once, early in the promotion, and then reports // success later is not enough on its own: the lock this call held is fully // released by the time it returns, so a rotate queued behind it can commit a // new value before the login service records its terminal `authenticated` // state, which happens well after this call returns (see `runTerminalCommit` // below, which runs this same check again, under a fresh lock acquisition, // immediately before that terminal state commits). async function assertAccountHomeSecretMatches( secretsSvc: { resolveSecretValueForDeviceLoginCheck: (companyId: string, secretId: string, context: { configPath: string }) => Promise }, companyId: string, secret: { id: string }, secretName: string, expectedAccountHomeDir: string, ): Promise { const storedValue = await secretsSvc.resolveSecretValueForDeviceLoginCheck(companyId, secret.id, { configPath: `secrets.${secretName}`, }); if (storedValue !== expectedAccountHomeDir) { throw new Error( `device-login credential promotion rejected: the existing ${secretName} secret does not name this account's own home`, ); } } // Confirms no company secret, under any name or provider, still names this // account home before a failed promotion deletes the directory. The // generated `CODEX_HOME_` name is not the only secret that can // reference this directory: a user can bind a hand-named secret to the same // account home, so a check that reads only the generated name misses that // secret and deletes a directory it still needs. A bound agent then reads a // `CODEX_HOME` value that points at nothing. A secret's value is a plain // string regardless of its provider, so an AWS Secrets Manager-backed secret // (or any other provider) can equal this directory's path just as a // `local_encrypted` secret can; the scan resolves every secret's value, not // only `local_encrypted` ones. // // A secret whose value fails to resolve is NOT proof that secret names a // different directory: the resolve call can fail for a secret that would // have matched. Treating that failure as a non-match would let the cleanup // delete a directory a secret still needs. So the scan fails closed: any // resolution failure makes the whole scan report a claim, even when every // secret that DID resolve named a different directory. // // The scan lists every secret once, then resolves each secret's value in // turn, and each resolve call is its own round trip. A new secret can enter // the company between the initial list and the last resolve call, so a // single pass can finish, find no claimant among the secrets it read, and // still miss a secret that named this directory moments later. So the scan // re-lists after every pass and resolves only the secrets it has not yet // checked, and it only reports "no claimant" once a pass finds nothing new // to check. A scan that keeps finding new secrets on every pass fails // closed after a bounded number of passes, so a fast stream of concurrent // secret creation cannot force an unsafe delete. // // The scan alone still cannot rule out a secret write that commits after the // scan's own last pass finishes but before the caller's delete runs: the // scan and that write are two separate operations with no shared state, so // neither can see the other. The caller closes that window by running the // scan and the delete inside `withAccountHomeSecretMutationLock`, the same // lock every `local_encrypted` secret create or rotate holds for its whole // write. That lock is the atomic protection; the multi-pass scan above stays // as a defense-in-depth check for a write path that has not taken the lock. const ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES = 5; async function anySecretNamesAccountHome( secretsSvc: { list: (companyId: string) => Promise>; resolveSecretValueForDeviceLoginCheck: ( companyId: string, secretId: string, context: { configPath: string }, ) => Promise; }, companyId: string, accountHomeDir: string, ): Promise { const checkedSecretIds = new Set(); for (let pass = 0; pass < ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES; pass += 1) { const secrets = await secretsSvc.list(companyId); const uncheckedSecrets = secrets.filter((secret) => !checkedSecretIds.has(secret.id)); if (uncheckedSecrets.length === 0) return false; let resolutionFailed = false; for (const secret of uncheckedSecrets) { checkedSecretIds.add(secret.id); const storedValue = await secretsSvc .resolveSecretValueForDeviceLoginCheck(companyId, secret.id, { configPath: `secrets.${secret.name}`, }) .catch(() => { resolutionFailed = true; return null; }); if (storedValue === accountHomeDir) return true; } if (resolutionFailed) return true; } // Every pass found a secret it had not yet checked. Fail closed: an // endless stream of new secrets is not proof that none of them claims // this directory. return true; } // Serializes hire requests that share a company and run, so a retried POST // cannot race its original past the idempotency lookup: the lookup, the create // and the activity record all happen inside the held section. In-process is // the right scope because a Paperclip instance serves its API from one // process, and the lock is keyed narrowly enough that unrelated hires never // wait on each other. const hireRunLocks = new Map>(); async function withHireRunLock(key: string, fn: () => Promise): Promise { const previous = hireRunLocks.get(key) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); const chained = previous.then(() => current); hireRunLocks.set(key, chained); await previous; try { return await fn(); } finally { release(); if (hireRunLocks.get(key) === chained) hireRunLocks.delete(key); } } export function agentRoutes( db: Db, options: { chatRunRetries?: Pick; pluginWorkerManager?: PluginWorkerManager; /** The active deployment mode. The confidential transport guard reads it. */ deploymentMode?: DeploymentMode; /** * The dedicated proxy IP or CIDR allowlist for the confidential setup-token * responses (SR-7). The global `TRUST_PROXY` setting does not satisfy the * guard; only a peer on this explicit allowlist may forward a TLS protocol. */ confidentialProxyAllowlist?: string[]; /** * The explicit operator declaration that a platform edge terminates TLS for * every client request (SR-7). Set from `CLAUDE_LOGIN_EDGE_TLS_TERMINATED`. * Use it on a managed PaaS where the app socket is always plain HTTP and * the edge-proxy peer addresses cannot be allowlisted. */ confidentialEdgeTlsTerminated?: boolean; /** * Receives the setup-token login session service once the router builds it. * The caller registers the startup reaper and the graceful-shutdown cleanup. */ onSetupTokenLoginService?: (service: SetupTokenSessionService) => void; /** * Binds the live setup-token login transport. When the caller provides it, * the session route is the live login path: the start route acquires a real * sandbox lease through `leases` and drives one live login process through * `factory`. When the caller omits it, the start route fails closed with the * fixed no-secret error, because the sandbox pseudo-terminal transport is not * bound yet. A test injects a fake factory and a fake lease manager to drive * the full route path. */ setupTokenLogin?: { factory: SetupTokenLoginProcessFactory; leases: SetupTokenLeaseManager; /** The durable cleanup store. Defaults to the in-memory record store. */ store?: SetupTokenCleanupStore; /** * The owner-bound secret writer. When the caller omits it, the completion * fails closed, because the secret sink is not bound yet. */ completeCredential?: SetupTokenSecretWriter; }; } = {}, ) { // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", kimi_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); /** Check if an adapter supports the managed instructions bundle. */ function adapterSupportsInstructionsBundle(adapterType: string): boolean { const adapter = findActiveServerAdapter(adapterType); if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); } /** Resolve the adapter config key for the instructions file path. */ function resolveInstructionsPathKey(adapterType: string): string | null { const adapter = findActiveServerAdapter(adapterType); if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath"; if (adapter?.supportsInstructionsBundle === false) return null; return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath", "agentsMdPath", ] as const; const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS); const router = Router(); const svc = agentService(db); const access = accessService(db); const approvalsSvc = approvalService(db); const budgets = budgetService(db); const environmentsSvc = environmentService(db); const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); // --- Setup-token login session (Claude in-product login) ------------------- // // The service owns a company-scoped, owner-bound login session, the // confidential transport guard (SR-6, SR-7), the session caps, and the start // rate limit. The `options.setupTokenLogin` transport binds the live sandbox // pseudo-terminal login process and the real sandbox-lease acquisition. When a // caller provides the transport, the session route is the live login path and // `SETUP_TOKEN_LOGIN_TRANSPORT_READY` is true. When a caller omits it, the // start route returns the fixed no-secret error and the login never spawns a // process or holds a lease. The full session state machine, the cleanup order, // and the reaper are covered by setup-token-session.test.ts. const SETUP_TOKEN_LOGIN_TRANSPORT_READY = options.setupTokenLogin != null; const setupTokenConfidentialConfig: ConfidentialTransportConfig = { deploymentMode: options.deploymentMode ?? "local_trusted", trustedProxies: options.confidentialProxyAllowlist ?? [], edgeTlsTerminated: options.confidentialEdgeTlsTerminated ?? false, }; // Rate-limit the start route: a small window per company and owner (SR-4). const setupTokenRateLimiter = createInviteRateLimiter({ windowMs: 60_000, maxRequests: 5 }); // The deferred lease manager. It fails closed on acquire until a caller binds // the live transport. It still releases a lease by handle or by id, so a // reaper or a shutdown can free a lease that an injected transport acquired. const deferredSetupTokenLeaseManager: SetupTokenLeaseManager = { async acquire(): Promise { // The real sandbox-lease acquisition binds through `options.setupTokenLogin`. // Until then the start route fails closed before it reaches here. throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); }, async release(lease): Promise { await environmentsSvc.releaseLease(lease.id, "released").catch(() => {}); }, async releaseById(leaseId): Promise { await environmentsSvc.releaseLease(leaseId, "released").catch(() => {}); }, }; // The in-memory non-secret cleanup record store. It is the default store when a // caller does not inject a durable database-backed store. const setupTokenCleanupRows = new Map(); const scopeMatchesRow = (row: SetupTokenCleanupRecord, identity: { companyId: string; ownerUserId: string; adapterType: string; }): boolean => row.companyId === identity.companyId && row.ownerUserId === identity.ownerUserId && row.adapterType === identity.adapterType; const inMemorySetupTokenCleanupStore: SetupTokenCleanupStore = { async record(record): Promise { setupTokenCleanupRows.set(record.sessionId, { ...record }); }, async markState(identity, state): Promise { const row = setupTokenCleanupRows.get(identity.sessionId); if (row && scopeMatchesRow(row, identity)) row.state = state; }, async remove(identity): Promise { // The delete matches the full owner scope, so it never removes a row by the // session id alone. const row = setupTokenCleanupRows.get(identity.sessionId); if (row && scopeMatchesRow(row, identity)) setupTokenCleanupRows.delete(identity.sessionId); }, async listReapable(): Promise { return []; }, async consumeStoredClaim(identity): Promise { const row = setupTokenCleanupRows.get(identity.sessionId); if ( !row || !scopeMatchesRow(row, identity) || row.state !== "stored" || row.boundAt !== null || row.deadline <= Date.now() ) { return null; } row.boundAt = Date.now(); return { ...row }; }, async cancelDurable(identity, cancellableStates): Promise { const row = setupTokenCleanupRows.get(identity.sessionId); if (!row || !scopeMatchesRow(row, identity) || !cancellableStates.includes(row.state)) { return null; } row.state = "cancelled"; return { ...row }; }, async findActiveDurable(key, now): Promise { for (const row of setupTokenCleanupRows.values()) { if ( row.companyId === key.companyId && row.ownerUserId === key.ownerUserId && row.adapterType === key.adapterType && !isTerminalSessionState(row.state) && row.deadline > now ) { return { ...row }; } } return null; }, }; const deferredSetupTokenLoginFactory: SetupTokenLoginProcessFactory = () => { // The runner-over-pseudo-terminal binding arrives through // `options.setupTokenLogin`. Until then the start route fails closed. throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); }; const deferredSetupTokenSecretWriter: SetupTokenSecretWriter = async () => { // The owner-bound secret writer arrives through `options.setupTokenLogin`. // Until then the completion fails closed, so the session never reports a // stored credential without a real secret write. throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); }; // Resolve the transport: use the injected factory, lease manager, store, and // secret writer when a caller binds them; otherwise use the deferred, // fail-closed defaults. const setupTokenLoginFactory = options.setupTokenLogin?.factory ?? deferredSetupTokenLoginFactory; const setupTokenLeaseManager = options.setupTokenLogin?.leases ?? deferredSetupTokenLeaseManager; const setupTokenCleanupStore = options.setupTokenLogin?.store ?? inMemorySetupTokenCleanupStore; const setupTokenSecretWriter = options.setupTokenLogin?.completeCredential ?? deferredSetupTokenSecretWriter; // Re-check the environment company binding at lease acquisition. The start // route runs `assertSandboxLoginEnvironment` before the session begins, but // managed-environment reconciliation can bind the sandbox to another company // between that guard and the lease acquire. This wrapper re-runs the same // guard at acquire time and fails closed with the 403 // `environment_company_mismatch` before the transport provisions a sandbox. // The lease insert transaction re-checks the binding once more inside the // insert, so a bind that lands during the provider call still holds no lease. const guardedSetupTokenLeaseManager: SetupTokenLeaseManager = { async acquire(input): Promise { await assertSandboxLoginEnvironment(input.scope.companyId, input.scope.environmentId, { requireSetupTokenLoginProvider: true, }); return setupTokenLeaseManager.acquire(input); }, release: (lease) => setupTokenLeaseManager.release(lease), releaseById: (leaseId) => setupTokenLeaseManager.releaseById(leaseId), }; const setupTokenLoginService = new SetupTokenSessionService({ factory: setupTokenLoginFactory, leases: guardedSetupTokenLeaseManager, store: setupTokenCleanupStore, completeCredential: async (input) => { if (!input.scope.aiConnection) return setupTokenSecretWriter(input); await aiConnectionService(db).save(input.scope.companyId, input.scope.ownerUserId, input.scope.aiConnection, input.token, input.sessionId); }, rateLimiter: setupTokenRateLimiter, }); { // Log the startup transport assessment, so an operator can see whether a // forwarded proxy protocol is trusted for the confidential routes (SR-7). const startupAssessment = assessConfidentialStartup(setupTokenConfidentialConfig); logger.info( { proxyForwardingEnabled: startupAssessment.proxyForwardingEnabled, reason: startupAssessment.reason, deploymentMode: setupTokenConfidentialConfig.deploymentMode, }, "Setup-token login confidential transport startup assessment", ); } options.onSetupTokenLoginService?.(setupTokenLoginService); const runRedactions = createRunSecretRedactionRegistry(db); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); const providerTraces = providerTraceStore(db); const traceExpiryCleanup = providerTraces.cleanupExpired?.(); void traceExpiryCleanup?.catch((error) => { logger.warn({ error }, "provider trace expiry cleanup failed"); }); const recovery = recoveryService(db, { enqueueWakeup: heartbeat.wakeup }); const issueApprovalsSvc = issueApprovalService(db); const secretsSvc = secretService(db); const instructions = agentInstructionsService(db); const agentFiles = agentFileStore(db); const instructionRevisions = agentInstructionRevisionService(db); const instructionWorkingCopies = agentInstructionWorkingCopyService(db); function instructionFileDetail(snapshot: import("@paperclipai/shared").AgentInstructionSnapshot, receipt?: import("@paperclipai/shared").AgentInstructionCommitReceipt) { const path = snapshot.revision.entryFile; return { path, content: snapshot.content, contentHash: snapshot.revision.contentHash, size: snapshot.revision.byteLength, revision: snapshot.revision, receipt, language: path.toLowerCase().endsWith(".md") ? "markdown" : "text", markdown: path.toLowerCase().endsWith(".md"), isEntryFile: true, editable: true, deprecated: false, virtual: false }; } const companySkills = companySkillService(db); const workspaceOperations = workspaceOperationService(db); const instanceSettings = instanceSettingsService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; // The company-scoped adapter login-session service. It runs the device-login // flow in a fresh trusted sandbox and holds the one-time prompt in memory. The // process owns one instance, so the in-memory prompt and the cancellation // controllers persist across requests. const adapterLoginStore = createDbAdapterAuthSessionStore(db); // The account-home secret a Codex login validated and bound, keyed by // session id, queued for `runTerminalCommit` to reconfirm right before the // login service commits its terminal `authenticated` write. `promote`'s own // check runs under a lock that is fully released by the time `promote` // returns, so a rotation can still land after that check and before the // terminal write; `runTerminalCommit` closes that gap by re-running the // same check under a fresh lock acquisition that it holds across the // terminal write itself. `runTerminalCommit` deletes the entry it reads, so // nothing outlives one login attempt. const pendingAccountHomeSecretCommits = new Map< string, { secretId: string; secretName: string; accountHomeDir: string; companyIdentityDiffers: boolean } >(); const adapterLoginService = createDeviceLoginService({ store: adapterLoginStore, runtime: createProductionLoginSessionRuntime({ db, environmentRuntime, // Re-check the provider login pseudo-terminal capability from current // runtime state immediately before the provider lease. The route gate ran // earlier, so a managed reconciliation can rebind the environment to an // unsupported provider between the gate and the acquire; this fails closed // before the lease and the pseudo-terminal. assertProviderSupportsLoginPty: (environmentId) => assertCodexLoginProviderCapability(environmentId), // Wire the live Codex pseudo-terminal opener through the plugin worker // manager route. The opener sets the sandbox `CODEX_HOME` to the same // server-controlled session home the descriptor-bound credential read // opens. When no worker manager is bound, the runtime keeps its fail-closed // opener and the login fails closed. openLivePtySession: options.pluginWorkerManager ? createWorkerBoundLoginPtyOpener({ workerManager: options.pluginWorkerManager, log: (line) => logger.info(line), }) : undefined, }), // The mandatory credential promotion, keyed by adapter type. A successful // login authenticates only after the promotion for its own adapter type // validates the exact staged credential, runs an independent readiness // check, confirms the session still holds the sole active claim, and // writes the credential into the company scope. A rejected or unready // credential fails the session and writes nothing. Keying by adapter type // keeps a `grok_local` login from ever running the Codex promotion (and // vice versa): each entry closes over its own readiness check and its own // promotion function. promotionByAdapterType: { codex_local: { // Hold one lock across the whole promotion sequence below: the // credential write, the existing-secret check, the secret create, and // the cleanup a create failure can trigger. Two different logins for // the SAME Codex account run this whole sequence one at a time, so a // login can never decide to delete the shared account-home directory // while another login's own sequence is still mid-way through writing // its credential or binding its own secret to that same directory. A // lock around only the directory-creation step is not enough: that // lock is already released by the time a login reaches the secret // bind, so a second login can write its credential and be about to // bind its own secret while the first login's later, unrelated // secret-write failure removes the directory both logins now share. async promote(authBytes, context) { const managedSession = await adapterLoginStore.get(context.sessionId); if (managedSession?.aiConnection) { await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => { if (!(await checkStagedCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready"); await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), context.sessionId); }); return; } return withCodexAccountHomePromotionLock(undefined, context.companyId, async () => { // Hold the promotion critical-section lock across the ownership check // and the credential write. The reaper takes the same lock before it // reclaims a stale `promoting` row. So a reclaim never interleaves with // a live write: the reaper either wins the lock first and the // ownership check then reads a reclaimed row and writes nothing, or // the write finishes first under the lock and the reaper reclaims only // after it completes. A read-only fence is not enough, because the // filesystem write can start after the fence; the lock spans the whole // section. const result = await adapterLoginStore.withCompanyAdapterPromotionLock( context.companyId, context.startedByUserId, context.adapterType, () => promoteDeviceLoginCredential({ authBytes, companyId: context.companyId, userInitiated: true, checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes), isSoleActiveOwner: async () => { // The partial unique index allows one active row per company and // adapter. So a `promoting` row for this session is the sole // active owner of the company credential slot. The read runs // inside the lock, so it observes a reaper reclaim that committed // before this section acquired the lock. const row = await adapterLoginStore.get(context.sessionId); return row?.status === "promoting" && row.companyId === context.companyId; }, log: (line) => { // The promotion lines carry no token bytes and no raw account id, // so it is safe to log them with the session identifier. logger.info({ sessionId: context.sessionId }, line); }, }), ); // A resolved promotion is not necessarily an accepted promotion. In // particular, a reaper/expiry race can revoke this session's sole // ownership between the service transition and Decision H. Fail closed: // only a credential write or a deliberate safe keep can authenticate. if (result.outcome !== "promoted" && result.outcome !== "kept") { throw new Error(`device-login credential promotion rejected: ${result.outcome}`); } // The account's own home is durable at this point (the promotion above // wrote it fail-loud). Name it with a company secret, so any agent can // bind to it. Reading the secret by name first keeps a repeat login for // the same account idempotent: `create` throws a conflict when the name // already exists. const handle = result.accountId ? toAccountHandle(result.accountId) : null; if (!handle || !result.accountHomeDir) { throw new Error( "device-login credential promotion rejected: the promotion carried no account home", ); } const secretName = `CODEX_HOME_${handle}`; const accountHomeDir = result.accountHomeDir; // Whether the company default home ended on a DIFFERENT account // than this login. The promotion's own company-home write already // ran (a seed or same-identity refresh landed this login there; a // different account's claim was kept), so this read observes the // post-promotion state. The flag rides to the owner status read, // where the client offers binding the agent to this account — the // only way the login can take effect while another account holds // the company slot. Any read failure degrades to `false`: the // client then simply offers nothing, never a wrong bind. const companyIdentityDiffers = await (async () => { try { const companyAuthBytes = await readFile( path.join(resolveManagedCodexHomeDir(process.env, context.companyId), "auth.json"), ); const companyIdentity = readSubscriptionAccountId(companyAuthBytes); return companyIdentity !== null && companyIdentity !== result.accountId; } catch { return false; } })(); const existingSecret = await secretsSvc.getByName(context.companyId, secretName); if (existingSecret) { // A same-name secret already exists. Confirm it still names this // account's own home before treating a repeat login as a success: // the name alone is not proof of a match. // // Run the check inside the same lock a `local_encrypted` secret // rotate holds for its whole write, the same lock a rotate // takes. This is an early fail-fast only: the lock is fully // released once this call returns, well before the login // service commits its terminal state, so queue the same check // for `runTerminalCommit` to run again right before that // commit, under a fresh lock acquisition it holds across the // commit itself. await withAccountHomeSecretMutationLock(undefined, context.companyId, () => assertAccountHomeSecretMatches(secretsSvc, context.companyId, existingSecret, secretName, accountHomeDir), ); pendingAccountHomeSecretCommits.set(context.sessionId, { secretId: existingSecret.id, secretName, accountHomeDir, companyIdentityDiffers, }); return; } try { const createdSecret = await secretsSvc.create( context.companyId, { name: secretName, provider: "local_encrypted", value: accountHomeDir, description: `CODEX_HOME generated by logging into account ${handle}`, }, { userId: context.startedByUserId, agentId: null }, ); // The value just committed is correct at this instant, but a // rotate queued behind the create's own lock can still commit a // different value before the login service records its // terminal state. Queue the same reconfirm `runTerminalCommit` // runs for the two branches above. pendingAccountHomeSecretCommits.set(context.sessionId, { secretId: createdSecret.id, secretName, accountHomeDir, companyIdentityDiffers, }); } catch (err) { if (err instanceof HttpError && err.status === 409) { // A conflict means a concurrent login for the same account won the // create race. Confirm the winning secret still names this // account's own home before treating the race as a successful, // idempotent login. const winningSecret = await secretsSvc.getByName(context.companyId, secretName); if (!winningSecret) { throw new Error( `device-login credential promotion rejected: the ${secretName} secret conflict could not be resolved`, ); } // Same lock and the same reasoning as the pre-existing-secret // check above: an early fail-fast only, so also queue the // same check for `runTerminalCommit` to run again, under a // fresh lock acquisition it holds across the terminal commit. await withAccountHomeSecretMutationLock(undefined, context.companyId, () => assertAccountHomeSecretMatches(secretsSvc, context.companyId, winningSecret, secretName, accountHomeDir), ); pendingAccountHomeSecretCommits.set(context.sessionId, { secretId: winningSecret.id, secretName, accountHomeDir, companyIdentityDiffers, }); return; } // The account home write failed for a reason other than a naming // conflict. Remove the directory only when this exact login created // it AND no secret, under any name, still names it. The lock // around this whole method already rules out another // SAME-ACCOUNT LOGIN from being mid-sequence here, but it does // not rule out a secret a different path created at this exact // directory in between the read above and this failure — for // example, a concurrent login for the same account that won a // race on this exact name, or a user who names a secret by hand // under a different name entirely. The re-check is this call's // only signal for that case, so it stays even under the lock: // `accountHomeCreated` alone is not proof no such secret now // claims the directory, and a same-name check alone is not proof // either, because the claiming secret can carry any name. // // The check and the delete run inside `withAccountHomeSecretMutationLock`, // the same lock the secrets service holds for the whole of a // `local_encrypted` secret's create or rotate call. That closes the // window `anySecretNamesAccountHome`'s own multi-pass scan cannot: a // secret write that commits after this check's last pass but before // the delete runs. Under the shared lock, a write either finishes // (and becomes visible to the check) before this section acquires the // lock, or it waits for this section to finish before it can commit. if (result.accountHomeCreated) { await withAccountHomeSecretMutationLock(undefined, context.companyId, async () => { const claimed = await anySecretNamesAccountHome(secretsSvc, context.companyId, accountHomeDir); if (!claimed) { await rm(accountHomeDir, { recursive: true, force: true }).catch(() => undefined); } }); } throw new Error( "device-login credential promotion rejected: failed to record the account home secret", ); } }); }, // The login service calls this immediately before it commits its // terminal `authenticated` write, wrapping that write in the // callback it hands in as `commit`. `promote` above already // validated the bound account-home secret once, early, but its own // lock is fully released by the time `promote` returns — well before // this runs. Re-run the same check here, and hold the SAME lock // across both the check and `commit`, so a rotate cannot land in the // gap between the validated value and the terminal write that // reports it as authenticated: a rotate either finishes (and this // check reads its new value, and rejects) before this section // acquires the lock, or it waits for this section — including the // terminal commit — to finish first. async runTerminalCommit(commit, context) { const pending = pendingAccountHomeSecretCommits.get(context.sessionId); pendingAccountHomeSecretCommits.delete(context.sessionId); if (!pending) { // `promote` never reached a secret bind for this session (for // example, a rejected promotion already failed the login before // the service ever reaches this call). Nothing to reconfirm. return commit(); } return withAccountHomeSecretMutationLock(undefined, context.companyId, async () => { // Resolve by the secret's id, not its name: a rotate changes the // value under the same id, so re-resolving this id picks up a // rotation the same way the very first check would have, with no // need to re-look the secret up by name. A deleted secret makes // this resolve call itself fail (unlike a value mismatch, which // `assertAccountHomeSecretMatches` turns into its own error), and // that failure propagates the same way: the login never // authenticates. await assertAccountHomeSecretMatches( secretsSvc, context.companyId, { id: pending.secretId }, pending.secretName, pending.accountHomeDir, ); // The claim rides the terminal write itself, so it is durable, it // survives a restart, and it can never exist for a session that // did not authenticate. return commit({ secretId: pending.secretId, companyIdentityDiffers: pending.companyIdentityDiffers, }); }); }, }, grok_local: { async promote(authBytes, context) { const managedSession = await adapterLoginStore.get(context.sessionId); if (managedSession?.aiConnection) { await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => { if (!(await checkStagedGrokCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready"); await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), context.sessionId); }); return; } // The same promotion critical-section lock as the Codex entry above, // keyed by the same `(companyId, startedByUserId, adapterType)` tuple, // so a Grok reclaim and a Grok write never interleave. const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock( context.companyId, context.startedByUserId, context.adapterType, () => promoteGrokDeviceLoginCredential({ authBytes, companyId: context.companyId, userInitiated: true, checkReadiness: (bytes) => checkStagedGrokCredentialReadiness(bytes), isSoleActiveOwner: async () => { const row = await adapterLoginStore.get(context.sessionId); return row?.status === "promoting" && row.companyId === context.companyId; }, log: (line) => { // The promotion lines carry no token bytes and no personal // field, so it is safe to log them with the session identifier. logger.info({ sessionId: context.sessionId }, line); }, }), ); if (outcome === "kept_foreign_identity") { // The login produced a different account than the one the company // credential home already holds. Fail the session, so the operator // never sees a false `authenticated` for an account the system will // not use. throw new Error( "device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept", ); } // A `kept` outcome is a successful login too: the company home // already holds a same-account credential that is not older than // this one (for example, a teardown copy-back installed a fresher // copy while this login was in progress), so a later run still // authenticates as the same account. if (outcome !== "promoted" && outcome !== "kept") { throw new Error(`device-login credential promotion rejected: ${outcome}`); } }, }, } satisfies Partial>, recordActivity: (event) => { // The event carries no URL, no code, no credential, no account identifier, // and no lease identifier, so it is safe to log. logger.info(event, "adapter login session lifecycle"); }, }); // The cancellation controllers for the in-flight login runs this process owns. const adapterLoginAbortControllers = new Map(); async function assertAgentEnvironmentSelection( companyId: string, adapterType: string, environmentId: string | null | undefined, ) { if (environmentId === undefined || environmentId === null) return; await assertEnvironmentSelectionForCompany(environmentService(db), companyId, environmentId, { allowedDrivers: allowedEnvironmentDriversForAgent(adapterType), }); } async function decideAgentRead(req: Request, agent: { id: string; companyId: string }) { return access.decide({ actor: req.actor, action: "agent:read", resource: { type: "agent", companyId: agent.companyId, agentId: agent.id }, }); } async function assertAgentReadAllowed(req: Request, res: Response, agent: { id: string; companyId: string }) { const decision = await decideAgentRead(req, agent); if (decision.allowed) return true; res.status(403).json({ error: "Agent is outside this actor's authorization boundary" }); return false; } async function assertRunTelemetryReadAllowed(req: Request, res: Response, companyId: string) { const decision = await access.decide({ actor: req.actor, action: "company_scope:read", resource: { type: "company", companyId }, }); if (decision.allowed) return true; res.status(403).json({ error: "Run telemetry is outside this actor's authorization boundary" }); return false; } async function filterAgentsForActor>( req: Request, rows: T[], fallbackCompanyId?: string, ) { const decisions = await Promise.all(rows.map((agent) => { const id = typeof agent.id === "string" ? agent.id : null; const companyId = typeof agent.companyId === "string" ? agent.companyId : fallbackCompanyId ?? null; if (!id || !companyId) return Promise.resolve({ allowed: false }); return decideAgentRead(req, { id, companyId }); })); return rows.filter((_, index) => decisions[index]?.allowed); } // A null agent override inherits the instance default, just like dispatch. // Resolve this before secrets or probes so a default remote environment can // never accidentally validate the account on the control-plane host. async function resolveAdapterTestEnvironmentId(companyId: string, environmentId: string | null | undefined) { if (environmentId) return environmentId; const settings = await instanceSettings.get(); if (settings.defaultEnvironmentId) return settings.defaultEnvironmentId; if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly === true) { const managed = await environmentsSvc.findManagedSandboxEnvironment(companyId); if (!managed) { throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable", }); } return managed.id; } return null; } /** * Resolve the execution target the adapter should run its test probes against. * * - No environmentId / local environment → returns a local target so the * adapter probes the Paperclip host (legacy behavior). * - SSH environment → builds an SSH execution target from the environment * config so the adapter probes the remote box. No lease is required: * the SSH spec is fully derived from the saved environment config. * - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the * workspace, and resolves a sandbox execution target wired to the runtime * so the adapter probe runs inside the sandbox the same way a heartbeat * would. The returned `release` callback rolls the lease back when the * route is done. * * The caller MUST always invoke `release()` (typically in a `finally` block). */ async function resolveAdapterTestExecutionContext(input: { companyId: string; adapterType: string; environmentId: string | null; }): Promise<{ executionTarget: AdapterExecutionTarget | null; environmentName: string | null; fallbackChecks: AdapterEnvironmentCheck[]; sandboxIdentityCheck?: AdapterEnvironmentCheck | null; release: (status?: "released" | "failed") => Promise; }> { const noopRelease = async () => {}; if (!input.environmentId) { return { executionTarget: null, environmentName: null, fallbackChecks: [], release: noopRelease, }; } const requestedEnvironment = await environmentsSvc.getById(input.environmentId); if (!requestedEnvironment) { return { executionTarget: null, environmentName: null, fallbackChecks: [ { code: "environment_not_found", level: "warn", message: "Selected environment was not found. The test did not run.", }, ], release: noopRelease, }; } // Managed-sandbox-only policy: redirect a Test that would run on the local // host onto the platform-managed sandbox, the same as a real run does // (resolveExecutionWorkspaceEnvironmentId in heartbeat). Without this // redirect the Test probes the local host while the run executes in the // managed sandbox, so a passing Test validates the wrong execution target. // With no active managed sandbox the Test fails closed — never local. let environment = requestedEnvironment; if (requestedEnvironment.driver === "local") { const managedSandboxOnly = (await instanceSettings.getExperimental()).enableManagedSandboxOnly === true; if (managedSandboxOnly) { const managedSandboxEnvironment = await environmentsSvc.findManagedSandboxEnvironment( input.companyId, ); if (!managedSandboxEnvironment) { return { executionTarget: null, environmentName: requestedEnvironment.name, fallbackChecks: [ { code: "managed_sandbox_unavailable", level: "error", message: "This instance runs agents only in its platform-managed sandbox, but no active managed sandbox environment exists. The test did not run.", hint: "Restore the managed sandbox environment, then test again.", }, ], release: noopRelease, }; } environment = managedSandboxEnvironment; } } if (environment.driver === "local") { return { executionTarget: null, environmentName: environment.name, fallbackChecks: [], release: noopRelease, }; } if (environment.driver === "ssh") { try { const target = await resolveEnvironmentExecutionTarget({ db, companyId: input.companyId, adapterType: input.adapterType, environment: { id: environment.id, driver: environment.driver, config: environment.config ?? null, }, leaseMetadata: null, }); if (target) { return { executionTarget: target, environmentName: environment.name, fallbackChecks: [], release: noopRelease, }; } return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_target_unavailable", level: "warn", message: `Could not resolve an execution target for environment "${environment.name}". The test did not run.`, }, ], release: noopRelease, }; } catch (err) { return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_target_failed", level: "warn", message: `Could not connect to environment "${environment.name}" to run the test.`, detail: err instanceof Error ? err.message : String(err), }, ], release: noopRelease, }; } } // sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize // the workspace inside the box, and run the same probe SSH uses against // a sandbox execution target wired to the environment runtime. // // We pass `heartbeatRunId: null` because there's no heartbeat run for an // operator-initiated `Test` invocation — the leases table FKs heartbeat // run id to heartbeat_runs.id, and we don't want to manufacture a fake // run row. Cleanup goes through the driver's `releaseRunLease` directly // (by lease record), since the batch helper queries by heartbeatRunId. // // Sandbox tests boot a fresh throwaway sandbox (never resume a retained // agent lease) and archive it on release instead of deleting it, so the // operator can inspect the exact sandbox from the provider dashboard while // provider-side expiry reaps it later. const testEnvironment = environment.driver === "sandbox" ? { ...environment, config: { ...(environment.config ?? {}), reuseLease: false, archiveOnRelease: true, }, } : environment; let leaseRecord: Awaited>; try { leaseRecord = await environmentRuntime.acquireRunLease({ companyId: input.companyId, environment: testEnvironment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, // Re-check the company binding atomically at lease time. The route // guard already rejected a foreign environment, but the binding could // change between the guard check and the lease acquire. This closes // that check-to-lease race so a foreign sandbox never gets a lease. assertCompanyBinding: true, // Apply the active custom-image template so the Test boots with the // operator's captured sandbox customizations and prepared image state, // matching what real agent runs use. Without this the test would // silently fall back to the base image. applyCustomImageTemplate: true, }); } catch (err) { return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_lease_acquire_failed", level: "error", message: `Could not acquire a lease for environment "${environment.name}".`, detail: err instanceof Error ? err.message : String(err), hint: "Check the environment's provider credentials and quota.", }, ], release: noopRelease, }; } const driver = environmentRuntime.getDriver(environment.driver); const releaseLease = async (status: "released" | "failed" = "released") => { try { if (driver) { await driver.releaseRunLease({ environment: testEnvironment, lease: leaseRecord.lease, status, }); } else { await environmentsSvc.releaseLease(leaseRecord.lease.id, status); } } catch (err) { // Cleanup failures must not mask the test result. // eslint-disable-next-line no-console console.warn( `[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`, ); } }; let realizedCwd: string | null = null; try { const realized = await environmentRuntime.realizeWorkspace({ environment: testEnvironment, lease: leaseRecord.lease, // No host workspace to copy for a Test invocation; sandbox/plugin // realize implementations use the lease metadata's remoteCwd to // create the working directory inside the box. workspace: {}, }); realizedCwd = typeof realized.cwd === "string" && realized.cwd.trim().length > 0 ? realized.cwd.trim() : null; } catch (err) { await releaseLease("failed"); return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_workspace_realize_failed", level: "error", message: `Could not realize a workspace inside "${environment.name}".`, detail: err instanceof Error ? err.message : String(err), }, ], release: noopRelease, }; } let target: AdapterExecutionTarget | null; try { // Prefer the cwd the realize step returned; fall back to lease metadata. const leaseMetadataForTarget: Record | null = realizedCwd ? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd } : (leaseRecord.lease.metadata as Record | null) ?? null; target = await resolveEnvironmentExecutionTarget({ db, companyId: input.companyId, adapterType: input.adapterType, environment: { id: testEnvironment.id, driver: testEnvironment.driver, config: testEnvironment.config ?? null, }, leaseId: leaseRecord.lease.id, leaseMetadata: leaseMetadataForTarget, lease: leaseRecord.lease, environmentRuntime, }); } catch (err) { await releaseLease("failed"); return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_target_failed", level: "error", message: `Could not resolve an execution target for "${environment.name}".`, detail: err instanceof Error ? err.message : String(err), }, ], release: noopRelease, }; } if (!target) { await releaseLease("failed"); return { executionTarget: null, environmentName: environment.name, fallbackChecks: [ { code: "environment_target_unsupported", level: "warn", message: `Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`, }, ], release: noopRelease, }; } return { executionTarget: target, environmentName: environment.name, fallbackChecks: [], sandboxIdentityCheck: buildSandboxIdentityCheck({ environmentName: environment.name, lease: leaseRecord.lease, }), release: releaseLease, }; } function readMetadataString(metadata: Record, keys: string[]): string | null { for (const key of keys) { const value = metadata[key]; if (typeof value === "string" && value.trim().length > 0) return value.trim(); } return null; } function buildSandboxIdentityCheck(input: { environmentName: string; lease: { id: string; provider?: string | null; providerLeaseId?: string | null; metadata?: Record | null; }; }): AdapterEnvironmentCheck { const metadata = input.lease.metadata ?? {}; const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]); const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]); const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]); const snapshotRef = readMetadataString(metadata, [ "snapshot", "snapshotId", "snapshotID", "snapshotRef", "snapshot_ref", "templateRef", "template_ref", "templateId", "templateID", "image", "imageId", "imageID", "imageRef", "image_ref", ]); const templateKind = readMetadataString(metadata, [ "templateKind", "template_kind", "templateRefKind", "template_ref_kind", ]); const detailParts = [ `paperclipLeaseId=${input.lease.id}`, input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null, provider ? `provider=${provider}` : null, sandboxId ? `sandboxId=${sandboxId}` : null, sandboxName ? `sandboxName=${sandboxName}` : null, snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null, ].filter((part): part is string => Boolean(part)); return { code: "sandbox_test_identity", level: "info", message: `Environment test identity for "${input.environmentName}".`, detail: detailParts.join("; "), hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed environment snapshots.", }; } async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }; } 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 buildAgentAccessState(agent: NonNullable>>) { const membership = await access.getMembership(agent.companyId, "agent", agent.id); const grants = membership ? await access.listPrincipalGrants(agent.companyId, "agent", agent.id) : []; const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign"); if (agent.role === "ceo") { return { canAssignTasks: true, taskAssignSource: "ceo_role" as const, membership, grants, }; } if (canCreateAgents(agent)) { return { canAssignTasks: true, taskAssignSource: "agent_creator" as const, membership, grants, }; } if (hasExplicitTaskAssignGrant) { return { canAssignTasks: true, taskAssignSource: "explicit_grant" as const, membership, grants, }; } if (membership?.status === "active") { return { canAssignTasks: true, taskAssignSource: "simple_default" as const, membership, grants, }; } return { canAssignTasks: false, taskAssignSource: "none" as const, membership, grants, }; } async function buildAgentDetail( agent: NonNullable>>, options?: { restricted?: boolean }, ) { const [chainOfCommand, accessState] = await Promise.all([ svc.getChainOfCommand(agent.id), buildAgentAccessState(agent), ]); const baseAgent = redactAgentRowForResponse( options?.restricted ? redactForRestrictedAgentView(agent) : agent, ); return { ...baseAgent, chainOfCommand, access: accessState, }; } async function resolveAgentSelfTrustPreset(req: Request, agent: NonNullable>>) { if (req.actor.type !== "agent" || req.actor.agentId !== agent.id) { return { kind: "standard" as const }; } const run = req.actor.type === "agent" && req.actor.runId ? await db .select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; const runContext = run?.agentId === agent.id ? readObject(run.contextSnapshot) : null; const runExecutionPolicy = readObject(runContext?.executionPolicy); const runIssueId = readRunIssueId(runContext); const runScopedIssue = runIssueId ? await db .select({ companyId: issuesTable.companyId, projectId: issuesTable.projectId, executionPolicy: issuesTable.executionPolicy, projectExecutionWorkspacePolicy: projectsTable.executionWorkspacePolicy, }) .from(issuesTable) .leftJoin(projectsTable, and(eq(projectsTable.id, issuesTable.projectId), eq(projectsTable.companyId, issuesTable.companyId))) .where(and(eq(issuesTable.id, runIssueId), eq(issuesTable.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; return resolveCoreTrustPreset({ companyId: agent.companyId, agent, project: runScopedIssue?.projectId ? { companyId: runScopedIssue.companyId, executionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy, } : null, issue: runScopedIssue ? { companyId: runScopedIssue.companyId, executionPolicy: runScopedIssue.executionPolicy, } : null, run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null, }); } function buildLowTrustSelfView(agent: NonNullable>>) { return { id: agent.id, companyId: agent.companyId, name: agent.name, role: agent.role, title: agent.title, status: agent.status, trustPreset: LOW_TRUST_REVIEW_PRESET, }; } async function applyDefaultAgentTaskAssignGrant( companyId: string, agentId: string, grantedByUserId: string | null, ) { await access.ensureMembership(companyId, "agent", agentId, "member", "active"); await access.setPrincipalPermission( companyId, "agent", agentId, "tasks:assign", true, grantedByUserId, ); } async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); const decision = await access.decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId }, }); if (!decision.allowed) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } if (req.actor.type !== "agent") return null; const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } return actorAgent; } async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); const decision = await access.decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId }, }); if (decision.allowed) return; throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } async function assertBoardCanWakeAgent(req: Request, agent: { id: string; companyId: string }) { assertBoard(req); if (!hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); assertCompanyAccess(req, agent.companyId); const decision = await access.decide({ actor: req.actor, action: "agent:wake", resource: { type: "agent", companyId: agent.companyId, agentId: agent.id }, }); if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } // The single owner-authorization helper for the three adapter login routes. It // requires a board actor, company access, and the same configuration // permission as the adapter Test route (`agents:create`). It returns the // immutable owner identifier: the board user that starts, reads, or cancels the // session. The start route persists this identifier; the status and cancel // routes compare it to the session owner and return 404 on a mismatch, so a // non-owner cannot enumerate a session. async function assertCanManageAdapterLogin( req: Request, companyId: string, ): Promise { assertBoard(req); assertCompanyAccess(req, companyId); const decision = await access.decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId }, }); if (!decision.allowed) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } const userId = req.actor.userId; if (!userId) { throw forbidden( "A board user identity is required to manage an adapter login session.", ); } return userId; } // Read the interactive login capability the registry declares for an adapter // type. Return null when the adapter declares no capability, so a guard fails // closed on the absent case. function getRegistryLoginCapability(type: string) { return findActiveServerAdapter(type)?.loginCapability ?? null; } // The device-login route drives a login that shows a one-time code on a real // pseudo-terminal. It serves an adapter whose registry login capability // declares the displayed-code panel mode and whose trusted adapter type maps // to a login command key. The guard reads the panel mode and the command map, // not the adapter name, so a new adapter that satisfies both passes with no // guard code change. It rejects an adapter with no matching capability, and an // adapter with no mapped command key, with the same fixed 400. // // The command-map check keeps admission consistent with the closed command // map. The login opener resolves the command key from the same map. An adapter // that declares the displayed-code capability but has no mapped key would pass // the panel-mode check, then fail at command resolution after the route // creates session state. The guard rejects it before any session or lease side // effect. function assertDeviceLoginAdapter(type: string): void { if (getRegistryLoginCapability(type)?.panelMode !== "displayed_code") { throw badRequest(`Adapter "${type}" does not support a device login.`); } if (!isLoginCommandSupportedAdapterType(type)) { throw badRequest(`Adapter "${type}" does not support a device login.`); } } // The environment-eligibility guard for an adapter login. A device login runs // only in an active sandbox environment. This reuses the shared environment // selection guard, so it rejects a missing, archived (inactive), local, SSH, or // plugin environment the same way the agent configuration routes do. // // The execution environment catalog is instance-scoped, not company-owned. PR // #8375 moved the catalog to one shared instance catalog, so an environment row // carries no single owning company. The shared selection guard therefore checks // only the driver and the status. The company-binding check below then rejects // an environment that binds to other companies. The route caller is already // bound to the path company by `assertCompanyAccess`, and the acquired lease // records that same company, so the login stays attributed to the caller. async function assertSandboxLoginEnvironment( companyId: string, environmentId: string, options?: { requireSetupTokenLoginProvider?: boolean }, ): Promise { await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { allowedDrivers: ["sandbox"], }); // Reject an environment that another company owns. A managed sandbox // environment binds to the companies that the instance provisions it for. // When the environment binds to companies but not the request company, the // environment belongs to another company. A login there runs the process in // a foreign company sandbox, so the guard fails closed. An environment with // no company binding is instance-global and stays open to every member. const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId); if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) { throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch", }); } // Gate the Claude setup-token login on the provider capability. Only a // sandbox provider that advertises the setup-token login capability // implements the setup-token pseudo-terminal methods. The setup-token start // routes pass this option, so an unsupported provider fails closed here // before the session starts. The lease guard passes it too, so a // reconciliation that rebinds the environment to an unsupported provider // still fails closed before the lease and the pseudo-terminal. if (options?.requireSetupTokenLoginProvider) { await assertSetupTokenLoginProviderCapability(environmentId); } } /** * Reports whether the environment provider advertises the login pseudo-terminal * capability. It resolves the effective provider from the current environment * config, then reads the static capability from the provider plugin manifest. It * never checks the provider by name. A missing provider, a missing plugin, a * non-plugin provider, and a provider without the flag all return false. Both * login flows share this resolver, so both gates read the same current * capability. */ async function resolveProviderSupportsLoginPty(environmentId: string): Promise { const environment = await environmentsSvc.getById(environmentId); const config = environment?.config && typeof environment.config === "object" ? (environment.config as Record) : {}; const provider = typeof config.provider === "string" ? config.provider : ""; const resolved = provider ? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider }) : null; return resolved?.driver.supportsLoginPty === true; } /** * Fails closed when the environment provider does not advertise the login * pseudo-terminal capability that the Claude setup-token login needs. It reads * the current provider capability. It fails closed with the fixed, typed error, * so no session row, lease, or pseudo-terminal starts. */ async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise { if (!(await resolveProviderSupportsLoginPty(environmentId))) { throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, { code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE, }); } } /** * Fails closed when the environment provider does not advertise the login * pseudo-terminal capability that the Codex device login needs. It reads the * current provider capability. The Codex route runs it before any session or * lease state, and the lease-acquisition path runs it again from current runtime * state before the provider lease. It fails closed with the fixed, typed error, * so no session row, lease, or pseudo-terminal starts. */ async function assertCodexLoginProviderCapability(environmentId: string): Promise { if (!(await resolveProviderSupportsLoginPty(environmentId))) { throw unprocessable(DEVICE_LOGIN_PROVIDER_UNSUPPORTED, { code: DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, }); } } // Read a login session for its owner. The durable row is the authority for the // company and the owner. This returns null when the row is absent, when it // belongs to another company or adapter, or when the requesting user is not the // owner. So a non-owner and a cross-company caller both receive a 404 and cannot // enumerate a session. Only the owner path reads the one-time prompt. async function readOwnerLoginSession( companyId: string, adapterType: string, publicSessionId: string, requestingUserId: string, ): Promise { // Read by the public session id, scoped to the company. The store predicate // already carries the company id, so a foreign-company caller reads nothing // and the internal row id never matches. Keep the adapter and owner checks. const row = await adapterLoginStore.getByPublicId(publicSessionId, companyId); if (!row || row.adapterType !== adapterType || row.startedByUserId !== requestingUserId) { return null; } const session = await adapterLoginService.readOwnerSession(publicSessionId, companyId, requestingUserId); if (!session) return session; // Merge the non-secret account-binding claim onto an authenticated Codex // owner read. The claim was written atomically with the terminal status // (see runTerminalCommit), so it survives restarts and can never appear // on a session that did not authenticate. // // Read the claim from a row fetched AFTER the status was observed, never // from the earlier authorization read: the terminal write can land // between the two, and a poll that sees `authenticated` paired with the // older claim-less row would drop the claim forever (polling stops at // the terminal status). The claim-and-status write is one atomic update, // so any row read after `authenticated` was observed carries the claim. // Shape-validate the durable value rather than trusting a cast: a // malformed claim degrades to "offer nothing", never to a wrong bind. if (row.adapterType === "codex_local" && session.status === "authenticated") { const settled = await adapterLoginStore.getByPublicId(publicSessionId, companyId); const raw = settled?.resultClaim; if ( raw && typeof raw.secretId === "string" && raw.secretId.length > 0 && typeof raw.companyIdentityDiffers === "boolean" ) { const claim: CodexAccountBindingClaim = { secretId: raw.secretId, companyIdentityDiffers: raw.companyIdentityDiffers, }; return { ...session, codexAccountBinding: claim }; } } return session; } async function assertCanReadConfigurations(req: Request, companyId: string) { // Reading agent configurations, skills, and config revisions is a // read-only operation available to any board (human) member of the // company. Responses go through `redactAgentConfiguration` so secrets // are never exposed. Mutations and environment probes still gate on // agents:create or agents:configure via the mutating route helpers. // // For AGENT actors we keep a stricter gate: an agent must have either // agents:configure or agents:suggest-changes before it can inspect peer // agent configuration for a proposed diff. assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { const decision = await access.decide({ actor: req.actor, action: "agent_config:read", resource: { type: "company", companyId }, }); if (!decision.allowed) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } return req.actor.agentId ? await svc.getById(req.actor.agentId) : null; } return null; } async function getAccessibleAgent(req: Request, res: Response, id: string) { const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return null; if (req.actor.type === "board") { await assertBoardCanManageAgentsForCompany(req, agent.companyId); } return agent; } async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { // Mirrors assertCanReadConfigurations but returns a boolean instead of // throwing. Board actors only need company access; agent actors must pass // the agent configuration read grant ladder so peer agents cannot snoop // each others' configurations. try { assertCompanyAccess(req, companyId); } catch { return false; } if (req.actor.type === "board") return true; const decision = await access.decide({ actor: req.actor, action: "agent_config:read", resource: { type: "company", companyId }, }); return decision.allowed; } async function buildSkippedWakeupResponse( agent: NonNullable>>, payload: Record | null | undefined, ) { const issueId = typeof payload?.issueId === "string" && payload.issueId.trim() ? payload.issueId : null; if (!issueId) { return { status: "skipped" as const, reason: "wakeup_skipped", message: "Wakeup was skipped.", issueId: null, executionRunId: null, executionAgentId: null, executionAgentName: null, }; } const issue = await db .select({ id: issuesTable.id, executionRunId: issuesTable.executionRunId, }) .from(issuesTable) .where(and(eq(issuesTable.id, issueId), eq(issuesTable.companyId, agent.companyId))) .then((rows) => rows[0] ?? null); const blocker = issue ? await getExecutionBlocker(db, agent.companyId, issueId) : null; if (blocker) return { status: "skipped" as const, reason: "execution_reconciliation_required", message: blocker.nextAction, issueId, executionRunId: blocker.runId, executionAgentId: blocker.agentId, executionAgentName: null, }; if (!issue?.executionRunId) { return { status: "skipped" as const, reason: "wakeup_skipped", message: "Wakeup was skipped.", issueId, executionRunId: null, executionAgentId: null, executionAgentName: null, }; } const executionRun = await heartbeat.getRun(issue.executionRunId); if (!executionRun || (executionRun.status !== "queued" && executionRun.status !== "running")) { return { status: "skipped" as const, reason: "wakeup_skipped", message: "Wakeup was skipped.", issueId, executionRunId: issue.executionRunId, executionAgentId: null, executionAgentName: null, }; } const executionAgent = await svc.getById(executionRun.agentId); const executionAgentName = executionAgent?.name ?? null; return { status: "skipped" as const, reason: "issue_execution_deferred", message: executionAgentName ? `Wakeup was deferred because this issue is already being executed by ${executionAgentName}.` : "Wakeup was deferred because this issue already has an active execution run.", issueId, executionRunId: executionRun.id, executionAgentId: executionRun.agentId, executionAgentName, }; } async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { if (!hasCompanyAccess(req, targetAgent.companyId)) { throw notFound("Agent not found"); } assertCompanyAccess(req, targetAgent.companyId); const decision = await access.decide({ actor: req.actor, action: "agent_config:update", resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, }); if (decision.allowed) return; throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) { if (!hasCompanyAccess(req, targetAgent.companyId)) { throw notFound("Agent not found"); } assertCompanyAccess(req, targetAgent.companyId); if (req.actor.type === "board") { await assertCanReadConfigurations(req, targetAgent.companyId); 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"); } const decision = await access.decide({ actor: req.actor, action: "agent_config:read", resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, }); if (decision.allowed) return; throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } function assertKnownAdapterType(type: string | null | undefined): string { const adapterType = typeof type === "string" ? type.trim() : ""; if (!adapterType) { throw unprocessable("Adapter type is required"); } if (!findServerAdapter(adapterType)) { throw unprocessable(`Unknown adapter type: ${adapterType}`); } return adapterType; } /** * Adapter validation for the paths that CHOOSE a harness for a new agent * (hire + create), as opposed to the paths that operate on an existing one. * * A disabled adapter is one this instance cannot run — most often because a * declarative registry (PAPERCLIP_ADAPTERS) curated it out, which * reconcileAdapterAvailability turns into a disabled type at boot. Registered * but disabled still passes assertKnownAdapterType, so an agent could be * created on it and then fail EVERY run at lease time with * `Adapter "..." is not in the configured adapter registry` — an error that * arrives minutes later, in a run log, with no way back to the choice that * caused it. Refuse at selection time instead, and name what can be chosen. * * Existing agents on a now-disabled adapter are deliberately untouched * (listEnabledServerAdapters documents the same rule: hidden from selection, * still functional for agents that already use them). */ async function assertSelectableAdapterType(type: string | null | undefined): Promise { const adapterType = assertKnownAdapterType(type); if (adapterType === "paperclip_runner") { const experimental = await instanceSettings.getExperimental(); if (experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, ); } } const disabled = new Set(getDisabledAdapterTypes()); if (!disabled.has(adapterType)) return adapterType; const available = listServerAdapters() .map((a) => a.type) .filter((t) => !disabled.has(t)) .sort(); throw unprocessable( `Adapter "${adapterType}" is not available on this instance. ` + `Available adapters: ${available.length > 0 ? available.join(", ") : "(none configured)"}`, ); } async function assertFreshPaperclipRunnerProvider( companyId: string, adapterType: string, adapterConfig: Record, ): Promise { if (adapterType !== "paperclip_runner") return; let profile; try { profile = resolvePaperclipRunnerProviderProfile(adapterConfig); } catch (error) { if (error instanceof PaperclipRunnerProviderProfileError) { throw unprocessable(error.message, { code: error.code }); } throw error; } if (profile.provider === "claude_managed") { await managedAgentProfileService(db).requireQualified( companyId, profile.managedProfileId, ); } else if (profile.provider === "aws_agentcore") { await remoteAgentProfileService(db).requireQualified( companyId, profile.agentCoreProfileId, "aws_bedrock_agentcore_harness", ); } } function resolvePaperclipRunnerAdapterTransition(input: { previousAdapterType: string; nextAdapterType: string; previousAdapterConfig: Record; nextAdapterConfig: Record; }): Record { if ( input.nextAdapterType !== "paperclip_runner" || input.previousAdapterType === input.nextAdapterType ) { return input.nextAdapterConfig; } const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider); if (!["claude_local", "codex_local", "opencode_local"].includes(input.previousAdapterType) && !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) { throw unprocessable("Select a Paperclip Runner provider before converting this agent."); } const next = { ...defaults, ...input.nextAdapterConfig }; if (!asNonEmptyString(next.model)) next.model = defaults.model; return normalizeLegacyRunnerProvider(next); } function assertProviderTraceSettingTransition( req: Request, nextRuntimeConfig: unknown, previousRuntimeConfig?: unknown, ): void { const previousRaw = asRecord(asRecord(previousRuntimeConfig)?.debug)?.providerTrace === "raw"; const nextRaw = asRecord(asRecord(nextRuntimeConfig)?.debug)?.providerTrace === "raw"; if (previousRaw !== nextRaw) assertInstanceAdmin(req); } async function assertAgentDefaultEnvironmentSelection( companyId: string, environmentId: string | null | undefined, options?: { allowedDrivers?: string[]; allowedSandboxProviders?: string[] }, ) { if (environmentId === undefined || environmentId === null) return; const environment = await environmentsSvc.getById(environmentId); if (!environment) { throw unprocessable("Selected environment was not found"); } if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) { throw unprocessable(`Environment driver "${environment.driver}" is not allowed here`); } if (environment.driver === "sandbox" && options?.allowedSandboxProviders) { const config = environment.config && typeof environment.config === "object" ? environment.config as Record : {}; const provider = typeof config.provider === "string" ? config.provider : ""; if (provider === "fake") { throw unprocessable( `Selected sandbox provider "${provider}" is not supported for agent defaults yet`, ); } if (options.allowedSandboxProviders.length > 0 && !options.allowedSandboxProviders.includes(provider)) { throw unprocessable( `Selected sandbox provider "${provider || "unknown"}" is not supported for agent defaults yet`, ); } } } function hasOwn(value: object, key: string): boolean { return Object.hasOwn(value, key); } function allowedEnvironmentDriversForAgent(adapterType: string): string[] { return supportedEnvironmentDriversForAdapter(adapterType); } function allowedSandboxProvidersForAgent(adapterType: string): string[] | undefined { return supportedEnvironmentDriversForAdapter(adapterType).includes("sandbox") ? [] : []; } 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 asEnvBindingString(value: unknown): string | null { const direct = asNonEmptyString(value); if (direct) return direct; const record = asRecord(value); if (record?.type !== "plain") return null; return asNonEmptyString(record.value); } function preserveInstructionsBundleConfig( existingAdapterConfig: Record, nextAdapterConfig: Record, ) { const nextKeys = new Set(Object.keys(nextAdapterConfig)); if (KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => nextKeys.has(key))) { return nextAdapterConfig; } const merged = { ...nextAdapterConfig }; for (const key of KNOWN_INSTRUCTIONS_BUNDLE_KEYS) { if (merged[key] === undefined && existingAdapterConfig[key] !== undefined) { merged[key] = existingAdapterConfig[key]; } } return merged; } 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) ?? false, intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), }; } function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record { const parsedRuntimeConfig = asRecord(runtimeConfig); const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {}; const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat); const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {}; if (parseBooleanLike(heartbeat.enabled) == null) { heartbeat.enabled = false; } if (parseNumberLike(heartbeat.maxConcurrentRuns) == null) { heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; } normalizedRuntimeConfig.heartbeat = heartbeat; return normalizedRuntimeConfig; } async function normalizeCreatedAgentRuntimeConfig( req: Request, companyId: string, adapterType: string, adapterConfig: Record, runtimeConfig: unknown, ) { const normalized = normalizeNewAgentRuntimeConfig(runtimeConfig); if (req.actor.type !== "agent" || normalized.aiConnection) return normalized; const manager = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; if (!manager || manager.companyId !== companyId) throw forbidden("Hiring agent is unavailable"); const binding = defaultAiConnectionForHire(adapterType, adapterConfig, manager.runtimeConfig?.aiConnection); if (binding) normalized.aiConnection = binding; return normalized; } async function normalizeMediatedAdapterConfigForPersistence(input: { companyId: string; adapterType: string | null | undefined; adapterConfig: Record; constraintAdapterConfig?: Record; }): Promise> { const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( input.companyId, input.adapterConfig, { strictMode: strictSecretsMode, adapterType: input.adapterType ?? null, }, ); await assertAdapterConfigConstraints( input.companyId, input.adapterType, input.constraintAdapterConfig ? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig } : normalizedAdapterConfig, ); return normalizePaperclipRunnerAdapterConfig( input.adapterType ?? "", normalizedAdapterConfig, ); } 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 codexLocalAgentHome(companyId: string, agentId: string): string { const instanceRoot = resolvePaperclipInstanceRootForAdapter({ homeDir: asNonEmptyString(process.env.PAPERCLIP_HOME) ?? undefined, instanceId: asNonEmptyString(process.env.PAPERCLIP_INSTANCE_ID) ?? undefined, env: process.env, }); return path.resolve(instanceRoot, "companies", companyId, "agents", agentId, "codex-home"); } function codexLocalEnvKeyConfigured(value: unknown): boolean { if (asEnvBindingString(value)) return true; const record = asRecord(value); return record?.type === "secret_ref" && typeof record.secretId === "string"; } // codex_local agents inherit whatever Codex login is already on the device // (the host's ~/.codex or $CODEX_HOME) by default, so a fresh agent needs no // env overrides at all. We only carve out an isolated per-agent CODEX_HOME // when the agent sets its own OPENAI_API_KEY, so that key's api-key auth.json // does not collide with the shared company home other agents use for the host // login. Agents without a key share the host credentials. function applyCodexLocalKeyIsolation( companyId: string, agentId: string, adapterType: string | null | undefined, adapterConfig: Record, ): Record { if (adapterType !== "codex_local") return adapterConfig; const existingEnv = asRecord(adapterConfig.env); if (!existingEnv) return adapterConfig; if (!codexLocalEnvKeyConfigured(existingEnv.OPENAI_API_KEY)) return adapterConfig; if (codexLocalEnvKeyConfigured(existingEnv.CODEX_HOME)) return adapterConfig; return { ...adapterConfig, env: { ...existingEnv, CODEX_HOME: codexLocalAgentHome(companyId, agentId) }, }; } // The provider credential environment keys a hired agent can inherit from the // hiring agent, by adapter type. Each key holds a credential. A configuration // key such as CODEX_HOME or GROK_HOME is a path, not a credential, and stays // out of this list. const INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS: Record = { claude_local: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"], codex_local: ["OPENAI_API_KEY", "CODEX_API_KEY"], grok_local: ["XAI_API_KEY"], }; function isInheritableCredentialReference(value: unknown): value is Record { const record = asRecord(value); return record !== null && (record.type === "secret_ref" || record.type === "user_secret_ref"); } // A hired agent inherits the provider credential references the hiring // agent already holds for the same adapter type, so a freshly hired agent // can run without a separate credential setup step. The merge copies each // reference object whole, so the child keeps the parent's pinned version // and its other fields. A key the hire request already supplies always // wins, and the merge never inherits a plain environment value. // // A claude_local hire request that already supplies any Claude credential // key inherits no Claude credential key at all. That keeps child-wins // precedence and rules out the forbidden pairing of the fixed OAuth binding // with an ANTHROPIC_API_KEY. // // The hiring agent must belong to the target company. Without that check, an // agent that can create agents in another company could copy its own // company's credential reference into that other company. async function applyHiringAgentAuthInheritance( req: Request, companyId: string, adapterType: string | null | undefined, adapterConfig: Record, runtimeConfig: unknown, ): Promise<{ adapterConfig: Record; inheritedFixedClaudeOAuthBinding: boolean }> { const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false }; if (asRecord(runtimeConfig)?.aiConnection) return noInheritance; if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance; const credentialKeys = adapterType ? INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[adapterType] : undefined; if (!credentialKeys) return noInheritance; const parent = await svc.getById(req.actor.agentId); if (!parent || parent.companyId !== companyId || parent.adapterType !== adapterType) return noInheritance; // Managed parents must not pass stale legacy credential references to hires. if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance; const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env); if (!parentEnv) return noInheritance; const existingEnv = asRecord(adapterConfig.env); const claudeCredentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS.claude_local; const childHasClaudeCredential = adapterType === "claude_local" && existingEnv !== null && claudeCredentialKeys.some((key) => existingEnv[key] !== undefined); if (childHasClaudeCredential) return noInheritance; const nextEnv: Record = { ...(existingEnv ?? {}) }; let inheritedFixedClaudeOAuthBinding = false; let changed = false; for (const key of credentialKeys) { if (existingEnv && existingEnv[key] !== undefined) continue; const parentValue = parentEnv[key]; if (!isInheritableCredentialReference(parentValue)) continue; nextEnv[key] = { ...parentValue }; changed = true; if (key === "CLAUDE_CODE_OAUTH_TOKEN" && isFixedClaudeOAuthBinding(parentValue)) { inheritedFixedClaudeOAuthBinding = true; } } if (!changed) return noInheritance; return { adapterConfig: { ...adapterConfig, env: nextEnv }, inheritedFixedClaudeOAuthBinding, }; } function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, ): Record { const next = { ...adapterConfig }; if (adapterType === "paperclip_runner") { return normalizePaperclipRunnerAdapterConfig(adapterType, next); } if (adapterType === "codex_local") { 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); } if (adapterType === "kimi_local" && !asNonEmptyString(next.model)) { next.model = DEFAULT_KIMI_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); } if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { next.model = DEFAULT_OPENCODE_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); } 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 === "paperclip_runner") { await assertFreshPaperclipRunnerProvider(companyId, adapterType, adapterConfig); return; } if (adapterType !== "opencode_local") return; try { requireOpenCodeModelId(adapterConfig.model); } 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 materializeDefaultInstructionsBundleForNewAgent( agent: T, input?: { files: Record; entryFile?: string }, ): Promise { if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } const adapterConfig = asRecord(agent.adapterConfig) ?? {}; const hasExplicitInstructionsBundle = Boolean(asNonEmptyString(adapterConfig.instructionsBundleMode)) || Boolean(asNonEmptyString(adapterConfig.instructionsRootPath)) || Boolean(asNonEmptyString(adapterConfig.instructionsEntryFile)) || Boolean(asNonEmptyString(adapterConfig.instructionsFilePath)) || Boolean(asNonEmptyString(adapterConfig.agentsMdPath)); if (hasExplicitInstructionsBundle) { const nextAdapterConfig = { ...adapterConfig }; const hadLegacyPrompt = Object.prototype.hasOwnProperty.call(nextAdapterConfig, "promptTemplate") || Object.prototype.hasOwnProperty.call(nextAdapterConfig, "bootstrapPromptTemplate"); delete nextAdapterConfig.promptTemplate; delete nextAdapterConfig.bootstrapPromptTemplate; if (!hadLegacyPrompt) return agent; const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, { allowPendingApprovalConfigUpdate: true, }); return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } const files = input?.files ?? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role)); const materialized = await instructions.materializeManagedBundle( agent, files, { entryFile: input?.entryFile ?? "AGENTS.md", replaceExisting: false }, ); const nextAdapterConfig = { ...materialized.adapterConfig }; delete nextAdapterConfig.promptTemplate; delete nextAdapterConfig.bootstrapPromptTemplate; const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, { allowPendingApprovalConfigUpdate: true, }); return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } // Resolve the server-owned instruction bundle for the onboarding first agent. // The marker seeds the chief-of-staff persona (server/src/onboarding-assets/ // first-task/chief-of-staff/AGENTS.md, placeholders filled) over the agent's // entry file instead of the generic default. Honored only for board-authored // requests — the onboarding wizard runs as the board — so a client marker // alone cannot swap another actor's instructions. Runtime coordination is // supplied separately by the harness. async function resolveOnboardingFirstAgentBundle(params: { onboardingFirstAgent: unknown; actorType: string; agentName: string; organizationName: string | null; }): Promise<{ files: Record; entryFile: string } | undefined> { if (params.onboardingFirstAgent !== true) return undefined; if (params.actorType !== "board") return undefined; return buildOnboardingFirstAgentInstructionsBundle({ agentName: params.agentName, organizationName: params.organizationName, }); } function assertNoNewAgentLegacyPromptTemplate(adapterType: string, adapterConfig: Record) { if (!adapterSupportsInstructionsBundle(adapterType)) return; if ( Object.prototype.hasOwnProperty.call(adapterConfig, "promptTemplate") || Object.prototype.hasOwnProperty.call(adapterConfig, "bootstrapPromptTemplate") ) { throw unprocessable( "New agents must use instructionsBundle/AGENTS.md instead of adapterConfig.promptTemplate or bootstrapPromptTemplate", ); } } async function assertCanApplyProtectedAgentChange( req: Request, targetAgent: { id: string; companyId: string }, targetKeys: string[], ) { if (!hasCompanyAccess(req, targetAgent.companyId)) { throw notFound("Agent not found"); } assertCompanyAccess(req, targetAgent.companyId); const changeScope = { requiresChangeGrant: true }; const decision = await access.decide({ actor: req.actor, action: "agent_config:update", resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, scope: changeScope, }); if (decision.allowed) { return; } if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) { try { await changeConsentGateService(db).assertConsented({ companyId: targetAgent.companyId, actorAgentId: req.actor.agentId, actorRunId: req.actor.runId ?? null, targetKeys, }); } catch (err) { if (err instanceof HttpError && err.status === 403) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } throw err; } const consentedDecision = await access.decide({ actor: req.actor, action: "agent_config:update", resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, scope: { ...changeScope, consentedChange: true }, }); if (consentedDecision.allowed) { return; } throw forbidden(consentedDecision.explanation, authorizationDeniedDetails(consentedDecision)); } throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { await assertCanApplyProtectedAgentChange( req, targetAgent, [agentInstructionsChangeTargetKey(targetAgent.id)], ); } async function assertCanApplyAgentProfileChange( req: Request, targetAgent: { id: string; companyId: string }, ) { await assertCanApplyProtectedAgentChange( req, targetAgent, [agentProfileChangeTargetKey(targetAgent.id)], ); } async function assertCanResumeAgent( req: Request, targetAgent: { id: string; companyId: string }, ) { if (req.actor.type !== "agent") return; const decision = await access.decide({ actor: req.actor, action: "agent_config:update", resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, scope: { requiresChangeGrant: true }, }); if (decision.allowed) return; throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } function assertNoAgentInstructionsConfigMutation( req: Request, adapterConfig: Record | null | undefined, path = "adapterConfig", ) { if (req.actor.type !== "agent" || !adapterConfig) return; const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS .filter((key) => adapterConfig[key] !== undefined) .map((key) => `${path}.${key}`); if (changedSensitiveKeys.length === 0) return; throw forbidden( `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, ); } function assertExternalInstructionsAdmin( req: Request, agent: Parameters[0], ) { if (agentInstructionsBundleMode(agent) === "external") { assertInstanceAdmin(req); } } function adapterConfigTouchesInstructionsConfig(adapterConfig: Record) { return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined); } function assertNoAgentAdapterConfigMutation( req: Request, adapterConfig: Record, path = "adapterConfig", ) { assertNoAgentInstructionsConfigMutation(req, adapterConfig, path); assertNoAgentHostWorkspaceCommandMutation( req, collectAgentAdapterWorkspaceCommandPaths(adapterConfig, path), ); } function assertNoAgentProcessAdapterMutation(req: Request, adapterType: string, mutatesConfiguration: boolean) { if (req.actor.type !== "agent" || adapterType !== "process" || !mutatesConfiguration) return; throw forbidden("Agent keys cannot configure host-executed process adapters."); } const LOCAL_ADAPTER_HOST_COMMAND_KEYS = [ "command", "hermesCommand", "args", "extraArgs", "cwd", "env", "filesystemSandboxCommand", ] as const; const LOCAL_ADAPTER_CREDENTIAL_ENV_KEYS: Record = { ...INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS, gemini_local: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], kimi_local: ["KIMI_MODEL_API_KEY"], opencode_local: ["PAPERCLIP_OPENCODE_PROVIDERS", "OPENCODE_AUTH_JSON"], }; const POOL_AUTH_OVERRIDE_ENV_KEYS = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "PAPERCLIP_OPENCODE_PROVIDERS", "OPENCODE_AUTH_JSON", ] as const; async function callerUsesAiConnectionPool(req: Request, companyId: string): Promise { if (req.actor.type !== "agent" || !req.actor.agentId) return false; const caller = await svc.getById(req.actor.agentId); if (!caller || caller.companyId !== companyId) return false; return asRecord(asRecord(caller.runtimeConfig)?.aiConnection)?.mode === "router"; } function containsOnlyLocalAdapterCredentialRefs( adapterType: string, env: unknown, allowPlainCredentials: boolean, ): boolean { const envRecord = asRecord(env); if (!envRecord) return false; const allowedKeys = LOCAL_ADAPTER_CREDENTIAL_ENV_KEYS[adapterType] ?? []; return Object.entries(envRecord).every(([key, value]) => (allowedKeys.includes(key) && isInheritableCredentialReference(value)) || (allowPlainCredentials && POOL_AUTH_OVERRIDE_ENV_KEYS.includes(key as typeof POOL_AUTH_OVERRIDE_ENV_KEYS[number]) && asEnvBindingString(value) !== null), ); } function assertNoAgentLocalAdapterHostCommandMutation( req: Request, adapterType: string, adapterConfig: Record, allowPlainCredentials = false, ) { if (req.actor.type !== "agent" || !adapterType.endsWith("_local")) return; const changedKeys = LOCAL_ADAPTER_HOST_COMMAND_KEYS.filter((key) => adapterConfig[key] !== undefined && (key !== "env" || !containsOnlyLocalAdapterCredentialRefs(adapterType, adapterConfig.env, allowPlainCredentials)), ); if (changedKeys.length === 0) return; throw forbidden( `Agent keys cannot configure host-executed local adapter settings (${changedKeys.join(", ")}).`, ); } 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 buildUnsupportedSkillSnapshot( adapterType: string, desiredSkillEntries: AgentDesiredSkillEntry[] = [], ): AgentSkillSnapshot { const desiredSkills = desiredSkillEntries.map((entry) => entry.key); return { adapterType, supported: false, mode: "unsupported", desiredSkills, desiredSkillEntries, entries: [], warnings: ["This adapter does not implement skill sync yet."], }; } // CEO and board-created onboarding chief-of-staff instructions assume the // core paperclip skills (board coordination, planning, hiring, memory). // Union them into these skills-capable hires/creates so their desired skills // match their instructions. Optional role // skills remain removable afterwards. Legacy adapters separately guarantee // the Paperclip operational skill as a runtime invariant. function defaultRoleSkillSelections( role: string | null | undefined, adapterType: string, boardOnboardingFirstAgent = false, ): AgentDesiredSkillEntry[] | undefined { if (role !== "ceo" && !boardOnboardingFirstAgent) return undefined; const adapter = findActiveServerAdapter(adapterType); if (!adapter?.listSkills && !adapter?.syncSkills) return undefined; const keys = boardOnboardingFirstAgent ? [...PAPERCLIP_CORE_SKILL_KEYS, ONBOARDING_FIRST_TASK_SKILL_KEY] : PAPERCLIP_CORE_SKILL_KEYS; return keys .filter((key) => adapterType !== "paperclip_runner" || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY) .map((key) => ({ key, versionId: null })); } function withDefaultRoleSkillSelections( requested: AgentDesiredSkillEntry[] | undefined, defaults: AgentDesiredSkillEntry[] | undefined, ): AgentDesiredSkillEntry[] | undefined { if (!defaults) return requested; if (!requested) return defaults; // Resolve explicit selections first: aliases can normalize to a default // key later, and the skill resolver keeps the first version selection. const merged = new Map(requested.map((entry) => [entry.key, entry])); for (const entry of defaults) { if (!merged.has(entry.key)) merged.set(entry.key, entry); } return Array.from(merged.values()); } function normalizeDesiredSkillSelections( requestedDesiredSkills: Array | undefined, ): AgentDesiredSkillEntry[] | undefined { if (!requestedDesiredSkills) return undefined; const out = new Map(); for (const value of requestedDesiredSkills) { const entry = typeof value === "string" ? { key: value.trim(), versionId: null } : { key: value.key.trim(), versionId: value.versionId ?? null }; if (!entry.key || out.has(entry.key)) continue; out.set(entry.key, entry); } return Array.from(out.values()); } // Legacy hardcoded set — used as fallback when adapter module does not // declare requiresMaterializedRuntimeSkills explicitly. const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", "pi_local", ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { const adapter = findActiveServerAdapter(adapterType); if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { return adapter.requiresMaterializedRuntimeSkills; } return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( companyId: string, adapterType: string, config: Record, options: { materializeMissing?: boolean; } = {}, ) { const preference = readPaperclipSkillSyncPreference(config); const betaSkillsEnabled = (await instanceSettings.getExperimental()).enableBetaSkills === true; const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: options.materializeMissing ?? shouldMaterializeRuntimeSkillsForAdapter(adapterType), versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries, { versionPinsEnabled: betaSkillsEnabled, }), }); return { ...config, paperclipRuntimeSkills: runtimeSkillEntries, }; } async function resolveDesiredSkillAssignment( companyId: string, adapterType: string, adapterConfig: Record, requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined, mode: AgentSkillAssignmentMode, options: { tolerateUnknownDesiredSkills?: boolean } = {}, ) { if (!requestedDesiredSkills) { return { adapterConfig, desiredSkills: null as string[] | null, desiredSkillEntries: null as AgentDesiredSkillEntry[] | null, runtimeSkillEntries: null as Awaited> | null, }; } if (requestedDesiredSkills.some((entry) => entry.versionId !== null)) { const betaSkillsEnabled = (await instanceSettings.getExperimental()).enableBetaSkills === true; if (!betaSkillsEnabled) { throw badRequest("Beta skill version pins require the Beta skills experimental setting to be enabled."); } } const { resolved: resolvedRequestedSkillEntries, unresolved: unresolvedDesiredSkillKeys } = await companySkills.resolveRequestedSkillEntries(companyId, requestedDesiredSkills, { tolerateUnknownReferences: options.tolerateUnknownDesiredSkills, }); const requestedSkillEntries = [ ...resolvedRequestedSkillEntries, ...unresolvedDesiredSkillKeys.map((key) => ({ key, versionId: null })), ].filter( (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, ); const currentPreference = readPaperclipSkillSyncPreference(adapterConfig); const { resolved: resolvedCurrentSkillEntries, unresolved: unresolvedCurrentSkillKeys } = currentPreference.desiredSkillEntries.length > 0 ? await companySkills.resolveRequestedSkillEntries( companyId, currentPreference.desiredSkillEntries, { tolerateUnknownReferences: true }, ) : { resolved: [], unresolved: [] }; const currentSkillEntries = [ ...resolvedCurrentSkillEntries, ...unresolvedCurrentSkillKeys.map((key) => ({ key, versionId: null })), ].filter( (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, ); const desiredSkillEntries = mergeDesiredSkillEntries( currentSkillEntries, requestedSkillEntries, mode, ).filter( (entry) => !isConnectorSkill(entry.key) && (adapterType !== "paperclip_runner" || entry.key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY), ); const desiredSkills = desiredSkillEntries.map((entry) => entry.key); const resolvedKeys = new Set([ ...resolvedCurrentSkillEntries.map((entry) => entry.key), ...resolvedRequestedSkillEntries.map((entry) => entry.key), ]); // Runtime materialization + version selection only ever consider final // assignments that resolve to the company library; stale keys remain // persisted and explicitly removable without reaching adapter runtimes. const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), versionSelections: skillVersionSelectionMap( desiredSkillEntries.filter((entry) => resolvedKeys.has(entry.key)), ), }); return { adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries), desiredSkills, desiredSkillEntries, runtimeSkillEntries, }; } function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { ...agent, adapterConfig: {}, runtimeConfig: {}, }; } // Single presenter for every response that emits a raw agent row. Restricted // views blank the config wholesale for authorization reasons; this runs for // config-reading (board) callers too, so plaintext `adapterConfig.env` values // never leave the API regardless of actor scope. function redactAgentRowForResponse( agent: T, ): T { if (!agent || typeof agent !== "object") return agent; if (!agent.adapterConfig || typeof agent.adapterConfig !== "object") return agent; return { ...agent, adapterConfig: redactAgentAdapterConfig(agent.adapterConfig as Record), }; } 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: redactAgentAdapterConfig(agent.adapterConfig), runtimeConfig: redactEventPayload(agent.runtimeConfig), permissions: agent.permissions, updatedAt: agent.updatedAt, }; } function restoreRedactedAgentEnv( requestedConfig: Record, existingConfig: Record, ): Record { const requestedEnv = asRecord(requestedConfig.env); const existingEnv = asRecord(existingConfig.env); if (!requestedEnv || !existingEnv) return requestedConfig; const restoredEnv = { ...requestedEnv }; for (const [key, value] of Object.entries(requestedEnv)) { const binding = asRecord(value); if ( binding?.type === "plain" && binding.value === REDACTED_EVENT_VALUE && Object.prototype.hasOwnProperty.call(existingEnv, key) ) { restoredEnv[key] = existingEnv[key]; } } return { ...requestedConfig, env: restoredEnv }; } function redactRevisionSnapshot(snapshot: unknown): Record { if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {}; const record = snapshot as Record; return { ...record, adapterConfig: redactAgentAdapterConfig( 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 = assertKnownAdapterType(req.params.type as string); const poolId = asNonEmptyString(req.query.poolId); if (poolId) { const pool = (await aiConnectionRouterService(db).selectable(companyId, getActorInfo(req).actorId)).find(pool => pool.id === poolId); if (!pool) throw unprocessable("Enable an accessible AI connection pool"); const catalog = new Map(); for (const member of pool.members) { const memberAdapter = member.binding.provider === "openai" ? "codex_local" : member.binding.provider === "anthropic" ? "claude_local" : member.binding.provider === "xai" ? "grok_local" : "opencode_local"; const models = [{ id: member.profile.model, label: member.profile.model }, ...await listAdapterModels(memberAdapter)]; for (const model of models) { try { poolMemberRuntimeConfig({ ...member, profile: { ...member.profile, model: model.id } }, type); catalog.set(model.id, model); } catch { /* Only qualified profiles belong in the pool catalog. */ } } } res.json([...catalog.values()]); return; } const refresh = typeof req.query.refresh === "string" ? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase()) : false; const environmentId = asNonEmptyString(req.query.environmentId); const environment = environmentId ? await environmentsSvc.getById(environmentId) : null; if (environmentId && !environment) { res.status(404).json({ error: "Environment not found" }); return; } const provider = asNonEmptyString(req.query.provider); if (type === "opencode_local" && provider === "openrouter") { res.json(await listOpenRouterModels(refresh)); return; } if (type === "paperclip_runner" && provider && !isPaperclipRunnerProvider(provider)) { throw unprocessable("Unknown Paperclip Runner provider"); } const modelAdapterType = type === "paperclip_runner" ? provider === "acpx" || provider === "claude_managed" ? "claude_local" : provider === "opencode" ? "opencode_local" : provider === "aws_agentcore" ? type : "codex_local" : type; if (modelAdapterType === "opencode_local" && environment && environment.driver !== "local") { res.json(requireServerAdapter(modelAdapterType).models ?? []); return; } const models = refresh ? await refreshAdapterModels(modelAdapterType) : await listAdapterModels(modelAdapterType); res.json(models); }); router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const type = assertKnownAdapterType(req.params.type as string); const detected = await detectAdapterModel(type); res.json(detected); }); // The environment drivers the adapter Test route accepts. A local, SSH, or // sandbox environment can host a probe; a plugin environment cannot. const ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox"]; // The fail-closed tenant-binding guard for the adapter Test route. A caller // may name any instance environment by id, so the route must reject an // environment that binds to another company before it resolves secrets, // merges env, resolves the target, leases a sandbox, or runs the adapter // test. The guard checks the company binding BEFORE it validates the status // or the driver, so it never reveals the status or the driver of a foreign // environment. A same-company or an instance-global environment then gets the // shared driver and status validation. async function assertAdapterTestEnvironmentForCompany( companyId: string, environmentId: string, ): Promise { const environment = await environmentsSvc.getById(environmentId); if (!environment) { // A missing environment leaks no tenant state. The execution-context // resolver surfaces the existing environment_not_found check. return; } const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId); if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) { throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch", }); } await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { allowedDrivers: ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, }); } async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>, agentId?: string) { const startedAt = new Date(); const result = await probeManagedEnvironment(adapterType, context, binding); // A provider rejection invalidates the tested credential generation. A // missing CLI, unavailable environment, or other runtime error does not. if (result.status === "fail" && result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code) || check.code === "ai_connection_api_key_rejected", )) { await aiConnectionService(db).markAuthenticationFailed({ companyId: context.companyId, agentId, runStartedAt: startedAt, attribution: { ...managed.attribution, identity: managed.identity }, }); } return result; } async function probeManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding) { await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget); const result = await requireServerAdapter(adapterType).testEnvironment(context); if (result.status === "fail") return result; // The resolved method, not binding.method — on a responsible_user binding // that field is wire-compat only and the default connection decides. const resolvedMethod = (context.config as { managedAiConnection?: { method?: string } }).managedAiConnection?.method; // An api_key account does not take the CLI hello probe below: a key // travels as an env var any engine understands, and the engine's own test // above judged whether this runtime can execute with it — the ACP lane // deliberately runs no hello probe when a key is configured. Demanding one // anyway forced the CLI lane, whose probe needs a provider CLI on PATH, // and a clean install has none: that walled off onboarding's API-key path // on exactly the machines the release smoke exists to guard. The account // is still verified live here — the same provider-endpoint check the save // performed — so a key revoked since its save fails adoption rather than // producing an agent that cannot authenticate at runtime. The hello-probe // requirement stays for subscriptions: a stored login is a file layout // only a provider CLI reads, so proving the runtime lane can consume it // takes a real hello turn. if (resolvedMethod === "api_key") { const envKey = AI_CONNECTION_CAPABILITIES[binding.provider].methods.api_key?.envKey; const key = envKey ? parseObject(context.config.env)[envKey] : undefined; try { if (typeof key !== "string" || !key) throw unprocessable("The selected account's API key was not available to verify."); await validateAiApiKey(binding.provider, key); result.checks.push({ code: "ai_connection_api_key_reverified", level: "info", message: "The provider verified this API key for adoption." }); } catch (error) { result.status = "fail"; const rejected = error instanceof HttpError && asRecord(error.details)?.code === "ai_connection_api_key_rejected"; result.checks.push({ code: rejected ? "ai_connection_api_key_rejected" : "ai_connection_verification_failed", level: "error", message: error instanceof HttpError ? error.message : "Could not verify the account. Try again." }); } return result; } if (!result.checks.some(check => check.code.includes("hello_probe"))) { const providerAdapter = { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local" }[binding.provider]; const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } }); result.checks.push(...probe.checks); result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass"; } if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) { result.status = "fail"; result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." }); } return result; } async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) { if (binding.mode === "router") { const settings = await import("../services/instance-settings.js"); if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Ask your instance operator to enable AI connection routing."); const pool = (await aiConnectionRouterService(db).selectable(companyId, responsibleUserForAiRequest(req) ?? "")).find((p) => p.id === binding.connectionId && p.enabled); if (!pool) throw unprocessable("Enable the selected connection pool"); const userId = responsibleUserForAiRequest(req); const memberConnectionIds = new Set(); let compatible = false; for (const member of pool.members) { let runtime: ReturnType; try { runtime = poolMemberRuntimeConfig(member, adapterType); } catch { continue; } compatible = true; const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, member.binding); try { const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, binding: member.binding, model: runtime.config.model, runnerProvider: runtime.config.provider, acpxAgent: runtime.config.acpxAgent, allowUninstalledPersonal: newAgent, allowUninstalledShared }); memberConnectionIds.add(selection.connection.id); } catch (error) { if (!(error instanceof HttpError && [403, 422].includes(error.status))) throw error; } } if (!compatible) throw unprocessable("This pool has no compatible harness"); if (!memberConnectionIds.size) throw unprocessable("Install or repair a compatible pool member for this agent.", { code: "ai_connection_pool_no_eligible_member" }); return { connectionId: pool.id, ...(newAgent ? { memberConnectionIds: [...memberConnectionIds] } : {}) }; } const userId = responsibleUserForAiRequest(req); const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding); const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, model: config.model, runnerProvider: config.provider, acpxAgent: config.acpxAgent, binding, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: test }).catch((error: unknown) => { // Hiring is allowed before the responsible user has connected this // provider. Execution still resolves credentials and creates the normal // task connection request; compatibility and access denials stay errors. if (newAgent && !test && binding.mode === "responsible_user" && error instanceof HttpError && ["ai_connection_default_missing", "ai_connection_missing", "ai_connection_unavailable", "ai_connection_responsible_user_missing"].includes(String(asRecord(error.details)?.code))) { return null; } throw error; }); if (!selection) return undefined; if (test) { const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId); if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId); const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: testEnvironmentId }); try { if (!target.executionTarget && target.fallbackChecks.length > 0) throw unprocessable("The agent environment is not available for adoption"); await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true }, async managed => { const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding, managed, agentId); if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment. Run the agent test to see the failing checks.", { code: "ai_connection_validation_failed", checks: result.checks.filter(check => check.level === "error" || check.code === ADAPTER_AUTH_MISSING_CHECK_CODE).map(check => ({ code: check.code, level: check.level })), }); if (selection.connection.config.aiLegacyAdoption === true) await db.update(toolConnections).set({ healthStatus: "ok", config: { ...selection.connection.config, aiLegacyAdoption: false }, updatedAt: new Date() }).where(eq(toolConnections.id, selection.connection.id)); }); } finally { await target.release("released"); } } return { connectionId: selection.connection.id }; } router.post( "/companies/:companyId/adapters/:type/test-environment", validate(testAdapterEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; const type = assertKnownAdapterType(req.params.type as string); await assertCanCreateAgentsForCompany(req, companyId); const adapter = requireServerAdapter(type); const requestedBinding = req.body.aiConnection ? aiRuntimeConnectionBindingSchema.parse(req.body.aiConnection) : undefined; const aiBinding = requestedBinding?.mode === "router" ? undefined : requestedBinding; if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials"); const inputAdapterConfig = aiBinding ? { ...req.body.adapterConfig, env: stripAiAuthBindings(req.body.adapterConfig?.env) } : (req.body?.adapterConfig ?? {}) as Record; const savedAgentId = typeof req.body.agentId === "string" ? req.body.agentId : null; const savedAgent = savedAgentId ? await getAccessibleResource(req, res, svc.getById(savedAgentId), "Agent not found") : null; if (savedAgentId) { if (!savedAgent) return; if (savedAgent.companyId !== companyId) throw notFound("Agent not found"); await assertCanUpdateAgent(req, savedAgent); } const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined); if (poolBinding?.mode === "router") { await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false, !savedAgentId); res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] }); return; } const requestedEnvironmentId = await resolveAdapterTestEnvironmentId( companyId, // Omission tests the saved selection. Explicit null tests a prospective // change back to the instance default. req.body.environmentId === undefined ? savedAgent?.defaultEnvironmentId : asNonEmptyString(req.body.environmentId), ); // Fail closed on a foreign environment before any secret resolution, env // merge, target resolution, sandbox lease, or adapter test runs. if (requestedEnvironmentId) { await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironmentId); } // Agent reads redact every plain environment value. When this is a saved // agent test, restore those display-only placeholders from the // server-side config before validating or resolving secrets; otherwise // the probe treats "***REDACTED***" as a value to persist. let adapterConfigForTest = inputAdapterConfig; if (savedAgent) { const providerAdapter = savedAgent.adapterType === "paperclip_runner" ? inputAdapterConfig.provider === "codex" ? "codex_local" : inputAdapterConfig.provider === "acpx" && inputAdapterConfig.acpxAgent === "claude" ? "claude_local" : null : null; const canRestoreEnv = savedAgent.adapterType === type || providerAdapter === type; // Permit testing a prospective adapter switch, but do not transfer // hidden values from the saved adapter into an unrelated harness. if (!canRestoreEnv && Object.values(parseObject(inputAdapterConfig.env)).some(value => { const binding = asRecord(value); return binding?.type === "plain" && binding.value === REDACTED_EVENT_VALUE; })) { throw unprocessable("Re-enter environment values when testing a different adapter"); } adapterConfigForTest = canRestoreEnv ? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig) : inputAdapterConfig; } const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, adapterConfigForTest, { strictMode: strictSecretsMode, adapterType: type }, ); // Prospective, non-persisted config: resolve the acting user's own user // secrets in owner_scoped mode (no declaration rows exist for this config). // Record an honest audit consumer — environment: when the caller selected // one, otherwise system:adapter_test — never a fake agent consumer. const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, buildActorSecretContext( req, requestedEnvironmentId ? { consumerType: "environment", consumerId: requestedEnvironmentId } : { consumerType: "system", consumerId: "adapter_test" }, ), { adapterType: type, userSecretMediation: "owner_scoped" }, ); const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, adapterType: type, environmentId: requestedEnvironmentId, }); let releaseStatus: "released" | "failed" = "released"; try { // Mirror the run path (resolveExecutionRunAdapterConfig): the selected // environment's envVars are the base env layer and the agent's // adapterConfig.env wins on key conflicts. Without this merge the probe // cannot see environment-level auth (e.g. CLAUDE_CODE_OAUTH_TOKEN) that // real runs receive. const environmentEnvChecks: AdapterEnvironmentCheck[] = []; let effectiveAdapterConfig = runtimeAdapterConfig; if (requestedEnvironmentId) { const selectedEnvironment = await environmentsSvc.getById(requestedEnvironmentId); const environmentEnv = Object.fromEntries( Object.entries(aiBinding ? stripAiAuthBindings(selectedEnvironment?.envVars) : parseObject(selectedEnvironment?.envVars)).filter( ([key]) => !isForbiddenConfigEnvKey(key), ), ); if (Object.keys(environmentEnv).length > 0) { const environmentSecretContext = buildActorSecretContext(req, { consumerType: "environment", consumerId: requestedEnvironmentId, }); const missingBindings = typeof secretsSvc.collectMissingRuntimeBindings === "function" ? await secretsSvc.collectMissingRuntimeBindings( companyId, environmentEnv, environmentSecretContext, ) : []; const missingKeys = new Set(missingBindings.map((binding) => binding.envKey)); if (missingKeys.size > 0) { environmentEnvChecks.push({ code: "environment_env_binding_missing", level: "error", message: `Environment variables with missing secret bindings were skipped: ${[...missingKeys].join(", ")}.`, hint: "Re-save the environment's variables to restore the secret binding, then test again.", }); } const resolvableEnvironmentEnv = Object.fromEntries( Object.entries(environmentEnv).filter(([key]) => !missingKeys.has(key)), ); const environmentEnvResolution = await secretsSvc.resolveEnvBindings( companyId, resolvableEnvironmentEnv, environmentSecretContext, ); if (Object.keys(environmentEnvResolution.env).length > 0) { effectiveAdapterConfig = { ...runtimeAdapterConfig, env: { ...environmentEnvResolution.env, ...parseObject(runtimeAdapterConfig.env), }, }; } } } // If the caller explicitly selected an environment, never fall back to // probing the host when we couldn't resolve that environment's // execution target. Surface the diagnostic checks instead. if (requestedEnvironmentId && !executionTarget && fallbackChecks.length > 0) { const combinedChecks = [...fallbackChecks, ...environmentEnvChecks]; const status: AdapterEnvironmentTestResult["status"] = combinedChecks.some((c) => c.level === "error") ? "fail" : combinedChecks.some((c) => c.level === "warn") ? "warn" : "pass"; if (status === "fail") releaseStatus = "failed"; const synthesized: AdapterEnvironmentTestResult = { adapterType: type, status, checks: combinedChecks, testedAt: new Date().toISOString(), }; res.json(synthesized); return; } // Probe-only credentials bypass storage, not authorization. The schema // allows only provider key names; they never enter persisted config. if (req.body.testCredentials) { effectiveAdapterConfig = { ...effectiveAdapterConfig, env: { ...parseObject(effectiveAdapterConfig.env), ...req.body.testCredentials }, }; // Hermes authenticates the gateway itself through a top-level field. // Keep its draft key out of persistence normalization, like env keys. if (type === "hermes_gateway" && req.body.testCredentials.API_SERVER_KEY) { effectiveAdapterConfig.apiKey = req.body.testCredentials.API_SERVER_KEY; } } const result = aiBinding ? await withManagedAiProbe(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }, async managed => { const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding, managed, savedAgentId ?? undefined); tested.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` }); return tested; }) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName }); const prefixChecks = [ ...(sandboxIdentityCheck ? [sandboxIdentityCheck] : []), ...environmentEnvChecks, ]; // A missing environment secret binding blocks real dispatch // (ConfigurationIncompleteFailure in the heartbeat), so the test // reports fail even when the adapter probe itself passed. const status = environmentEnvChecks.some((c) => c.level === "error") ? "fail" : result.status; if (status === "fail") releaseStatus = "failed"; res.json({ ...result, status, checks: prefixChecks.length > 0 ? [...prefixChecks, ...result.checks] : result.checks, }); } catch (err) { releaseStatus = "failed"; throw err; } finally { await release(releaseStatus); } }, ); // The claude_local branch of the auth-signal read. It checks two host-local // sources for a usable Claude Code OAuth token: the resolved envVars of the // caller's selected environment, and the caller's own stored Claude login. It // returns "present" the moment either source holds a non-empty token, so it // never resolves more than the one env key it needs. async function evaluateClaudeAuthSignal( req: Request, companyId: string, environmentId: string | null, ): Promise { if (environmentId) { const environment = await environmentsSvc.getById(environmentId); const environmentEnv = Object.fromEntries( Object.entries(parseObject(environment?.envVars)).filter( ([key]) => !isForbiddenConfigEnvKey(key), ), ); const tokenBinding = environmentEnv.CLAUDE_CODE_OAUTH_TOKEN; if (tokenBinding !== undefined) { const resolution = await secretsSvc.resolveEnvBindings( companyId, { CLAUDE_CODE_OAUTH_TOKEN: tokenBinding }, buildActorSecretContext(req, { consumerType: "environment", consumerId: environmentId }), ); if (asNonEmptyString(resolution.env.CLAUDE_CODE_OAUTH_TOKEN)) { return "present"; } } } const ownerUserId = req.actor.userId; if (ownerUserId) { const stored = await secretsSvc.readClaudeOAuthUserSecretStatus(companyId, ownerUserId); if (stored) return "present"; } return "absent"; } // The codex_local branch of the auth-signal read. The host filesystem check // (`evaluateCodexCredentialReadiness` against `process.env`) describes only // the Paperclip host, so it is authoritative for the null-environment and // "local" driver cases, where the host is the execution target. For a // non-local environment (a sandbox), the host's own credential state says // nothing about that sandbox, so the route checks the environment's own // OPENAI_API_KEY binding instead and otherwise reports "unknown" -- never // "present" from a host login the sandbox does not share. async function evaluateCodexAuthSignal( req: Request, companyId: string, environmentId: string | null, ): Promise { if (environmentId) { const environment = await environmentsSvc.getById(environmentId); if (environment && environment.driver !== "local") { const environmentEnv = Object.fromEntries( Object.entries(parseObject(environment.envVars)).filter( ([key]) => !isForbiddenConfigEnvKey(key), ), ); const apiKeyBinding = environmentEnv.OPENAI_API_KEY; if (apiKeyBinding !== undefined) { const resolution = await secretsSvc.resolveEnvBindings( companyId, { OPENAI_API_KEY: apiKeyBinding }, buildActorSecretContext(req, { consumerType: "environment", consumerId: environmentId }), ); if (asNonEmptyString(resolution.env.OPENAI_API_KEY)) { return "present"; } } return "unknown"; } } const readiness = await evaluateCodexCredentialReadiness({ env: process.env, companyId, configuredCodexHome: null, configuredApiKey: null, }); return readiness.ready ? "present" : "absent"; } // The cheap host-local authentication signal for one adapter type. The route // reads host-local state only: a stored Claude login, a resolved environment // env var, or the local Codex credential readiness check. It leases no // sandbox, starts no shell command, and starts no model request. The two // access gates below run before any read, so a caller who cannot create // agents for the company and a foreign environment both fail closed before // the route touches a credential source. router.get( "/companies/:companyId/adapters/:type/auth-signal", async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; await assertCanCreateAgentsForCompany(req, companyId); const environmentId = asNonEmptyString(req.query.environmentId); if (environmentId) { await assertAdapterTestEnvironmentForCompany(companyId, environmentId); } res.setHeader("Cache-Control", "no-store"); let status: AdapterAuthSignal = "unknown"; try { if (type === "claude_local") { status = await evaluateClaudeAuthSignal(req, companyId, environmentId); } else if (type === "codex_local") { status = await evaluateCodexAuthSignal(req, companyId, environmentId); } } catch { // A failed read is never a claim that the credential is absent. Report // the neutral "unknown" signal instead, so the wizard falls back to // showing the login panel. status = "unknown"; } const body: AdapterAuthSignalResponse = { status }; res.json(body); }, ); // Start a company-scoped adapter device login. The create form has no agent // identifier, so the route keys on the company and the adapter. The owner // helper requires a board actor with the configuration permission, and it // returns the immutable owner identifier that the service persists on the row. router.post( "/companies/:companyId/adapters/:type/login-sessions", async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; // The shared start-route spine derives the owner, checks the path adapter // type, validates the strict request schema, and checks the sandbox // environment before any session or lease side effect. The client body // carries no adapter type, so the spine injects the path type into the // parse. The strict schema rejects an unknown field, a non-uuid // environment id, and an out-of-range time-to-live with a fixed 400. const resolved = await runAdapterLoginStartSpine({ req, res, deriveOwner: () => assertCanManageAdapterLogin(req, companyId), guardBeforeValidate: () => assertDeviceLoginAdapter(type), requestSchema: startAdapterAuthSessionRequestSchema, invalidRequestError: "The device login start request is invalid.", requestOverrides: { adapterType: type }, assertSandbox: async (data) => { // The device login runs on a real pseudo-terminal, so it needs a // provider that advertises the login pseudo-terminal capability. Gate // the route on the current provider capability before any session or // lease state. The lease-acquisition path re-checks it from current // runtime state before the provider lease. await assertSandboxLoginEnvironment(companyId, data.environmentId); await assertCodexLoginProviderCapability(data.environmentId); }, }); if (!resolved) return; const { ownerUserId: startedByUserId, data } = resolved; if (data.aiConnection) { await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection); if (!isAiConnectionCompatible(data.aiConnection, type)) throw unprocessable("Incompatible login method"); } const controller = new AbortController(); let result: Awaited>; try { result = await adapterLoginService.start({ companyId, environmentId: data.environmentId, adapterType: type, startedByUserId, ttlSeconds: data.ttlSeconds, aiConnection: data.aiConnection, signal: controller.signal, }); } catch (error) { // A second active login for the same company and adapter loses the // credential slot. Map the service conflict to a 409 response. if (error instanceof AdapterAuthSessionConflictError) { throw conflict(error.message); } throw error; } // Keep the controller so the cancel route can abort the in-flight run. // Drop it when the run ends. The completion runs the terminal handling in // the background; the response returns the initial session at once. const startedSessionId = result.session.sessionId; adapterLoginAbortControllers.set(startedSessionId, controller); void result.completed .catch(() => {}) .finally(() => { adapterLoginAbortControllers.delete(startedSessionId); }); res.status(201).json(result.session); }, ); // Read the caller's active login session for one company and adapter, with no // session id. The browser rediscovers its own session after a reload with no // local state. A non-owner, a foreign company, an unknown adapter, and no // active session all receive the same 404. // // This route registers before the `:sessionId` route below, so Express // never matches the literal `active` segment as a session id. router.get( "/companies/:companyId/adapters/:type/login-sessions/active", async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; const ownerUserId = await assertCanManageAdapterLogin(req, companyId); assertDeviceLoginAdapter(type); res.setHeader("Cache-Control", "no-store, private"); const owner = await adapterLoginService.readActiveOwnerSession(companyId, type, ownerUserId); if (!owner) { res.status(404).json({ error: "Adapter login session not found" }); return; } res.json(owner); }, ); // Read a login session. The owner receives the status and the one-time prompt. // A non-owner or a cross-company caller receives a 404. The owner response // repeats the live prompt on every read while the session holds an active // public status, so this sets the same private no-store policy as the // active-session route above: a shared or a browser cache never stores the // authenticated response between one poll and the next. router.get( "/companies/:companyId/adapters/:type/login-sessions/:sessionId", async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; const sessionId = req.params.sessionId as string; const ownerUserId = await assertCanManageAdapterLogin(req, companyId); assertDeviceLoginAdapter(type); res.setHeader("Cache-Control", "no-store, private"); const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId); if (!owner) { res.status(404).json({ error: "Adapter login session not found" }); return; } res.json(owner); }, ); // Cancel a login session. The owner aborts the in-flight run. A non-owner or a // cross-company caller receives a 404. router.post( "/companies/:companyId/adapters/:type/login-sessions/:sessionId/cancel", async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; const sessionId = req.params.sessionId as string; const ownerUserId = await assertCanManageAdapterLogin(req, companyId); assertDeviceLoginAdapter(type); // Scope the cancel to this company, adapter, and owner. A non-owner and a // cross-company caller both receive a 404 and cannot cancel a session. const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId); if (!owner) { res.status(404).json({ error: "Adapter login session not found" }); return; } // Durably release the company slot. The durable write terminates the row // even when this process does not own the in-flight run, so a cross-process // cancel or a cancel after a restart does not leave the slot held until the // expiry. The reaper deletes the sandbox and finalizes the terminal. const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, companyId, ownerUserId); // Abort the in-flight run this process owns, so the local login stops at // once instead of waiting for the reaper. A run in another process, or an // already-terminal run, has no controller here. adapterLoginAbortControllers.get(sessionId)?.abort(); res.json(cancelled ?? owner); }, ); router.get("/agents/:id/skills", 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 adapter = findActiveServerAdapter(agent.adapterType); if (!adapter?.listSkills) { const preference = readPaperclipSkillSyncPreference( agent.adapterConfig as Record, ); const desiredSkillEntries = preference.desiredSkillEntries.filter( (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, ); res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries)); return; } const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( agent.companyId, agent.adapterConfig, buildActorSecretContext(req, { consumerType: "agent", consumerId: agent.id }), { adapterType: agent.adapterType, skipUserSecrets: true }, ); const runtimeSkillConfig = await buildRuntimeSkillConfig( agent.companyId, agent.adapterType, runtimeConfig, { materializeMissing: false }, ); const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id }); const connectorConfig = await applyConnectorSkills(runtimeSkillConfig, runtimeSkillConfig.paperclipRuntimeSkills, connectorAssignments); const snapshot = await adapter.listSkills({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, config: connectorConfig, }); res.json(annotateConnectorSkills(snapshot, connectorAssignments)); }); router.post( "/agents/:id/skills/sync", requireAgentSkillAssignmentMode, validate(agentSkillSyncSchema), async (req, res) => { const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; await assertCanUpdateAgent(req, agent); const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills); const { adapterConfig: nextAdapterConfig, desiredSkills, desiredSkillEntries, runtimeSkillEntries, } = await resolveDesiredSkillAssignment( agent.companyId, agent.adapterType, agent.adapterConfig as Record, requestedSkills, req.body.mode, // Toggling a resolvable skill must not fail just because the agent // already carries stale desired keys (e.g. a skill removed from the // library). Preserve those keys so they remain visible/removable. { tolerateUnknownDesiredSkills: true }, ); if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) { throw unprocessable("Skill sync requires desiredSkills."); } const actor = getActorInfo(req); const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig, }, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "skill-sync", }, }); if (!updated) { res.status(404).json({ error: "Agent not found" }); return; } const adapter = findActiveServerAdapter(updated.adapterType); const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( updated.companyId, updated.adapterConfig, buildActorSecretContext(req, { consumerType: "agent", consumerId: updated.id }), { adapterType: updated.adapterType, skipUserSecrets: true }, ); const connectorAssignments = await resolveConnectorAssignments(db, { companyId: updated.companyId, agentId: updated.id }); const runtimeSkillConfig = await applyConnectorSkills(runtimeConfig, runtimeSkillEntries, connectorAssignments); const manualSkillConfig = await applyConnectorSkills(runtimeConfig, runtimeSkillEntries, []); let snapshot = adapter?.syncSkills ? await adapter.syncSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, config: manualSkillConfig, }, readPaperclipSkillSyncPreference(manualSkillConfig).desiredSkills) : adapter?.listSkills ? await adapter.listSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, config: runtimeSkillConfig, }) : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries); if (connectorAssignments.length && adapter?.listSkills) { snapshot = await adapter.listSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, config: runtimeSkillConfig }); } await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, action: "agent.skills_synced", entityType: "agent", entityId: updated.id, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, details: { adapterType: updated.adapterType, desiredSkills, desiredSkillEntries, assignmentMode: req.body.mode, mode: snapshot.mode, supported: snapshot.supported, entryCount: snapshot.entries.length, warningCount: snapshot.warnings.length, }, }); res.json(annotateConnectorSkills(snapshot, connectorAssignments)); }, ); router.get("/companies/:companyId/agents", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const unsupportedQueryParams = Object.keys(req.query).sort(); if (unsupportedQueryParams.length > 0) { res.status(400).json({ error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`, }); return; } const result = await filterAgentsForActor(req, await svc.list(companyId)); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); if (canReadConfigs) { res.json(result.map((agent) => redactAgentRowForResponse(agent))); return; } res.json(result.map((agent) => redactForRestrictedAgentView(agent))); }); router.get("/instance/scheduler-heartbeats", async (req, res) => { assertInstanceAdmin(req); const rows = await db .select({ id: agentsTable.id, companyId: agentsTable.companyId, agentName: agentsTable.name, agentAppearance: agentsTable.appearance, 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)) .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.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 filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); res.json(leanTree); }); router.get("/companies/:companyId/org.svg", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style); res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", "no-cache"); res.send(svg); }); router.get("/companies/:companyId/org.png", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style); res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "no-cache"); res.send(png); }); 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; } if ( req.actor.keyScope?.kind === "task_bridge" || req.actor.keyScope?.kind === "skill_test" ) { res.json({ id: agent.id, companyId: agent.companyId, name: agent.name, role: agent.role, title: agent.title, status: agent.status, keyScope: req.actor.keyScope, }); return; } const trustPreset = await resolveAgentSelfTrustPreset(req, agent); if (trustPreset.kind === "denied") { res.status(403).json({ error: trustPreset.detail }); return; } if (trustPreset.kind === "low_trust_review") { res.json(buildLowTrustSelfView(agent)); return; } res.json(await buildAgentDetail(agent)); }); router.get("/agents/me/inbox-lite", async (req, res) => { if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { res.status(401).json({ error: "Agent authentication required" }); return; } const issuesSvc = issueService(db); const recoveryActionsSvc = issueRecoveryActionService(db); const rows = await issuesSvc.list(req.actor.companyId, { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", includeRoutineExecutions: true, limit: ISSUE_LIST_DEFAULT_LIMIT, }); const worktreeActivation = await resolveWorktreeRunExecutionActivationState({ getExperimental: () => instanceSettingsService(db).getExperimental(), }); const isWorktreeRuntime = isTruthyRuntimeEnvValue(process.env.PAPERCLIP_IN_WORKTREE); const eligibleRows = !isWorktreeRuntime ? rows : worktreeActivation.armed ? rows.filter((issue) => new Date(issue.createdAt) >= new Date(worktreeActivation.cutoff)) : []; const issueIds = eligibleRows.map((issue) => issue.id); const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([ issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds), recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds), ]); res.json( eligibleRows.map((issue) => ({ id: issue.id, identifier: issue.identifier, title: issue.title, status: issue.status, priority: issue.priority, projectId: issue.projectId, goalId: issue.goalId, parentId: issue.parentId, updatedAt: issue.updatedAt, activeRun: issue.activeRun, activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null, dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true, unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0, unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [], })), ); }); router.get("/agents/me/inbox/mine", async (req, res) => { if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { res.status(401).json({ error: "Agent authentication required" }); return; } const query = agentMineInboxQuerySchema.parse(req.query); const issuesSvc = issueService(db); const rows = await issuesSvc.list(req.actor.companyId, { touchedByUserId: query.userId, inboxArchivedByUserId: query.userId, status: query.status, limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json(rows); }); router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; if (!(await assertAgentReadAllowed(req, res, agent))) return; const isSelf = req.actor.type === "agent" && req.actor.agentId === id; if (isSelf) { const trustPreset = await resolveAgentSelfTrustPreset(req, agent); if (trustPreset.kind === "denied") { res.status(403).json({ error: trustPreset.detail }); return; } if (trustPreset.kind === "low_trust_review") { res.json(buildLowTrustSelfView(agent)); return; } } const canReadSensitiveDetail = isSelf ? true : await actorCanReadConfigurationsForCompany(req, agent.companyId); if (!canReadSensitiveDetail) { res.json(await buildAgentDetail(agent, { restricted: true })); return; } res.json(await buildAgentDetail(agent)); }); 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 getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; await assertCanUpdateAgent(req, existing); const revision = await svc.getConfigRevision(id, revisionId); if (!revision) { res.status(404).json({ error: "Revision not found" }); return; } const rollbackConfig = asRecord(revision.afterConfig); if (!rollbackConfig) { throw unprocessable("Invalid revision snapshot"); } assertProviderTraceSettingTransition( req, rollbackConfig.runtimeConfig, existing.runtimeConfig, ); const rollbackAdapterType = assertKnownAdapterType( typeof rollbackConfig.adapterType === "string" ? rollbackConfig.adapterType : null, ); if (rollbackAdapterType !== existing.adapterType) { await assertSelectableAdapterType(rollbackAdapterType); } const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {}; assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig); assertNoAgentProcessAdapterMutation( req, rollbackAdapterType, true, ); assertExternalInstructionsAdmin(req, existing); assertExternalInstructionsAdmin(req, { ...existing, adapterConfig: rollbackAdapterConfig, }); if ( rollbackAdapterType !== existing.adapterType || rollbackAdapterType === "paperclip_runner" ) { await assertFreshPaperclipRunnerProvider( existing.companyId, rollbackAdapterType, rollbackAdapterConfig, ); } 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, agentApiKeyId: actor.agentApiKeyId, action: "agent.config_rolled_back", entityType: "agent", entityId: updated.id, details: { revisionId }, }); res.json(redactAgentRowForResponse(updated)); }); router.get("/agents/:id/runtime-state", async (req, res) => { assertBoard(req); const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; await assertBoardCanManageAgentsForCompany(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 getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; await assertBoardCanManageAgentsForCompany(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 getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; await assertBoardCanManageAgentsForCompany(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); }); // Fingerprint the whole validated hire request so a retried POST inside the // same run (e.g. an agent that misread the 201 body and re-sent the payload) // resolves to the hire it already created instead of spawning a "Name 2" // duplicate, while a corrected payload (a different adapter config, budget, // manager, skills, instructions, ...) counts as a new hire. Hashed, so no // adapter-config secret lands in the activity log. const hireFingerprint = (body: unknown): string => sha256Digest(body); 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 { desiredSkills: requestedDesiredSkills, instructionsBundle, sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, // The stored-session claim is not an agent column. The server derives the // owner from the authenticated actor and consumes the claim in the create // transaction, so it never reaches the insert values. storedSessionId: hireStoredSessionId, // The apply-existing flag is not an agent column. The server binds the // fixed reference to the owner stored value with no login round trip. applyStoredClaudeLogin: hireApplyStoredClaudeLogin, // The onboarding marker is not an agent column. The server consumes it to // seed the chief-of-staff persona; it never reaches the insert values. onboardingFirstAgent: hireOnboardingFirstAgent, // This intent flag is consumed below and must never reach agent create. inheritRuntimeFrom, ...hireInput } = req.body; if (inheritRuntimeFrom === "caller") { if (req.actor.type !== "agent" || !req.actor.agentId) { throw forbidden("Only an agent can inherit native runtime settings from the caller"); } if (hireInput.adapterType !== "paperclip_runner") { throw unprocessable("inheritRuntimeFrom=caller requires adapterType=paperclip_runner"); } const requestedConfig = (hireInput.adapterConfig ?? {}) as Record; const requestedRuntime = (hireInput.runtimeConfig ?? {}) as Record; if (Object.keys(requestedConfig).length > 0 || Object.keys(requestedRuntime).length > 0 || hireInput.defaultEnvironmentId !== undefined) { throw unprocessable("inheritRuntimeFrom=caller cannot be combined with adapterConfig, runtimeConfig, or defaultEnvironmentId"); } const caller = await svc.getById(req.actor.agentId); if (!caller || caller.companyId !== companyId) { throw forbidden("The caller agent is not in this company"); } if (caller.adapterType !== "paperclip_runner") { throw unprocessable("The caller must use the paperclip_runner adapter"); } hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig); hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null; } hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType); const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, hireInput.runtimeConfig); await assertFreshPaperclipRunnerProvider( companyId, hireInput.adapterType, rawHireAdapterConfig, ); assertNoNewAgentLegacyPromptTemplate( hireInput.adapterType, rawHireAdapterConfig, ); assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType, rawHireAdapterConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, hireInput.adapterType, Object.keys(rawHireAdapterConfig).length > 0); const hiredAgentId = randomUUID(); const authInheritance = await applyHiringAgentAuthInheritance( req, companyId, hireInput.adapterType, applyCreateDefaultsByAdapterType( hireInput.adapterType, rawHireAdapterConfig, ), hireInput.runtimeConfig, ); const requestedAdapterConfig = applyCodexLocalKeyIsolation( companyId, hiredAgentId, hireInput.adapterType, authInheritance.adapterConfig, ); assertExternalInstructionsAdmin(req, { id: hiredAgentId, companyId, name: hireInput.name, adapterConfig: requestedAdapterConfig, }); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, hireInput.adapterType, requestedAdapterConfig, withDefaultRoleSkillSelections( normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), defaultRoleSkillSelections( hireInput.role, hireInput.adapterType, hireOnboardingFirstAgent === true && req.actor.type === "board", ), ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, adapterType: hireInput.adapterType, adapterConfig: desiredSkillAssignment.adapterConfig, }); const normalizedRuntimeConfig = await normalizeCreatedAgentRuntimeConfig(req, companyId, hireInput.adapterType, normalizedAdapterConfig, hireInput.runtimeConfig); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, runtimeConfig: normalizedRuntimeConfig, }; await assertAgentEnvironmentSelection(companyId, hireInput.adapterType, hireInput.defaultEnvironmentId); await assertAgentDefaultEnvironmentSelection(companyId, hireInput.defaultEnvironmentId, { allowedDrivers: allowedEnvironmentDriversForAgent(hireInput.adapterType), allowedSandboxProviders: allowedSandboxProvidersForAgent(hireInput.adapterType), }); 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; } // Idempotency within a run: if this run already created a hire from this // exact request, return that hire instead of creating a duplicate. The // creating agent cannot pause or delete its own hire (board-only), so a // doubled hire would otherwise strand a phantom teammate the board never // approved. The lookup, the create and the activity record run under one // lock per company + run, so two overlapping retries cannot both miss. const requestFingerprint = hireFingerprint(req.body); const runId = req.actor.runId && isUuidLike(req.actor.runId) ? req.actor.runId : null; const performHire = async (): Promise<{ status: 200 | 201; body: Record }> => { if (runId) { const priorHires = await db .select({ entityId: activityLog.entityId, details: activityLog.details }) .from(activityLog) .where( and( eq(activityLog.companyId, companyId), eq(activityLog.runId, runId), eq(activityLog.action, "agent.hire_created"), ), ) .orderBy(desc(activityLog.createdAt)); const match = priorHires.find( (row) => (row.details as Record | null)?.hireFingerprint === requestFingerprint, ); if (match) { const existingAgent = await svc.getById(match.entityId); if (existingAgent && existingAgent.status !== "terminated") { const priorApprovalId = (match.details as Record | null)?.approvalId; const existingApproval = typeof priorApprovalId === "string" ? await approvalsSvc.getById(priorApprovalId) : null; return { status: 200, body: { agent: existingAgent, approval: existingApproval, idempotent: true } }; } } } const requiresApproval = company.requireBoardApprovalForNewAgents; const status = requiresApproval ? "pending_approval" : "idle"; const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined; const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined; const createdAgent = await svc.create( companyId, { id: hiredAgentId, ...normalizedHireInput, status, spentMonthlyCents: 0, lastHeartbeatAt: null, }, { aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: hireStoredSessionId ?? null, ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null), // The apply-existing path runs only for a user actor. The owner comes // from the actor, so an agent actor never reaches the no-claim bind. applyExistingWithoutClaim: req.actor.type !== "agent" && hireApplyStoredClaudeLogin === true, // Set only when an agent actor hired this child and the merge above // inherited the parent's fixed Claude OAuth reference. The service // re-reads this named parent inside the write transaction before it // permits the bind, so this identifier is a claim to verify, not a // trusted value. inheritedFromAgentId: req.actor.type === "agent" && authInheritance.inheritedFixedClaudeOAuthBinding ? req.actor.agentId : null, }, }, ); const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({ onboardingFirstAgent: hireOnboardingFirstAgent, actorType: req.actor.type, agentName: createdAgent.name, organizationName: company.name ?? null, }); const agent = await materializeDefaultInstructionsBundleForNewAgent( createdAgent, onboardingFirstAgentBundle ?? instructionsBundle, ); let approval: Awaited> | null = null; const actor = getActorInfo(req); if (requiresApproval) { const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; const requestedAdapterConfig = redactEventPayload( (agent.adapterConfig ?? normalizedHireInput.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, appearance: agent.appearance, reportsTo: normalizedHireInput.reportsTo ?? null, capabilities: normalizedHireInput.capabilities ?? null, adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, budgetMonthlyCents: typeof normalizedHireInput.budgetMonthlyCents === "number" ? normalizedHireInput.budgetMonthlyCents : agent.budgetMonthlyCents, desiredSkills: desiredSkillAssignment.desiredSkills, metadata: requestedMetadata, agentId: agent.id, requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedConfigurationSnapshot: { adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, desiredSkills: desiredSkillAssignment.desiredSkills, }, }, 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, agentApiKeyId: actor.agentApiKeyId, action: "agent.hire_created", entityType: "agent", entityId: agent.id, details: { name: agent.name, role: agent.role, requiresApproval, approvalId: approval?.id ?? null, issueIds: sourceIssueIds, desiredSkills: desiredSkillAssignment.desiredSkills, hireFingerprint: requestFingerprint, ...(managedHireConnection?.memberConnectionIds ? { aiConnectionPoolId: managedHireConnection.connectionId, aiConnectionMemberInstallIds: managedHireConnection.memberConnectionIds } : {}), }, }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( companyId, agent.id, actor.actorType === "user" ? actor.actorId : null, ); if (approval) { await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "approval.created", entityType: "approval", entityId: approval.id, details: { type: approval.type, linkedAgentId: agent.id }, }); } return { status: 201, body: { agent, approval } }; }; const outcome = runId ? await withHireRunLock(`${companyId}:${runId}`, performHire) : await performHire(); res.status(outcome.status).json(outcome.body); }); router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId); 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; } if (company.requireBoardApprovalForNewAgents) { throw conflict( "Direct agent creation requires board approval. Use POST /api/companies/:companyId/agent-hires to create a pending hire approval.", ); } const { desiredSkills: requestedDesiredSkills, instructionsBundle, // The stored-session claim is not an agent column. The server derives the // owner from the authenticated actor and consumes the claim in the create // transaction, so it never reaches the insert values. storedSessionId: createStoredSessionId, // The apply-existing flag is not an agent column. The server binds the // fixed reference to the owner stored value with no login round trip. applyStoredClaudeLogin: createApplyStoredClaudeLogin, // The onboarding marker is not an agent column. The server consumes it to // seed the chief-of-staff persona; it never reaches the insert values. onboardingFirstAgent: createOnboardingFirstAgent, ...createInput } = req.body; createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType); const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, createInput.runtimeConfig); await assertFreshPaperclipRunnerProvider( companyId, createInput.adapterType, rawCreateAdapterConfig, ); assertNoNewAgentLegacyPromptTemplate( createInput.adapterType, rawCreateAdapterConfig, ); assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig); assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType, rawCreateAdapterConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, createInput.adapterType, Object.keys(rawCreateAdapterConfig).length > 0); const agentId = randomUUID(); const requestedAdapterConfig = applyCodexLocalKeyIsolation( companyId, agentId, createInput.adapterType, applyCreateDefaultsByAdapterType( createInput.adapterType, rawCreateAdapterConfig, ), ); assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedAdapterConfig, }); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, createInput.adapterType, requestedAdapterConfig, withDefaultRoleSkillSelections( normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), defaultRoleSkillSelections( createInput.role, createInput.adapterType, createOnboardingFirstAgent === true && req.actor.type === "board", ), ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, adapterType: createInput.adapterType, adapterConfig: desiredSkillAssignment.adapterConfig, }); const normalizedRuntimeConfig = await normalizeCreatedAgentRuntimeConfig(req, companyId, createInput.adapterType, normalizedAdapterConfig, createInput.runtimeConfig); await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); await assertAgentDefaultEnvironmentSelection(companyId, createInput.defaultEnvironmentId, { allowedDrivers: allowedEnvironmentDriversForAgent(createInput.adapterType), allowedSandboxProviders: allowedSandboxProvidersForAgent(createInput.adapterType), }); const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined; const managedConnection = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined; const createdAgent = await svc.create( companyId, { id: agentId, ...createInput, adapterConfig: normalizedAdapterConfig, runtimeConfig: normalizedRuntimeConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, }, { aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: createStoredSessionId ?? null, ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null), // The apply-existing path runs only for a user actor. The owner comes // from the actor, so an agent actor never reaches the no-claim bind. applyExistingWithoutClaim: req.actor.type !== "agent" && createApplyStoredClaudeLogin === true, }, }, ); const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({ onboardingFirstAgent: createOnboardingFirstAgent, actorType: req.actor.type, agentName: createdAgent.name, organizationName: company.name ?? null, }); const agent = await materializeDefaultInstructionsBundleForNewAgent( createdAgent, onboardingFirstAgentBundle ?? instructionsBundle, ); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "agent.created", entityType: "agent", entityId: agent.id, details: { name: agent.name, role: agent.role, desiredSkills: desiredSkillAssignment.desiredSkills, ...(managedConnection?.memberConnectionIds ? { aiConnectionPoolId: managedConnection.connectionId, aiConnectionMemberInstallIds: managedConnection.memberConnectionIds } : {}), }, }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( companyId, agent.id, req.actor.type === "board" ? (req.actor.userId ?? null) : null, ); await builtInAgentService(db).ensureCompanyDefaultAgentGrants(companyId); if (agent.budgetMonthlyCents > 0) { await budgets.upsertPolicy( companyId, { scopeType: "agent", scopeId: agent.id, amount: agent.budgetMonthlyCents, windowKind: "calendar_month_utc", }, actor.actorType === "user" ? actor.actorId : null, ); } res.status(201).json(redactAgentRowForResponse(agent)); }); router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; 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; } } else { await assertBoardCanManageAgentsForCompany(req, existing.companyId); } const agent = await svc.updatePermissions(id, req.body); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const effectiveCanAssignTasks = agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks; await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active"); await access.setPrincipalPermission( agent.companyId, "agent", agent.id, "tasks:assign", effectiveCanAssignTasks, req.actor.type === "board" ? (req.actor.userId ?? null) : null, ); const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "agent.permissions_updated", entityType: "agent", entityId: agent.id, details: { canCreateAgents: agent.permissions?.canCreateAgents ?? false, canCreateSkills: agent.permissions?.canCreateSkills ?? true, canAssignTasks: effectiveCanAssignTasks, trustPreset: agent.permissions?.trustPreset ?? "standard", }, }); res.json(await buildAgentDetail(agent)); }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { if (req.actor.type !== "board") { throw forbidden("Only board-authenticated callers can manage instructions path or bundle configuration"); } const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; await assertCanManageInstructionsPath(req, existing); assertExternalInstructionsAdmin(req, existing); const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); const defaultKey = resolveInstructionsPathKey(existing.adapterType); 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 syncedAdapterConfig = syncInstructionsBundleConfigFromFilePath(existing, nextAdapterConfig); assertExternalInstructionsAdmin(req, { ...existing, adapterConfig: syncedAdapterConfig }); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, syncedAdapterConfig, { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); 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, agentApiKeyId: actor.agentApiKeyId, 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.get("/agents/:id/instructions-bundle", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); if (agentInstructionsBundleMode(existing) === "external") { await assertCanReadAgent(req, existing); } else { await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id }); } if (agentInstructionsBundleMode(existing) !== "external") { const target = { companyId: existing.companyId, agentId: existing.id }; const current = await instructionRevisions.readCurrent(target, req.actor); if (current) { try { await instructionRevisions.materializeCurrent(target); } catch (error) { const bundle = await instructions.getBundle(existing); bundle.warnings.push(`Saved instruction revision needs materialization: ${error instanceof Error ? error.message : String(error)}`); res.json(bundle); return; } } } res.json(await instructions.getBundle(existing)); }); router.patch("/agents/:id/instructions-bundle", validate(updateAgentInstructionsBundleSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; await assertCanManageInstructionsPath(req, existing); assertExternalInstructionsAdmin(req, existing); if (req.body.mode === "external") assertInstanceAdmin(req); const actor = getActorInfo(req); const { bundle, adapterConfig } = await instructions.updateBundle(existing, req.body); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, adapterConfig, { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, { adapterConfig: normalizedAdapterConfig }, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "instructions_bundle_patch", }, }, ); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "agent.instructions_bundle_updated", entityType: "agent", entityId: existing.id, details: { mode: bundle.mode, rootPath: bundle.rootPath, entryFile: bundle.entryFile, clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true, }, }); res.json(bundle); }); router.get("/agents/:id/instructions-bundle/file", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); if (agentInstructionsBundleMode(existing) === "external") { await assertCanReadAgent(req, existing); } else { await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id }); } const relativePath = typeof req.query.path === "string" ? req.query.path : ""; if (!relativePath.trim()) { res.status(422).json({ error: "Query parameter 'path' is required" }); return; } if (req.query.download === "true" && agentInstructionsBundleMode(existing) === "managed") { const download = await agentFiles.download(existing.companyId, existing.id, relativePath, req.actor); if (download === null) throw notFound("Agent file not found"); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("X-Content-Type-Options", "nosniff"); res.attachment(relativePath.split("/").at(-1)!); res.setHeader("Content-Length", download.size); await pipeline(download.stream, res); return; } if (agentInstructionsBundleMode(existing) !== "external" && instructionPath(relativePath) === deriveBundleState(existing).entryFile) { const snapshot = await instructionRevisions.readCurrent({ companyId: existing.companyId, agentId: existing.id }, req.actor); if (snapshot) { res.json(instructionFileDetail(snapshot)); return; } } res.json(await instructions.readFile(existing, relativePath)); }); router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; const entryFile = deriveBundleState(existing).entryFile; if (instructionPath(req.body.path) === entryFile) { assertExternalInstructionsAdmin(req, existing); if (req.body.baseHash !== undefined) req.body.baseRevisionId = req.body.baseHash === null ? null : agentFileTokenFromHash(req.body.baseHash); if (req.body.baseRevisionId === undefined) throw unprocessable("Read the entry and supply baseRevisionId (null for a new entry)", { code: "INSTRUCTION_BASE_REQUIRED" }); // Clearing legacy prompt configuration remains a protected config change. if (req.body.clearLegacyPromptTemplate) await assertCanManageInstructionsPath(req, existing); const receipt = await instructionRevisions.commit({ companyId: existing.companyId, agentId: existing.id, entryFile, content: req.body.content, baseRevisionId: req.body.baseRevisionId, source: req.actor.type === "board" ? "board" : "api" }, req.actor); if (req.actor.type === "agent" && req.actor.runId) { await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id, runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash }).catch(() => undefined); } if (req.body.clearLegacyPromptTemplate) { const fresh = await svc.getById(existing.id); if (fresh) { const adapterConfig = { ...asRecord(fresh.adapterConfig) }; delete adapterConfig.promptTemplate; delete adapterConfig.bootstrapPromptTemplate; await svc.update(existing.id, { adapterConfig }); } } res.json(instructionFileDetail(receipt, receipt)); return; } await assertCanManageInstructionsPath(req, existing); assertExternalInstructionsAdmin(req, existing); if (agentInstructionsBundleMode(existing) === "managed" && req.body.path !== "promptTemplate.legacy.md") { if (req.body.baseHash === undefined) throw unprocessable("Read the file and supply baseHash (null for a new file)"); await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: req.body.path, bytes: Buffer.from(req.body.content, "utf8"), baseHash: req.body.baseHash }, req.actor); res.json(await instructions.readFile(existing, req.body.path)); return; } const actor = getActorInfo(req); const result = await instructions.writeFile(existing, req.body.path, req.body.content, { clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate, }); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, result.adapterConfig, { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, { adapterConfig: normalizedAdapterConfig }, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "instructions_bundle_file_put", }, }, ); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "agent.instructions_file_updated", entityType: "agent", entityId: existing.id, details: { path: result.file.path, size: result.file.size, clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true, }, }); res.json(result.file); }); router.get("/agents/:id/instructions-bundle/candidates", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); res.json(await instructionWorkingCopies.list(existing.companyId, existing.id, req.actor)); }); router.post("/agents/:id/instructions-bundle/candidates/:runId/resolve", validate(resolveAgentInstructionCandidateSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); const runId = req.params.runId as string; if (!isUuidLike(runId)) throw unprocessable("Invalid instruction candidate run id"); const receipt = await instructionWorkingCopies.resolve({ companyId: existing.companyId, agentId: existing.id, runId, baseRevisionId: req.body.baseRevisionId, content: req.body.content }, req.actor); res.json(instructionFileDetail(receipt, receipt)); }); router.get("/agents/:id/instructions-bundle/history", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile; const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined; if (cursor && !isUuidLike(cursor)) throw unprocessable("Invalid history cursor"); res.json(await instructionRevisions.history({ companyId: existing.companyId, agentId: existing.id, entryFile, cursor }, req.actor)); }); router.get("/agents/:id/instructions-bundle/revision/:revisionId", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); const revisionId = req.params.revisionId as string; if (!isUuidLike(revisionId)) throw unprocessable("Invalid instruction revision id"); const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile; res.json(await instructionRevisions.readRevision({ companyId: existing.companyId, agentId: existing.id, entryFile, revisionId }, req.actor)); }); router.get("/agents/:id/instructions-bundle/diff", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); const fromRevisionId = typeof req.query.from === "string" ? req.query.from : ""; const toRevisionId = typeof req.query.to === "string" ? req.query.to : ""; if (!isUuidLike(fromRevisionId) || !isUuidLike(toRevisionId)) throw unprocessable("Provide valid from and to revision ids"); const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile; res.json(await instructionRevisions.diff({ companyId: existing.companyId, agentId: existing.id, entryFile, fromRevisionId, toRevisionId }, req.actor)); }); router.post("/agents/:id/instructions-bundle/restore", validate(restoreAgentInstructionSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!existing) return; assertExternalInstructionsAdmin(req, existing); const receipt = await instructionRevisions.restore({ companyId: existing.companyId, agentId: existing.id, entryFile: req.body.path, baseRevisionId: req.body.baseRevisionId, revisionId: req.body.revisionId }, req.actor); if (req.actor.type === "agent" && req.actor.runId) { await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id, runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash }).catch(() => undefined); } res.json(instructionFileDetail(receipt, receipt)); }); router.delete("/agents/:id/instructions-bundle/file", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; await assertCanManageInstructionsPath(req, existing); assertExternalInstructionsAdmin(req, existing); const relativePath = typeof req.query.path === "string" ? req.query.path : ""; if (!relativePath.trim()) { res.status(422).json({ error: "Query parameter 'path' is required" }); return; } if (agentInstructionsBundleMode(existing) === "managed") { const baseHash = typeof req.query.baseHash === "string" && /^[a-f0-9]{64}$/.test(req.query.baseHash) ? req.query.baseHash : null; if (baseHash === null) throw unprocessable("Read the file and supply baseHash before deleting it"); await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: relativePath, bytes: null, baseHash }, req.actor); res.json(await instructions.getBundle(existing)); return; } const actor = getActorInfo(req); const result = await instructions.deleteFile(existing, relativePath); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, action: "agent.instructions_file_deleted", entityType: "agent", entityId: existing.id, details: { path: relativePath, }, }); res.json(result.bundle); }); router.post("/agents/:id/connection-intents/:interactionId/adopt", validate(completeConnectionIntentSchema), async (req, res) => { assertBoard(req); const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); if (!agent) return; await assertCanUpdateAgent(req, agent); const userId = responsibleUserForAiRequest(req); const intents = connectionIntentService(db); const interactionId = req.params.interactionId as string; const loaded = await intents.loadIntent(interactionId); if (loaded.issue.companyId !== agent.companyId || loaded.interaction.payload.requestingAgentId !== agent.id) throw notFound("Connection intent not found"); if (!userId || loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can adopt this connection"); const connectionId = req.body.connectionId as string; if (loaded.interaction.status === "accepted" && loaded.interaction.result?.connectionId === connectionId) { res.json(loaded.interaction); return; } if (loaded.interaction.status !== "pending") throw conflict("Connection intent is already resolved"); const setup = await intents.setupOptions(interactionId); if (!setup.aiConnectionRequiresAdoption || !setup.aiConnection) throw conflict("The agent’s AI configuration changed. Reload the task and try again."); // Probe in the agent's execution environment without installing access. // The binding, install, audit, and card resolution commit together below. const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true); if (validatedConnection?.connectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again."); res.json(await intents.complete(interactionId, connectionId, userId, { validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection }, })); }); router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; if (hasOwn(req.body as object, "permissions")) { res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); return; } const patchData = { ...(req.body as Record) }; const replaceAdapterConfig = patchData.replaceAdapterConfig === true; delete patchData.replaceAdapterConfig; // The apply-existing flag is not an agent column. The server binds the fixed // reference to the owner stored value with no login round trip. Remove it // from the patch so it never reaches the update values. const applyStoredClaudeLogin = patchData.applyStoredClaudeLogin === true; delete patchData.applyStoredClaudeLogin; if (hasOwn(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } assertNoAgentAdapterConfigMutation(req, adapterConfig); const changingInstructionsConfig = adapterConfigTouchesInstructionsConfig(adapterConfig); if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; } // Switching an existing agent ONTO another adapter is a new selection, so // it gets the selectable check; keeping the agent's current adapter (even // one since disabled) stays allowed, so a disabled harness does not make an // existing agent uneditable. const nextAdapterType = hasOwn(patchData, "adapterType") ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) : existing.adapterType; const requestedAdapterType = nextAdapterType === existing.adapterType ? nextAdapterType : await assertSelectableAdapterType(nextAdapterType); let requestedRuntimeConfig: Record | null = null; if (hasOwn(patchData, "runtimeConfig")) { const runtimeConfig = asRecord(patchData.runtimeConfig); if (!runtimeConfig) { res.status(422).json({ error: "runtimeConfig must be an object" }); return; } assertProviderTraceSettingTransition( req, runtimeConfig, existing.runtimeConfig, ); requestedRuntimeConfig = runtimeConfig; } const touchesAdapterConfiguration = hasOwn(patchData, "adapterType") || hasOwn(patchData, "adapterConfig"); assertNoAgentProcessAdapterMutation(req, requestedAdapterType, touchesAdapterConfiguration); if (touchesAdapterConfiguration) { assertExternalInstructionsAdmin(req, existing); const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const changingAdapterType = typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; const requestedAdapterConfig = hasOwn(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : null; if ( requestedAdapterConfig && replaceAdapterConfig && KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => existingAdapterConfig[key] !== undefined && requestedAdapterConfig[key] === undefined, ) ) { await assertCanManageInstructionsPath(req, existing); } let rawEffectiveAdapterConfig = requestedAdapterConfig ? restoreRedactedAgentEnv(requestedAdapterConfig, existingAdapterConfig) : changingAdapterType ? {} : existingAdapterConfig; if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) { rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...rawEffectiveAdapterConfig }; } if (changingAdapterType) { // Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config // when the adapter type changes. Without this, a PATCH that includes // adapterConfig but omits these keys would silently drop them. for (const key of ADAPTER_AGNOSTIC_KEYS) { if (KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET.has(key)) continue; if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) { rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] }; } } rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( existingAdapterConfig, rawEffectiveAdapterConfig, ); rawEffectiveAdapterConfig = resolvePaperclipRunnerAdapterTransition({ previousAdapterType: existing.adapterType, nextAdapterType: requestedAdapterType, previousAdapterConfig: existingAdapterConfig, nextAdapterConfig: rawEffectiveAdapterConfig, }); } if (requestedAdapterType === "paperclip_runner") { rawEffectiveAdapterConfig = normalizePaperclipRunnerAdapterConfig(requestedAdapterType, rawEffectiveAdapterConfig); } if (changingAdapterType || requestedAdapterConfig) { assertNoAgentLocalAdapterHostCommandMutation( req, requestedAdapterType, changingAdapterType ? rawEffectiveAdapterConfig : (requestedAdapterConfig ?? {}), ); } const existingRunnerProvider = existing.adapterType === "paperclip_runner" ? existingAdapterConfig.provider : undefined; if ( changingAdapterType || (requestedAdapterType === "paperclip_runner" && (requestedAdapterConfig !== null || rawEffectiveAdapterConfig.provider !== existingRunnerProvider)) ) { await assertFreshPaperclipRunnerProvider( existing.companyId, requestedAdapterType, rawEffectiveAdapterConfig, ); } const effectiveAdapterConfig = applyCodexLocalKeyIsolation( existing.companyId, existing.id, requestedAdapterType, applyCreateDefaultsByAdapterType( requestedAdapterType, rawEffectiveAdapterConfig, ), ); const normalizedEffectiveAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId: existing.companyId, adapterType: requestedAdapterType, adapterConfig: effectiveAdapterConfig, }); patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); assertExternalInstructionsAdmin(req, { ...existing, adapterConfig: patchData.adapterConfig, }); } if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection; const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data; if (nextAiBinding) { await assertCanUpdateAgent(req, existing); const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection); const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record; if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model"); if (changed || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true); } if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig; if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) { await assertAgentDefaultEnvironmentSelection( existing.companyId, Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId") ? (typeof patchData.defaultEnvironmentId === "string" ? patchData.defaultEnvironmentId : null) : existing.defaultEnvironmentId, { allowedDrivers: allowedEnvironmentDriversForAgent(requestedAdapterType), allowedSandboxProviders: allowedSandboxProvidersForAgent(requestedAdapterType), }, ); } const touchesProfileFields = touchesAgentProfileChangeConsentFields(patchData); const profileOnlyChange = touchesProfileFields && Object.keys(patchData).every((key) => (AGENT_PROFILE_CHANGE_CONSENT_FIELDS as readonly string[]).includes(key), ); if (profileOnlyChange) { await assertCanApplyAgentProfileChange(req, existing); } else { await assertCanUpdateAgent(req, existing); } const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "patch", }, claudeLogin: { ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null), // The apply-existing path runs only for a user actor. The owner comes // from the actor, so an agent actor never reaches the no-claim bind. applyExistingWithoutClaim: req.actor.type !== "agent" && applyStoredClaudeLogin, }, }); 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, agentApiKeyId: actor.agentApiKeyId, action: "agent.updated", entityType: "agent", entityId: agent.id, details: summarizeAgentUpdateDetails(patchData), }); res.json(redactAgentRowForResponse(agent)); }); router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; if (!(await getAccessibleAgent(req, res, id))) { return; } 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(redactAgentRowForResponse(agent)); }); router.post("/agents/:id/resume", async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) { return; } await assertCanResumeAgent(req, existing); if (existing.orgChainHealth?.status === "invalid_org_chain") { res.status(409).json({ error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it", }); return; } const agent = await svc.resume(id); 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, agentApiKeyId: actor.agentApiKeyId, action: "agent.resumed", entityType: "agent", entityId: agent.id, }); res.json(redactAgentRowForResponse(agent)); }); router.post("/agents/:id/clear-error", async (req, res) => { assertBoard(req); const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) { return; } if (existing.orgChainHealth?.status === "invalid_org_chain") { res.status(409).json({ error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before clearing its error", }); return; } const agent = await svc.clearError(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.error_cleared", entityType: "agent", entityId: agent.id, }); res.json(redactAgentRowForResponse(agent)); }); router.post("/agents/:id/approve", async (req, res) => { assertBoard(req); const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) { return; } if (existing.status !== "pending_approval") { res.status(409).json({ error: "Only pending approval agents can be approved" }); return; } // Resolve the linked hire approval (clears it from the inbox) and run the // shared approval side effects: agent activation, budget policy, and the // hire-approved notification. Fall back to direct activation if no open // approval record exists (e.g. agents created before approvals were tracked). const decidedByUserId = req.actor.userId ?? "board"; const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id); let agent: Awaited> | null = null; if (openApproval) { await approvalsSvc.approve(openApproval.id, decidedByUserId); agent = await svc.getById(id); } else { const approval = await svc.activatePendingApproval(id); if (!approval) { res.status(404).json({ error: "Agent not found" }); return; } if (!approval.activated) { res.status(409).json({ error: "Only pending approval agents can be approved" }); return; } agent = approval.agent; } 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.approved", entityType: "agent", entityId: agent.id, details: { source: "agent_detail", approvalId: openApproval?.id ?? null }, }); res.json(redactAgentRowForResponse(agent)); }); router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) { return; } // Terminating an agent that is still awaiting approval is the agent-detail // equivalent of rejecting the hire. When a linked hire approval is still // open, delegate to approvalsSvc.reject(), which both resolves the approval // (clearing the inbox "Approve/Reject" card) and terminates the agent. // Mirror the approve path's branch-or-fallback so we never terminate twice: // reject() already calls agentsSvc.terminate() internally. let agent: Awaited> = null; if (existing.status === "pending_approval") { const openApproval = await approvalsSvc.findOpenHireApprovalForAgent(existing.companyId, id); if (openApproval) { await approvalsSvc.reject(openApproval.id, req.actor.userId ?? "board"); agent = await svc.getById(id); } } if (!agent) { agent = await svc.terminate(id); } if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const companyAgentRows = await db .select({ id: agentsTable.id, companyId: agentsTable.companyId, name: agentsTable.name, reportsTo: agentsTable.reportsTo, status: agentsTable.status, }) .from(agentsTable) .where(eq(agentsTable.companyId, agent.companyId)); const invalidOrgChainDescendantIds = listInvalidOrgChainDescendantIds(id, companyAgentRows); const cancellation = await heartbeat.cancelInvocationsForAgents( [id, ...invalidOrgChainDescendantIds], "Cancelled because the agent was terminated or became invalid-org-chain under a terminated manager", ); await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.terminated", entityType: "agent", entityId: agent.id, details: { invalidOrgChain: { descendantCount: invalidOrgChainDescendantIds.length, descendantIds: invalidOrgChainDescendantIds, state: invalidOrgChainDescendantIds.length > 0 ? "descendants_invalid_under_terminated_manager" : "none", }, cancellation: { agentIds: cancellation.agentIds, runsCancelled: cancellation.runsCancelled, wakeupsCancelled: cancellation.wakeupsCancelled, }, }, }); res.json(redactAgentRowForResponse(agent)); }); router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; if (!(await getAccessibleAgent(req, res, id))) { return; } 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 agent = await getAccessibleAgent(req, res, id); if (!agent) { return; } 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 agent = await getAccessibleAgent(req, res, id); if (!agent) { return; } const key = await svc.createApiKey(id, req.body.name, req.body.scope, { responsibleUserId: req.actor.userId ?? null, }); 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, scope: key.scope, responsibleUserId: key.responsibleUserId, }, }); res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); const id = req.params.id as string; const keyId = req.params.keyId as string; const agent = await getAccessibleAgent(req, res, id); if (!agent) { return; } const key = await svc.getKeyById(keyId); if (!key || key.agentId !== agent.id) { res.status(404).json({ error: "Key not found" }); return; } const revoked = await svc.revokeKey(agent.id, keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } await logActivity(db, { companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "agent.key_revoked", entityType: "agent", entityId: agent.id, details: { keyId: key.id, name: key.name }, }); res.json({ ok: true }); }); // Shared handler body for the wakeup-style endpoints. The two routes differ // only in: // - `source` — the modern /wakeup endpoint reads it from the request body // (timer|assignment|on_demand|automation) while the legacy // /heartbeat/invoke endpoint hardcodes "on_demand", since it has only // ever produced on-demand invocations. // - skipped-response shape — the modern endpoint surfaces the rich // SkippedWakeupResponse; the legacy endpoint stays on the simpler // { status: "skipped" } shape for backward compat. type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation"; type WakeupRouteOpts = { source: HeartbeatSource | undefined; skippedResponse: (agent: NonNullable>>, payload: Record | null) => unknown | Promise; }; const handleWakeupRoute = async ( req: Request, res: Response, opts: WakeupRouteOpts, ): Promise => { const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; if (req.actor.type === "agent") { if (req.actor.agentId !== id) { res.status(403).json({ error: "Agent can only invoke itself" }); return; } } else { await assertBoardCanWakeAgent(req, agent); } if (req.body.debug?.providerTrace === "raw") { assertInstanceAdmin(req); } if (agent.orgChainHealth?.status === "invalid_org_chain") { res.status(409).json({ error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs", }); return; } let wakePayload = req.body.payload ?? null; let retryConversationContext: Record = {}; let retryNativeReviewContext: NativeReviewAssignmentContext | null = null; if (req.body.failedRunId) { assertBoard(req); if ( req.body.reason !== "retry_failed_run" || (opts.source ?? "on_demand") !== "on_demand" || (req.body.triggerDetail ?? "manual") !== "manual" || req.body.forceFreshSession === true || req.body.debug ) { throw badRequest( "An exact failed-run retry cannot override its execution context.", ); } const failedRun = await heartbeat.getRun(req.body.failedRunId, { includeExecutionEvidence: true }); if ( !failedRun || failedRun.companyId !== agent.companyId || failedRun.agentId !== agent.id ) { throw notFound("Failed run not found"); } if (failedRun.runtimeMode === "native" && failedRun.errorCode === "native_session_cleanup_quarantined") { throw conflict("The stopped native session requires cleanup and reconciliation before a new attempt.", { code: "native_session_cleanup_quarantined", }); } if (!(await canRetryStoppedRun(db, failedRun))) { throw conflict("Only a failed run or a verified eligible cancellation can start a new attempt."); } const failedContext = asRecord(failedRun.contextSnapshot) ?? {}; const issueId = typeof failedContext.issueId === "string" ? failedContext.issueId : null; if (failedRun.status === "cancelled" && (!issueId || (await getExecutionBlocker(db, agent.companyId, issueId))?.runId !== failedRun.id)) { throw conflict("The stopped run no longer owns this task's recovery hold."); } if (issueId) { const issue = await issueService(db).getById(issueId); if (!issue || issue.companyId !== agent.companyId) throw notFound("Task not found"); if (issue.conversationAgentId && issue.conversationUserId !== req.actor.userId) { throw forbidden("Only the conversation owner can retry a chat run"); } const decision = await access.decide({ actor: req.actor, action: "issue:comment", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status, }, }); if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); const savedReviewContext = readNativeReviewAssignmentContext(failedContext); if (failedRun.runtimeMode === "native" && failedRun.status === "failed" && failedRun.errorCode === "native_provider_model_rejected" && failedRun.nativeIssueId === issue.id && savedReviewContext) { const review = await getNativeReviewAssignment(db, { companyId: agent.companyId, issueId: issue.id, agentId: agent.id, contextSnapshot: savedReviewContext, }); if (review?.interaction.status !== "pending") { throw conflict("The failed run's review is no longer current."); } retryNativeReviewContext = savedReviewContext; } if (issue.assigneeAgentId !== agent.id && !retryNativeReviewContext) { throw conflict("The task is no longer assigned to this agent."); } if (issue.conversationAgentId) { // Agent Chat has no task description to replay. Recover the exact // request from the selected server-owned run, never caller markers. // Keep the generation pinned so a reset before dispatch cannot revive // the old request. Runs that failed before turn preparation belong to // the initial generation and cannot be retried after a reset. const generation = failedContext.conversationSessionGeneration ?? 0; if (!Number.isInteger(generation) || generation !== issue.conversationSessionGeneration) { throw conflict("Conversation session changed; this older turn cannot be retried."); } retryConversationContext = { conversationSessionGeneration: generation }; const commentIds = [...new Set([ ...(Array.isArray(failedContext.wakeCommentIds) ? failedContext.wakeCommentIds : []), failedContext.wakeCommentId, failedContext.commentId, ].filter((value): value is string => typeof value === "string" && value.trim().length > 0))]; if (commentIds.length > 0) { retryConversationContext = { ...retryConversationContext, wakeCommentIds: commentIds, wakeCommentId: commentIds[commentIds.length - 1], }; } } } const chatBinding = issueId ? await db .select({ id: chatConversations.id }) .from(chatConversations) .where( and( eq(chatConversations.companyId, agent.companyId), eq(chatConversations.issueId, issueId), ), ) .limit(1) .then((rows) => rows[0]) : null; if (chatBinding) { if (failedRun.status === "cancelled") { throw conflict("Send a new chat message to continue this stopped conversation."); } if (!options.chatRunRetries || !req.actor.userId) { throw conflict("Chat retry authorization is unavailable.", { code: "chat_failed_run_retry_requires_authorized_context", }); } const retry = await db.transaction((tx) => options.chatRunRetries!.prepareFailedChatRunRetry(tx, { companyId: agent.companyId, issueId: issueId!, agentId: agent.id, failedRunId: failedRun.id, initiatedByUserId: req.actor.userId!, }), ); let receipt; try { receipt = await options.chatRunRetries.processFailedChatRunRetry( retry.actionId, ); } catch { // Admission is already committed. Infrastructure/read failure must // not report refusal or cause the caller to mint a second request. logger.warn( { retryActionId: retry.actionId }, "chat retry dispatch deferred to durable worker", ); receipt = { ...retry, runId: null, status: "queued" as const }; } res.status(202).json(receipt); return; } if ( typeof failedContext.source === "string" && failedContext.source.startsWith("chat:") ) { throw conflict( "The failed chat request no longer has an authorized conversation.", { code: "chat_failed_run_retry_requires_authorized_context" }, ); } // Non-chat runs retain the existing retry path, but the selected server // record—not caller-supplied task/comment markers—selects its task. wakePayload = Object.fromEntries( ["issueId", "taskId", "taskKey"].flatMap((key) => typeof failedContext[key] === "string" ? [[key, failedContext[key]]] : [], ), ); } const run = await heartbeat.wakeup(id, { failedRunId: req.body.failedRunId ?? null, ...(req.actor.type === "board" && !req.body.failedRunId ? { manualUserWake: true } : {}), source: opts.source, triggerDetail: req.body.triggerDetail ?? "manual", reason: req.body.reason ?? null, payload: req.actor.type === "agent" && wakePayload ? { ...wakePayload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined } : wakePayload, 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: { ...retryConversationContext, // Only the selected server-owned run can carry review authority into // an exact retry. Dispatch revalidates the current review assignment. ...retryNativeReviewContext, triggeredBy: req.actor.type, originIdentityContextId: req.actor.identityContextId ?? null, responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, forceFreshSession: req.body.forceFreshSession === true, ...(req.body.reason === "rerun_with_provider_trace" && req.body.debug?.providerTrace === "raw" ? { resumeIntent: true } : {}), ...(req.body.debug?.providerTrace === "raw" ? { debug: { providerTrace: "raw" }, providerTraceRequestedBy: req.actor.userId ?? "local-admin", } : {}), }, }); if (!run) { res.status(202).json(await opts.skippedResponse(agent, wakePayload)); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: run.id, action: "heartbeat.invoked", entityType: "heartbeat_run", entityId: run.id, details: { agentId: id }, }); if (req.body.debug?.providerTrace === "raw") { await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: run.id, action: "provider_trace.capture_requested", entityType: "heartbeat_run", entityId: run.id, details: { mode: "raw", retentionHours: 24, maxBytes: 64 * 1024 * 1024, }, }); } res.status(202).json(run); }; router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { await handleWakeupRoute(req, res, { source: req.body.source, skippedResponse: (agent, payload) => buildSkippedWakeupResponse(agent, payload), }); }); router.post("/agents/:id/heartbeat/invoke", async (req, res) => { // Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior // before the wakeup/invoke convergence). Reads scope fields directly off // the body without `validate(wakeAgentSchema)` because callers — including // the e2e suite — post an empty body, and the schema rejects undefined // / missing bodies. Only forwards fields the caller actually supplied so // an empty body produces the original fixed-arg `heartbeat.invoke()` // shape exactly. if (req.body?.failedRunId !== undefined) { throw badRequest("Use the wakeup endpoint to retry an exact failed run."); } const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; if (req.actor.type === "agent") { if (req.actor.agentId !== id) { res.status(403).json({ error: "Agent can only invoke itself" }); return; } } else { await assertBoardCanWakeAgent(req, agent); } const providerTraceRequested = req.body?.debug?.providerTrace === "raw"; if (providerTraceRequested) { assertInstanceAdmin(req); } if (agent.orgChainHealth?.status === "invalid_org_chain") { res.status(409).json({ error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs", }); return; } const body = (req.body ?? {}) as Partial<{ reason: unknown; payload: unknown; idempotencyKey: unknown; forceFreshSession: unknown; triggerDetail: unknown; debug: unknown; }>; const contextSnapshot: Record = { triggeredBy: req.actor.type, originIdentityContextId: req.actor.identityContextId ?? null, responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, }; if (body.forceFreshSession === true) { contextSnapshot.forceFreshSession = true; } if (providerTraceRequested) { contextSnapshot.debug = { providerTrace: "raw" }; contextSnapshot.providerTraceRequestedBy = req.actor.userId ?? "local-admin"; if (body.reason === "rerun_with_provider_trace") { contextSnapshot.resumeIntent = true; } } const wakeOpts: Parameters[1] = { ...(req.actor.type === "board" ? { manualUserWake: true } : {}), source: "on_demand", triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual", requestedByActorType: req.actor.type === "agent" ? "agent" : "user", requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, contextSnapshot, }; if (typeof body.reason === "string" && body.reason.length > 0) { wakeOpts.reason = body.reason; } if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) { wakeOpts.payload = req.actor.type === "agent" ? { ...body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined } : body.payload as Record; } if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) { wakeOpts.idempotencyKey = body.idempotencyKey; } const run = await heartbeat.wakeup(id, wakeOpts); 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: run.id, action: "heartbeat.invoked", entityType: "heartbeat_run", entityId: run.id, details: { agentId: id }, }); if (providerTraceRequested) { await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: run.id, action: "provider_trace.capture_requested", entityType: "heartbeat_run", entityId: run.id, details: { mode: "raw", retentionHours: 24, maxBytes: 64 * 1024 * 1024, }, }); } 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 getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!agent) return; await assertBoardCanManageAgentsForCompany(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) ?? {}; // Persisted agent: default declared mode; consumerId = agent.id matches the // declaration rows written at env. by syncAgentAdapterEnvBindings. const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( agent.companyId, config, buildActorSecretContext(req, { consumerType: "agent", consumerId: agent.id }), { adapterType: agent.adapterType }, ); 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); }); // --- Setup-token login session routes -------------------------------------- // // The routes give the UI operations against one live login session. Every // operation verifies the company and owner user through the session scope. A // missing session and a cross-scope session both return the same 404. The // confidential responses pass through the transport assessment and set // `Cache-Control: no-store`. The routes write no prompt, code, token, or raw // process chunk to a log or an activity detail, and they return fixed error // text only. // // Operator requirement (SR-7): to serve the confidential responses behind a // TLS-terminating reverse proxy, set `CLAUDE_LOGIN_TRUSTED_PROXIES` to the // explicit proxy IP or CIDR allowlist — or, on a managed platform whose edge // always terminates TLS and whose proxy peer addresses cannot be allowlisted, // declare `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true`. The global `TRUST_PROXY` // setting, including `TRUST_PROXY=true` and a hop-count value, does not // satisfy the guard. A direct TLS request is always valid; a non-TLS request // is valid only on a loopback peer in the `local_trusted` deployment mode. // // Each route below writes its full path as a plain string literal. The static // OpenAPI coverage test reads the route paths from the source text; it does // not evaluate a template variable. A shared base constant would leave the // test with an unresolved path, so the routes repeat the base path instead. /** * Derives the immutable owner of a setup-token login session from the actor. * Only a board user owns a login session. It returns the owner id, or it * throws a forbidden error. The owner is never a client field; it comes only * from the authenticated actor. */ const deriveSetupTokenOwnerUserId = (req: Request): string => { const actor = getActorInfo(req); if (actor.actorType !== "user") { throw forbidden("A user must own a setup-token login session."); } return actor.actorId; }; /** * Read-access gate for the company-scoped setup-token session routes. It runs * before a route resolves a session. The session id is an opaque secret-bearing * reference, so a cross-company reference must fail closed like a missing * session. This gate returns the same fixed not-found error for a cross-company * reference by an authenticated non-member as for a missing session, so the * route is not a company-membership oracle. It keeps the not-found equivalence * the session lookups use. * * The gate keeps the actor rules unchanged. It throws 401 for an unauthenticated * caller and 403 for a non-user actor through the owner derivation. For an * authorized member it runs the full `assertCompanyAccess` write-path checks and * returns the owner user id. For a non-member it sends the fixed 404 and returns * null; the route must stop. */ const resolveCompanySessionOwner = ( req: Request, companyId: string, res: Response, ): string | null => { assertAuthenticated(req); const ownerUserId = deriveSetupTokenOwnerUserId(req); if (!hasCompanyAccess(req, companyId)) { res.setHeader("Cache-Control", "no-store"); res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND }); return null; } assertCompanyAccess(req, companyId); return ownerUserId; }; /** * Assesses the setup-token confidential transport. The product * owner set a non-negotiable requirement: do not force TLS. Many users run * Paperclip over plain HTTP on a home server or a Tailscale tailnet. So the * route does not block a non-confidential transport. It returns a non-blocking * advisory instead, and the route attaches it to the confidential response. * The client shows a visible disclaimer and lets the login proceed. The * function reads the raw socket TLS bit and the immediate peer address, so the * global `trust proxy` setting cannot change the result. It returns null when * the transport is confidential (direct TLS, a local-trusted loopback, or an * allowlisted TLS proxy), so a confidential response shows no disclaimer. */ const assessSetupTokenTransport = (req: Request): SetupTokenTransportAdvisory | null => { const socket = req.socket as { encrypted?: boolean; remoteAddress?: string }; const forwardedProto = req.headers["x-forwarded-proto"]; const decision = evaluateConfidentialTransport(setupTokenConfidentialConfig, { socketEncrypted: socket?.encrypted === true, remoteAddress: socket?.remoteAddress, forwardedProto: Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto, }); return decision.allowed ? null : { code: SETUP_TOKEN_TRANSPORT_ADVISORY_CODE }; }; const sendSetupTokenError = (res: Response, err: unknown): void => { if (err instanceof SetupTokenSessionError) { res.status(err.status).json({ error: err.message }); return; } throw err; }; // --- Company-and-environment setup-token login routes ---------------------- // // These routes serve the agentless Claude login. The scope binds one login to // one company, one owner user, one adapter, and one environment. The scope // carries no agent id, so a hire flow starts one login before an agent exists. // // Object-level authorization: every action derives the owner from // the authenticated actor, fixes the adapter to `claude_local`, and resolves // the environment server-side. The lookup scopes by the immutable tuple // company, owner, adapter, environment, and session. A foreign session returns // the same not-found error as a missing session, so a caller cannot enumerate // a session across a company, an owner, an adapter, or an environment. // // Each route writes its full path as a plain string literal, so the static // OpenAPI coverage test can read the path from the source text. // Maps the internal session state to the public login status. The public union // carries no server-only state, so the route never returns the internal // `submitting` or `stored` state to a client. const toClaudeLoginStatus = (state: SetupTokenSessionState): AdapterAuthSessionStatus => { switch (state) { case "starting": return "starting"; case "awaiting_code": case "submitting": case "stored": return "waiting_for_user"; case "completed": return "authenticated"; case "failed": return "failed"; case "timed_out": return "timed_out"; case "cancelled": return "cancelled"; } }; // Builds the fixed, non-secret failure for a terminal failure state. A live or // a completed session has no failure. The failure carries a stable reason and // no secret detail. const toClaudeLoginFailure = (state: SetupTokenSessionState): AdapterAuthSessionFailure | null => { switch (state) { case "failed": return { reason: "failed", message: null }; case "timed_out": return { reason: "timed_out", message: null }; case "cancelled": return { reason: "cancelled", message: null }; default: return null; } }; // The public login-session response. It carries no prompt and no secret. const toClaudePublicResponse = ( descriptor: SetupTokenSessionDescriptor, ): ClaudeSetupTokenSessionResponse => ({ sessionId: descriptor.sessionId, environmentId: descriptor.environmentId, status: toClaudeLoginStatus(descriptor.state), expiresAt: new Date(descriptor.deadline).toISOString(), failure: toClaudeLoginFailure(descriptor.state), }); // The company-and-environment login key the non-start routes derive. The route // path gives the company, the actor gives the owner, and the route fixes the // adapter. The service matches this key and the agentless marker. const companySetupTokenKey = (companyId: string, ownerUserId: string) => ({ companyId, ownerUserId, adapterType: SETUP_TOKEN_ADAPTER_TYPE, }); // The stored Claude OAuth token status read. It returns // only the secret id and the latest version of the owner value; it returns no // token. The client reads the version, applies the stored token first, and // captures the version for a later confirmed overwrite. The route derives the // owner only from the authenticated actor and reads the fixed Claude // definition; it accepts no owner, no definition, and no secret id as input. // // The route returns the same fixed 404 for a missing owner value as the // company gate returns for a non-member, so it discloses no existence // distinction across owners or companies. It sets `Cache-Control: no-store`, // so no cache holds the metadata. router.get("/companies/:companyId/claude-oauth-token-status", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); const status = await secretsSvc.readClaudeOAuthUserSecretStatus(companyId, ownerUserId); if (!status) { // A missing owner value returns the same fixed not-found as the non-member // gate, so a member without a value and a non-member look the same. res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND }); return; } const body: ClaudeOAuthTokenStatusResponse = status; res.json(body); }); router.post("/companies/:companyId/setup-token-login-sessions", async (req, res) => { const companyId = req.params.companyId as string; // The shared start-route spine derives the owner, validates the strict // request schema, runs the Claude-only guards, and checks the sandbox // environment before any session, lease, or pseudo-terminal side effect. // // The owner step runs the company access check, derives the owner, and then // sets `Cache-Control: no-store`, so a rejected member sees no cache header // and every other response carries it. The strict schema rejects an unknown // field, including a legacy `ttlSeconds`, with a fixed 400. The post-validate // guard rejects a non-Claude adapter with a fixed 400 and fails closed with // the fixed no-secret 503 until the live login transport binds. The sandbox // check fails closed on a missing, archived, non-sandbox, fake-provider, or // foreign environment, and on a provider without the setup-token login // capability, so no rejected environment reaches a session row, a lease, or a // pseudo-terminal. const resolved = await runAdapterLoginStartSpine({ req, res, deriveOwner: () => { assertCompanyAccess(req, companyId); const ownerUserId = deriveSetupTokenOwnerUserId(req); res.setHeader("Cache-Control", "no-store"); return ownerUserId; }, requestSchema: startClaudeSetupTokenSessionRequestSchema, invalidRequestError: "The Claude login start request is invalid.", guardAfterValidate: (data) => { // The setup-token route drives a login on a pseudo-terminal and records a // stored session identifier on success. It serves any adapter whose // registry login capability records that completion claim. The guard reads // the capability, not the adapter name, so a new adapter with the same // claim passes with no code change. It rejects an adapter with no matching // capability with a fixed 400. const capability = getRegistryLoginCapability(data.adapterType); if (capability?.completionClaim !== "storedSessionId") { res.status(400).json({ error: "This adapter does not support a setup-token login." }); return true; } // The five follow-up routes and the restart reaper both read only the // one pinned adapter type. A capability match alone is not enough: an // adapter that declares `storedSessionId` but is not the served type // would pass the check above, then create a session that no follow-up // route and no reaper scan can reach. Reject that case here, before any // sandbox assertion, lease, durable row, or pseudo-terminal, with the // same fixed 400 as the capability check above, so the response // discloses no difference between the two rejection reasons. if (data.adapterType !== SETUP_TOKEN_ADAPTER_TYPE) { res.status(400).json({ error: "This adapter does not support a setup-token login." }); return true; } if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) { res.status(503).json({ error: SETUP_TOKEN_START_FAILED }); return true; } return false; }, assertSandbox: (data) => assertSandboxLoginEnvironment(companyId, data.environmentId, { requireSetupTokenLoginProvider: true, }), }); if (!resolved) return; const { ownerUserId, data } = resolved; const { environmentId, adapterType } = data; if (data.aiConnection) { await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection); if (!isAiConnectionCompatible(data.aiConnection, adapterType)) throw unprocessable("Incompatible login method"); } const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = data.overwrite ?? null; const scope: SetupTokenSessionScope = { companyId, ownerUserId, adapterType, environmentId, confirmedOverwrite, aiConnection: data.aiConnection, }; // Read the panel mode from the adapter capability. The guard already checked // the capability, so it is present here. The client renders the panel from // this value instead of a hard-coded mode. const panelMode = getRegistryLoginCapability(adapterType)?.panelMode ?? "submitted_browser_code"; try { const started = await setupTokenLoginService.start(scope); const descriptor = setupTokenLoginService.describeOwned(started.sessionId, scope); // The start response carries the panel mode, so the client renders the // correct panel. The full login URL rides only through the guarded prompt // read, not the start response, so the prompt is null here. The client // reads the prompt route for the login URL. const body: ClaudeSetupTokenSessionOwnerResponse = { ...toClaudePublicResponse(descriptor), panelMode, prompt: null, }; res.status(201).json(body); } catch (err) { sendSetupTokenError(res, err); } }); // Read the caller's active Claude setup-token login session, with no session // id. The browser rediscovers its own session after a reload with no local // state. The response carries the panel mode and the one-time prompt, the // same owner response shape the start route returns. A caller with no active // session receives the same fixed not-found error as a foreign session. // // This route registers before the `:sessionId` route below, so Express never // matches the literal `active` segment as a session id. router.get("/companies/:companyId/setup-token-login-sessions/active", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store, private"); const descriptor = await setupTokenLoginService.findActiveByScope( companySetupTokenKey(companyId, ownerUserId), ); if (!descriptor) { res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND }); return; } // Read the panel mode from the adapter capability, the same way the start // route does. The full login URL rides in this response, guarded by the // same transport advisory the prompt route attaches. const panelMode = getRegistryLoginCapability(SETUP_TOKEN_ADAPTER_TYPE)?.panelMode ?? "submitted_browser_code"; const body: ClaudeSetupTokenSessionOwnerResponse = { ...toClaudePublicResponse(descriptor), panelMode, prompt: descriptor.loginUrl ? { authorizationUrl: descriptor.loginUrl, transportAdvisory: assessSetupTokenTransport(req) } : null, }; res.json({ ...body, ...(descriptor.aiConnection ? { aiConnection: descriptor.aiConnection } : {}) }); }); router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); try { const sessionId = req.params.sessionId as string; const scope = setupTokenLoginService.resolveCompanyScope( sessionId, companySetupTokenKey(companyId, ownerUserId), ); const descriptor = setupTokenLoginService.describeOwned(sessionId, scope); // The status response is public. It carries no prompt and no secret. res.json(toClaudePublicResponse(descriptor)); } catch (err) { sendSetupTokenError(res, err); } }); router.get("/companies/:companyId/setup-token-login-sessions/:sessionId/prompt", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); // The full login URL is a confidential response. The route // does not force TLS. It attaches a non-blocking advisory instead. const transportAdvisory = assessSetupTokenTransport(req); try { const sessionId = req.params.sessionId as string; const scope = setupTokenLoginService.resolveCompanyScope( sessionId, companySetupTokenKey(companyId, ownerUserId), ); const descriptor = setupTokenLoginService.describeOwned(sessionId, scope); if (!descriptor.loginUrl) { // The prompt has not surfaced yet. Return the same not-found error as a // missing or a foreign session, so the route never confirms the session // exists before the URL is ready. res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND }); return; } // The full login URL rides only in this authorized owner response. const body: ClaudeSetupTokenSessionPrompt = { authorizationUrl: descriptor.loginUrl, transportAdvisory, }; res.json(body); } catch (err) { sendSetupTokenError(res, err); } }); router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/code", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); // The browser code is the confidential OAuth authorization // secret. The route does not force TLS. It attaches a non-blocking advisory // to the response instead, so the client can show a disclaimer. const transportAdvisory = assessSetupTokenTransport(req); // Parse the request with the shared strict validator before the route forwards // the code to the live process. `.strict()` rejects an unknown field, and the // grammar rejects an empty, an oversized, or a control-byte code. The route // echoes no input; it returns fixed error text only. const parsed = submitBrowserCodeRequestSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: "A valid browser code is required." }); return; } try { const sessionId = req.params.sessionId as string; const scope = setupTokenLoginService.resolveCompanyScope( sessionId, companySetupTokenKey(companyId, ownerUserId), ); setupTokenLoginService.submitCode(sessionId, scope, parsed.data.browserCode); const descriptor = setupTokenLoginService.describeOwned(sessionId, scope); const body: ClaudeSetupTokenSessionResponse = { ...toClaudePublicResponse(descriptor), transportAdvisory, }; res.json(body); } catch (err) { sendSetupTokenError(res, err); } }); router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/completion", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); try { const sessionId = req.params.sessionId as string; const scope = setupTokenLoginService.resolveCompanyScope( sessionId, companySetupTokenKey(companyId, ownerUserId), ); // The service returns the non-secret `storedSessionId` claim from a // completed session whose owner-bound secret write succeeded. The response // carries no token. const result = setupTokenLoginService.completeSession(sessionId, scope); const body: ClaudeSetupTokenCompletionResponse = { storedSessionId: result.storedSessionId }; res.json(body); } catch (err) { sendSetupTokenError(res, err); } }); router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; res.setHeader("Cache-Control", "no-store"); const sessionId = req.params.sessionId as string; try { // `cancelByScope` tries the live in-memory session first, then falls back // to a durable-only cancel when no live session matches — for example, // after a restart drops the in-memory session but the durable row still // holds the company slot. await setupTokenLoginService.cancelByScope( sessionId, companySetupTokenKey(companyId, ownerUserId), ); res.status(200).json({}); } catch (err) { // Cancel is idempotent. The service removes a session when it reaches a // terminal state, so a repeat cancel, a cancel after a timeout, or a // cancel of an unknown session finds no record and throws the fixed // not-found error. Return the same success as an active cancel, so the // client stops the poll and returns to its start state. // // This keeps the not-found uniform. The 200 response is identical // for a missing session, an already-terminal session, and a foreign // session, so the route never confirms a session exists and cancels // nothing for a foreign id. A non-member still fails closed with a 404 at // the company-access gate above, before this handler runs. A non-404 // error still surfaces. if (err instanceof SetupTokenSessionError && err.status === 404) { res.status(200).json({}); return; } sendSetupTokenError(res, err); } }); router.get("/companies/:companyId/heartbeat-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertRunTelemetryReadAllowed(req, res, companyId))) return; 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 summary = req.query.summary === "true" || req.query.summary === "1"; const runs = await heartbeat.list(companyId, agentId, limit, { summary }); res.json(await runRedactions.redactForRuns(companyId, runs)); }); router.get("/companies/:companyId/provider-traces", async (req, res) => { assertInstanceAdmin(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const runIds = String(req.query.runIds ?? "") .split(",") .map((value) => value.trim()) .filter(Boolean) .slice(0, 100); const traces = await providerTraces.listMetadataForRuns(companyId, runIds); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "provider_trace.metadata_listed", entityType: "company", entityId: companyId, details: { requestedRunCount: runIds.length, traceCount: traces.length, payloadLogged: false, }, }); res.set("Cache-Control", "no-cache, no-store"); res.json(traces); }); router.get("/companies/:companyId/live-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertRunTelemetryReadAllowed(req, res, companyId))) return; // `minCount` is a padding floor for callers that want a minimum number of // recent runs to render (e.g. dashboard cards). It must default to 0 so // callers asking for "live runs" get only actually-live runs — otherwise // every caller with no minCount param gets up to 50 historical runs // padded in and renders bogus "live" counts. const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 0); const limit = readLiveRunsQueryInt(req.query.limit, 50, 50); const distinctTasks = req.query.distinctTasks === "true"; const columns = { id: heartbeatRuns.id, runtimeMode: heartbeatRuns.runtimeMode, companyId: heartbeatRuns.companyId, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, agentAppearance: agentsTable.appearance, adapterType: agentsTable.adapterType, logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, livenessReason: heartbeatRuns.livenessReason, continuationAttempt: heartbeatRuns.continuationAttempt, lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, nextAction: heartbeatRuns.nextAction, lastOutputAt: heartbeatRuns.lastOutputAt, lastOutputSeq: heartbeatRuns.lastOutputSeq, lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes, processStartedAt: heartbeatRuns.processStartedAt, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), }; const liveRunsQuery = 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)); const liveRuns = distinctTasks ? [] : await liveRunsQuery.limit(limit); let rows = liveRuns; const targetRunCount = Math.min(minCount, limit); if (distinctTasks) { // Return enough representatives for the dashboard to count cards beyond // its visible four, rather than stopping at the minimum display count. const selectedIds = await selectDashboardRunIds(db, companyId, limit); const selectedRows = selectedIds.length === 0 ? [] : await db .select(columns) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, selectedIds))); const byId = new Map(selectedRows.map((run) => [run.id, run])); rows = selectedIds.flatMap((id) => { const run = byId.get(id); return run ? [run] : []; }); } else if (targetRunCount > 0 && liveRuns.length < targetRunCount) { 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(targetRunCount - liveRuns.length); rows = [...liveRuns, ...recentRuns]; } const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ ...heartbeat.decorateActiveRunStatus(run), agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence(run), }))))); }); function readHeartbeatRunId(req: Request): string { const runId = req.params.runId as string; // isUuidLike accepts surrounding whitespace, but PostgreSQL UUID inputs do not. if (runId !== runId.trim() || !isUuidLike(runId)) { throw badRequest("Invalid heartbeat run ID"); } return runId; } router.get("/heartbeat-runs/:runId", async (req, res) => { const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId); const decoratedRun = heartbeat.decorateActiveRunStatus(run); res.json(await runRedactions.redactForRun( run.companyId, run.id, redactCurrentUserValue( { ...decoratedRun, execution: await executionProjectionForRun(db, run.companyId, run.id), identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) }, await getCurrentUserRedactionOptions(), ), )); }); router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; // Stamp the cancellation as operator-initiated (this route is board-only). // Recovery reads this to stand down instead of classifying the cancelled // run as agent stranding and re-waking the agent the operator just stopped. const run = await heartbeat.cancelRun(runId, "Cancelled by a board operator", { resultJson: { cancelledByActorType: "user", cancelledByUserId: req.actor.userId ?? null, }, }); 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.post( "/heartbeat-runs/:runId/runtime-requests/:requestId/resolve", async (req, res) => { assertBoard(req); const runId = readHeartbeatRunId(req); const requestId = req.params.requestId as string; const existing = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!existing) return; if (existing.runtimeMode !== "native" || existing.status !== "running") { throw conflict( "This runner session is no longer accepting runtime responses.", ); } if (!requestId || requestId.length > 160) { throw badRequest("A runtime request identifier is required."); } const resolutionActor: NativeRuntimeRequestResolver = { type: "user", userId: req.actor.userId ?? (req.actor.source === "local_implicit" ? "local-admin" : ""), isInstanceAdmin: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true, }; const pendingRequest = await readPendingNativeRuntimeRequest(db, { companyId: existing.companyId, runId, requestId, }); if (!pendingRequest) { throw conflict("This runtime request is stale or is no longer pending."); } try { assertNativeRuntimeRequestResolverAuthorized( pendingRequest, resolutionActor, ); } catch (error) { if (error instanceof NativeRuntimeRequestResolutionAuthorizationError) { throw forbidden( pendingRequest.resolverPolicy === "instance_admin" ? "Instance admin access is required to resolve privileged runtime approvals." : "This actor is not authorized to resolve the runtime request.", ); } throw error; } // Kind and turn are read only from the server-persisted PRP event. Body // values are deliberately ignored so a caller cannot downgrade an // approval into a human-only question or move a response across turns. const rawRequestKind = pendingRequest.requestKind; let resolution: HarnessRuntimeRequestResolution; try { if (rawRequestKind === "runtime") { const candidate = req.body?.resolution; const action = candidate?.action; if (action === "decline" || action === "cancel") { resolution = { action }; } else if ( action === "submit" && candidate?.response?.schema === "paperclip.question_response.v1" && candidate.response.answers && typeof candidate.response.answers === "object" && !Array.isArray(candidate.response.answers) ) { // The active session/durable runner validates this untrusted // response against its persisted question set immediately before // translating it back to the provider. resolution = { action: "submit", response: candidate.response }; } else { throw new HarnessRuntimeRequestResolutionError( "user_input", "runtime input requires a canonical submit, decline, or cancel", ); } } else { resolution = parseHarnessRuntimeRequestResolution( rawRequestKind as HarnessRuntimeRequestKind, req.body?.resolution, ); } } catch (error) { if (error instanceof HarnessRuntimeRequestResolutionError) { throw badRequest("Invalid runtime request response."); } throw error; } try { // Re-read the canonical lifecycle immediately before the durable // command mutation. A resolution/cancellation committed while the // response body was parsed revokes this route's authority. const currentPendingRequest = await readPendingNativeRuntimeRequest(db, { companyId: existing.companyId, runId, requestId, }); if ( !currentPendingRequest || currentPendingRequest.requestKind !== pendingRequest.requestKind || currentPendingRequest.turnId !== pendingRequest.turnId ) { throw conflict("This runtime request is stale or is no longer pending."); } let queued: { commandId: string }; try { queued = await resolveNativeRuntimeRequest({ runId, requestId, turnId: currentPendingRequest.turnId, resolution, authorizeBeforeDispatch: async () => { const dispatchPendingRequest = await readPendingNativeRuntimeRequest(db, { companyId: existing.companyId, runId, requestId, }); if ( !dispatchPendingRequest || dispatchPendingRequest.requestKind !== currentPendingRequest.requestKind || dispatchPendingRequest.turnId !== currentPendingRequest.turnId ) { throw conflict( "This runtime request is stale or is no longer pending.", ); } assertNativeRuntimeRequestResolverAuthorized( dispatchPendingRequest, resolutionActor, ); }, }); } catch (error) { if ( error instanceof NativeRuntimeRequestResolutionError && error.code === "runtime_request_resolution_conflict" ) { throw conflict( "A different response was already submitted for this runtime request.", ); } if ( !(error instanceof NativeRuntimeRequestResolutionError) || ![ "native_session_not_active", "runtime_request_resolution_unsupported", ].includes(error.code) ) { if ( error instanceof NativeRuntimeRequestResolutionError && error.code === "runtime_request_stale_turn" ) { throw conflict( "The runner session is no longer accepting runtime responses.", ); } throw error; } queued = queueRunnerPrpRuntimeRequestResolution({ companyId: existing.companyId, runId, pendingRequest: currentPendingRequest, actor: resolutionActor, resolution, }); } await logActivity(db, { companyId: existing.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "heartbeat.runtime_request_resolution_queued", entityType: "heartbeat_run", entityId: existing.id, details: { requestId, requestKind: currentPendingRequest.requestKind, resolverPolicy: currentPendingRequest.resolverPolicy, resolvedByUserId: resolutionActor.userId, action: resolution.action, }, }); res.status(202).json({ accepted: true, commandId: queued.commandId }); } catch (error) { if (error instanceof NativeRuntimeRequestResolutionAuthorizationError) { throw forbidden( "This actor is not authorized to resolve the runtime request.", ); } if (error instanceof RunnerPrpRuntimeRequestResolutionError) { throw conflict( error.code === "runtime_request_resolution_conflict" ? "A different response was already submitted for this runtime request." : "The runner session is no longer accepting runtime responses.", ); } throw error; } }, ); router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => { const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; const decision = typeof req.body?.decision === "string" ? req.body.decision : ""; if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) { res.status(400).json({ error: "Unsupported watchdog decision" }); return; } const evaluationIssueId = typeof req.body?.evaluationIssueId === "string" ? req.body.evaluationIssueId : null; const reason = typeof req.body?.reason === "string" ? req.body.reason.slice(0, 4000) : null; const snoozedUntil = decision === "snooze" ? new Date(String(req.body?.snoozedUntil ?? "")) : null; if (decision === "snooze" && (!snoozedUntil || Number.isNaN(snoozedUntil.getTime()) || snoozedUntil <= new Date())) { res.status(400).json({ error: "snoozedUntil must be a future ISO datetime" }); return; } const row = await recovery.recordWatchdogDecision({ runId: existing.id, actor: req.actor, decision: decision as "snooze" | "continue" | "dismissed_false_positive", evaluationIssueId, reason, snoozedUntil, createdByRunId: req.actor.runId ?? null, }); res.json(row); }); router.get("/heartbeat-runs/:runId/provider-trace", async (req, res) => { assertInstanceAdmin(req); const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!run) return; const inspection = await providerTraces.inspect(run.id, run.companyId); await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "local-admin", action: "provider_trace.redacted_viewed", entityType: "heartbeat_run", entityId: run.id, details: { traceId: inspection.trace?.id ?? null, rawPayloadRevealed: false, }, }); res.set("Cache-Control", "no-cache, no-store"); res.json(inspection); }); router.post( "/heartbeat-runs/:runId/provider-trace/reproject-workspace-diffs", async (req, res) => { assertBoard(req); const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!run) return; const trace = await providerTraces.getByRun(run.id, run.companyId); let unavailable: WorkspaceDiffReprojectionSkipReason | null = null; if (!trace || trace.deletedAt) unavailable = { reason: "trace_unavailable" }; else if (trace.expiresAt <= new Date()) unavailable = { reason: "trace_expired" }; else if (trace.status !== "complete") unavailable = { reason: "trace_incomplete" }; if (unavailable !== null) { res.json({ created: 0, skipped: 1, skipReasons: [unavailable] }); return; } const entries = await providerTraces .readExactEntries(run.id, run.companyId) .catch(() => null); if (entries === null) { res.json({ created: 0, skipped: 1, skipReasons: [{ reason: "trace_unavailable" }], }); return; } const result = await persistReprojectedWorkspaceDiffs(db, { traceId: trace.id, runId: run.id, companyId: run.companyId, agentId: run.agentId, projection: projectCodexWorkspaceDiffsFromTrace(entries), }); await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "local-board", action: "provider_trace.workspace_diffs_reprojected", entityType: "heartbeat_run", entityId: run.id, details: { traceId: trace.id, created: result.created, skipped: result.skipped, providerActionsReplayed: 0, }, }); res.json(result); }, ); router.post( "/heartbeat-runs/:runId/provider-trace/frames/:frameId/reveal", async (req, res) => { assertInstanceAdmin(req); const runId = readHeartbeatRunId(req); const frameId = Number(req.params.frameId); if (!Number.isSafeInteger(frameId) || frameId < 1) { throw badRequest("Invalid provider trace frame id"); } const run = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!run) return; const frame = await providerTraces.revealFrame( run.id, run.companyId, frameId, ); if (!frame) throw notFound("Provider trace frame not found"); await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "local-admin", action: "provider_trace.frame_revealed", entityType: "heartbeat_run", entityId: run.id, details: { frameId, digest: frame.digest, byteLength: frame.byteLength, }, }); res.set("Cache-Control", "no-cache, no-store"); res.json(frame); }, ); router.get( "/heartbeat-runs/:runId/provider-trace/download", async (req, res) => { assertInstanceAdmin(req); const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!run) return; const download = await providerTraces.download(run.id, run.companyId); if (!download) throw notFound("Provider trace not found"); await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "local-admin", action: "provider_trace.downloaded", entityType: "heartbeat_run", entityId: run.id, details: { traceId: download.row.id, byteCount: download.bytes.byteLength, digest: download.row.digest, }, }); res.set("Cache-Control", "no-cache, no-store"); res.set("Content-Type", "application/x-ndjson"); res.set( "Content-Disposition", `attachment; filename=provider-trace-${run.id}.ndjson`, ); res.send(download.bytes); }, ); router.delete("/heartbeat-runs/:runId/provider-trace", async (req, res) => { assertInstanceAdmin(req); const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, heartbeat.getRun(runId), "Heartbeat run not found", ); if (!run) return; const removed = await providerTraces.remove(run.id, run.companyId); if (!removed) throw notFound("Provider trace not found"); await logActivity(db, { companyId: run.companyId, actorType: "user", actorId: req.actor.userId ?? "local-admin", action: "provider_trace.deleted", entityType: "heartbeat_run", entityId: run.id, details: { traceId: removed.id, recoverable: false }, }); res.json({ ok: true }); }); router.get("/heartbeat-runs/:runId/events", async (req, res) => { const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; 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 currentUserRedactionOptions = await getCurrentUserRedactionOptions(); const redactedEvents = events.map((event) => redactCurrentUserValue({ ...event, payload: redactEventPayload(event.payload), }, currentUserRedactionOptions), ); res.json(await runRedactions.redactForRun(run.companyId, run.id, redactedEvents)); }); router.get("/heartbeat-runs/:runId/log", async (req, res) => { const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; const offset = Number(req.query.offset ?? 0); const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await heartbeat.readLog(run, { offset: Number.isFinite(offset) ? offset : 0, limitBytes, }); res.set("Cache-Control", "no-cache, no-store"); res.json(await runRedactions.redactForRun(run.companyId, run.id, result)); }); router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => { const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; const context = asRecord(run.contextSnapshot); const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId); const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId); res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions())); }); router.get("/workspace-operations/:operationId/log", async (req, res) => { const operationId = req.params.operationId as string; const operation = await getAccessibleResource(req, res, workspaceOperations.getById(operationId), "Workspace operation not found"); if (!operation) return; if (!(await assertRunTelemetryReadAllowed(req, res, operation.companyId))) return; const offset = Number(req.query.offset ?? 0); const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await workspaceOperations.readLog(operationId, { offset: Number.isFinite(offset) ? offset : 0, limitBytes, }); res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); router.get("/issues/:issueId/live-runs", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); const identifier = normalizeIssueIdentifier(rawId); const issue = await getAccessibleResource( req, res, identifier ? issueSvc.getByIdentifier(identifier) : issueSvc.getById(rawId), "Issue not found", ); if (!issue) return; const liveRuns = await db .select({ id: heartbeatRuns.id, runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, agentAppearance: agentsTable.appearance, adapterType: agentsTable.adapterType, logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, livenessReason: heartbeatRuns.livenessReason, continuationAttempt: heartbeatRuns.continuationAttempt, lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, nextAction: heartbeatRuns.nextAction, lastOutputAt: heartbeatRuns.lastOutputAt, lastOutputSeq: heartbeatRuns.lastOutputSeq, lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes, processStartedAt: heartbeatRuns.processStartedAt, }) .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)); const projections = await executionProjectionsForRuns(db, issue.companyId, liveRuns.map(run => run.id)); res.json(await Promise.all(liveRuns.map(async (run) => ({ ...heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }), agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }), })))); }); router.get("/issues/:issueId/execution", async (req, res) => { const issue = await getAccessibleResource(req, res, issueService(db).getById(req.params.issueId as string), "Issue not found"); if (!issue) return; const [run] = await db.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(and( eq(heartbeatRuns.companyId, issue.companyId), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, )).orderBy(sql`case when ${heartbeatRuns.id} = ${issue.executionRunId} then 0 when ${heartbeatRuns.status} = 'running' then 1 else 2 end`, desc(heartbeatRuns.createdAt)).limit(1); res.json(run ? { runId: run.id, agentId: run.agentId, recoveryAction: await issueRecoveryActionService(db).getActiveForIssue(issue.companyId, issue.id), execution: await executionProjectionForRun(db, issue.companyId, run.id) } : null); }); router.get("/issues/:issueId/active-run", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); const identifier = normalizeIssueIdentifier(rawId); const issue = await getAccessibleResource( req, res, identifier ? issueSvc.getByIdentifier(identifier) : issueSvc.getById(rawId), "Issue not found", ); if (!issue) return; let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null; if ( run && ( (run.status !== "queued" && run.status !== "running") || run.issueId !== issue.id ) ) { run = null; } if (!run && issue.assigneeAgentId && issue.status === "in_progress") { const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId); const candidateIssueId = asNonEmptyString(candidateRun?.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; } const decoratedRun = heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }); res.json({ ...decoratedRun, execution: await executionProjectionForRun(db, issue.companyId, run.id), agentId: agent.id, agentName: agent.name, agentAppearance: agent.appearance, avatarUrl: agent.avatarUrl, adapterType: agent.adapterType, outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }), }); }); return router; } import { listRunIdentityContexts } from "../services/run-identity.js";