Files
PaperClipAI/server/src/routes/agents.ts
T
DottaandPaperclip 5c7a025032 fix(ui): clear consumed messages from the pending queue (#16317)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task conversations keep follow-up messages pending while an agent
works.
> - The screen combines optimistic sends, queue snapshots, and run input
records.
> - A late send response can restore a local queue flag after a run
takes the message.
> - A stale queue snapshot can then keep the same message in pending.
> - This pull request matches the input record to its source queue and
clears stale pending flags.
> - The benefit is that consumed messages leave pending while unsent
messages stay visible.

## Linked Issues or Issue Description

**What happened?**

A follow-up can remain in the task composer's pending row after a run
takes it as input. Local queue state can outlive the send request. The
old projection cleared that state for steering, but not for ordinary
run-start input. A stale queue read can also retain the consumed entry.

**Expected behavior**

A message that a started run takes as input appears once in history and
leaves pending. Other pending messages remain available. A message
assigned to a run that has not started remains pending.

**Steps to reproduce**

1. Send a follow-up while a task has a live run.
2. Let a started run record that message as input before or after the
send response arrives.
3. Let a queue read still include the consumed message.
4. Observe the message remain pending on the base revision.

**Paperclip version or commit**

Reproduced against master at `33d24e48ad` with controlled request
ordering.

**Deployment mode**

Source checkout. Browser verification used the production task screen
with a simulated native runner and delayed API responses. It did not run
a live model or change a deployed instance.

Related: [PR
#15030](https://github.com/paperclipai/paperclip/pull/15030) addresses
acknowledgement matching in the runner console. This change addresses
the task-detail composer and does not modify that console.

## What Changed

- Clear local pending flags when a started run records the comment as
input.
- Use live-run input IDs while the run-history query catches up.
- Match stale queue entries by their primary or coalesced source wake
ID, even when the snapshot targets the previous run or has no target.
- Retain coalesced source queue IDs in run context and include receipt
IDs in compact task run reads.
- Keep unstarted run inputs and authoritative retries pending. Cover
send ordering, same-run and successor delivery, queued snapshots,
coalesced inputs, and retry consumption.
- Add the delivery-race checks to the steering feature map.

## Verification

- Before the fix, the first four request-order regressions failed with
the consumed message still in pending. Coverage now includes 16
combinations of receipt source, send-response order, and stale snapshot
kind.
- Focused UI checks: 5 files and 233 tests passed. These cover task
detail, queue projection, optimistic comments, queued-message controls,
and the task-chat adapter.
- `pnpm -r typecheck` passed.
- Server receipt checks: 5 files and 139 tests passed with
`PAPERCLIP_TEST_POSTGRES_TEMPLATE=0`. These exercise real database
reads, coalesced heartbeat input, and polling routes. The default
cached-template path timed out during setup on this host; the
fresh-cluster path passed without longer timeouts.
- Token gates and `node scripts/check-maps.mjs` passed.
- Browser: sent a follow-up with a delayed response in the production
task screen. The consumed message moved into history. A second unsent
follow-up stayed queued. Reload preserved both states. The API and
runner were simulated.
- `pnpm build` passed.
- The full local repository test run was started, then stopped to
isolate database setup timeouts. All 55 GitHub CI checks passed on
`4ec6820cf1f89880b801fd5f938d72f54591c5e2`, including the full test
matrix. One serialized server shard passed on its single rerun after a
15-second route-test timeout; the same 97-test route suite also passed
locally.
- Greptile reviewed the final head at 5/5. All review threads are
resolved.

## Risks

- Queue display depends on matching comment IDs, source queue IDs, and a
recorded start time. Rows without start evidence stay pending. Newer
queues remain visible until their own input receipt arrives.
- Run context gains additive receipt metadata. No schema migration is
required. Dispatch order, stored queue state, company access, and
steering authority stay unchanged.
- Browser evidence covers controlled response ordering. It does not
establish deployment or live-provider behavior.

## Model Used

- OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The
exact serving model ID and context-window limit are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 16:11:29 -05:00

7518 lines
329 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
import { agentIdentityService } from "../services/agent-identity.js";
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
import { publicMcpConfig } from "../services/public-mcp/oauth.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { completeConnectionIntentSchema } from "@paperclipai/shared";
import { HEARTBEAT_RUN_LIST_DEFAULT_LIMIT, HEARTBEAT_RUN_LIST_MAX_LIMIT } 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 { withManagedAiProbe, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
import {
ADAPTER_AUTH_MISSING_CHECK_CODE,
aiConnectionBindingSchema,
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 } 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 { applyMcpReasoningEffort } from "../services/public-mcp/agent-config.js";
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 { 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 {
canActorReadHeartbeatRun,
getHeartbeatRunReadDecisions,
canActorReadWorkspaceOperation,
redactHeartbeatRunListRow,
} from "../services/heartbeat-run-privacy.js";
import {
PaperclipRunnerProviderProfileError,
resolvePaperclipRunnerProviderProfile,
validatePaperclipRunnerDotConfig,
validatePaperclipRunnerGrokConfig,
} 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;
type RunReadBinding = {
companyId: string;
scopeKind?: "company" | "issue" | null;
issueId?: string | null;
};
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<string, unknown> | 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_<handle>` 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<string> },
companyId: string,
secret: { id: string },
secretName: string,
expectedAccountHomeDir: string,
): Promise<void> {
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_<handle>` 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: {
hasInstanceAccountHomeClaim: (companyId: string, accountHomeDir: string) => Promise<boolean>;
list: (companyId: string) => Promise<Array<{ id: string; name: string; provider: string }>>;
resolveSecretValueForDeviceLoginCheck: (
companyId: string,
secretId: string,
context: { configPath: string },
) => Promise<string>;
},
companyId: string,
accountHomeDir: string,
): Promise<boolean> {
if (await secretsSvc.hasInstanceAccountHomeClaim(companyId, accountHomeDir)) return true;
const checkedSecretIds = new Set<string>();
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<string, Promise<void>>();
async function withHireRunLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = hireRunLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((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<ChatChannelService, "prepareFailedChatRunRetry" | "processFailedChatRunRetry">;
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<string, string> = {
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<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const access = accessService(db);
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const environmentsSvc = environmentService(db);
const { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment } = agentEnvironmentTestService(db, options.pluginWorkerManager);
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<SetupTokenLease> {
// 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<void> {
await environmentsSvc.releaseLease(lease.id, "released").catch(() => {});
},
async releaseById(leaseId): Promise<void> {
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<string, SetupTokenCleanupRecord>();
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<void> {
setupTokenCleanupRows.set(record.sessionId, { ...record });
},
async markState(identity, state): Promise<void> {
const row = setupTokenCleanupRows.get(identity.sessionId);
if (row && scopeMatchesRow(row, identity)) row.state = state;
},
async remove(identity): Promise<void> {
// 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<SetupTokenCleanupRecord[]> {
return [];
},
async consumeStoredClaim(identity): Promise<SetupTokenCleanupRecord | null> {
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<SetupTokenCleanupRecord | null> {
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<SetupTokenCleanupRecord | null> {
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<SetupTokenLease> {
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";
async function actorCanReadRun(req: Request, run: RunReadBinding) {
return canActorReadHeartbeatRun(db, access, req.actor, run);
}
async function assertRunReadAllowed(
req: Request,
res: Response,
run: RunReadBinding,
notFoundMessage = "Heartbeat run not found",
) {
if (await actorCanReadRun(req, run)) return true;
res.status(404).json({ error: notFoundMessage });
return false;
}
// 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<Record<AgentAdapterType, CredentialPromotion>>,
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<string, AbortController>();
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<T extends Record<string, unknown>>(
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);
}
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
}
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function buildAgentAccessState(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
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<Awaited<ReturnType<typeof svc.getById>>>,
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<Awaited<ReturnType<typeof svc.getById>>>) {
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<Awaited<ReturnType<typeof svc.getById>>>) {
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<string> {
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<void> {
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<boolean> {
const environment = await environmentsSvc.getById(environmentId);
const config =
environment?.config && typeof environment.config === "object"
? (environment.config as Record<string, unknown>)
: {};
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<void> {
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<void> {
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<AdapterAuthSessionOwnerResponse | null> {
// 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<Awaited<ReturnType<typeof svc.getById>>>,
payload: Record<string, unknown> | 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, config?: unknown): Promise<string> {
const adapterType = assertKnownAdapterType(type);
if (adapterType === "paperclip_runner") {
const experimental = await instanceSettings.getExperimental();
if (asRecord(config)?.provider === "openai_dot") {
if (experimental.enableOpenAiDot !== true) throw unprocessable(
"OpenAI Dot is experimental and disabled on this instance.",
{ code: "paperclip_runner_dot_disabled" },
);
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && 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<string, unknown>,
): Promise<void> {
if (adapterType !== "paperclip_runner") return;
let profile;
try {
if (adapterConfig.provider === "grok_bot") {
validatePaperclipRunnerGrokConfig(adapterConfig, false);
return;
}
if (adapterConfig.provider === "openai_dot") {
validatePaperclipRunnerDotConfig(adapterConfig, false);
return;
}
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<string, unknown>;
nextAdapterConfig: Record<string, unknown>;
}): Record<string, unknown> {
if (
input.nextAdapterType !== "paperclip_runner"
|| input.previousAdapterType === input.nextAdapterType
) {
return input.nextAdapterConfig;
}
if (input.nextAdapterConfig.provider === "openai_dot") 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<string, unknown>
: {};
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<string | null> {
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<string> {
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<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<string, unknown>,
nextAdapterConfig: Record<string, unknown>,
) {
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<string, unknown> {
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<string, unknown>,
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<string, unknown>;
constraintAdapterConfig?: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
if (agentHarnessType(adapterType ?? "", adapterConfig) !== "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<string, readonly string[]> = {
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<string, unknown> {
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<string, unknown>,
runtimeConfig: unknown,
): Promise<{ adapterConfig: Record<string, unknown>; inheritedFixedClaudeOAuthBinding: boolean }> {
const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false };
if (asRecord(runtimeConfig)?.aiConnection) return noInheritance;
if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance;
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness];
if (!credentialKeys) return noInheritance;
const parent = await svc.getById(req.actor.agentId);
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : 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<string, unknown> = { ...(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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
) {
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<string, unknown>) {
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<T extends {
id: string;
companyId: string;
name: string;
role: string;
adapterType: string;
adapterConfig: unknown;
}>(
agent: T,
input?: { files: Record<string, string>; entryFile?: string },
): Promise<T> {
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<string, string>; 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<string, unknown>) {
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<string, unknown> | 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<typeof agentInstructionsBundleMode>[0],
) {
if (agentInstructionsBundleMode(agent) === "external") {
assertInstanceAdmin(req);
}
}
function adapterConfigTouchesInstructionsConfig(adapterConfig: Record<string, unknown>) {
return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined);
}
function assertNoAgentAdapterConfigMutation(
req: Request,
adapterConfig: Record<string, unknown>,
path = "adapterConfig",
) {
if (req.actor.type === "agent" && ["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId", "externalBindingId"].some(key => hasOwn(adapterConfig, key)))
throw forbidden("Only an operator can configure external-agent pairing, attachment access, or workspace access.");
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<string, readonly string[]> = {
...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<boolean> {
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<string, unknown>,
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<string, unknown>) {
const changedTopLevelKeys = Object.keys(patch).sort();
const details: Record<string, unknown> = { 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,
adapterConfig?: Record<string, unknown>,
): AgentDesiredSkillEntry[] | undefined {
if (adapterType === "paperclip_runner" && ["openai_dot", "grok_bot"].includes(String(adapterConfig?.provider))) return 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<string | AgentDesiredSkillEntry> | undefined,
): AgentDesiredSkillEntry[] | undefined {
if (!requestedDesiredSkills) return undefined;
const out = new Map<string, AgentDesiredSkillEntry>();
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<string, unknown>,
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<string, unknown>,
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<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | 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<ReturnType<typeof svc.getById>>) {
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<T extends { adapterConfig?: unknown } | null | undefined>(
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<string, unknown>),
};
}
function redactAgentConfiguration(agent: Awaited<ReturnType<typeof svc.getById>>) {
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<string, unknown>,
existingConfig: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {};
const record = snapshot as Record<string, unknown>;
return {
...record,
adapterConfig: redactAgentAdapterConfig(
typeof record.adapterConfig === "object" && record.adapterConfig !== null
? (record.adapterConfig as Record<string, unknown>)
: {},
),
runtimeConfig: redactEventPayload(
typeof record.runtimeConfig === "object" && record.runtimeConfig !== null
? (record.runtimeConfig as Record<string, unknown>)
: {},
),
metadata:
typeof record.metadata === "object" && record.metadata !== null
? redactEventPayload(record.metadata as Record<string, unknown>)
: record.metadata ?? null,
};
}
function redactConfigRevision(
revision: Record<string, unknown> & { beforeConfig: unknown; afterConfig: unknown },
) {
return {
...revision,
beforeConfig: redactRevisionSnapshot(revision.beforeConfig),
afterConfig: redactRevisionSnapshot(revision.afterConfig),
};
}
function toLeanOrgNode(node: Record<string, unknown>): Record<string, unknown> {
const reports = Array.isArray(node.reports)
? (node.reports as Array<Record<string, unknown>>).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<string, { id: string; label: string }>();
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);
});
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, 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<string>();
let compatible = false;
for (const member of pool.members) {
let runtime: ReturnType<typeof poolMemberRuntimeConfig>;
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 managedAiCredentialHome = adapterType === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, managedAiCredentialHome, 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;
let type = assertKnownAdapterType(req.params.type as string);
await assertCanCreateAgentsForCompany(req, companyId);
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<string, unknown>;
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);
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 retainsSavedExecution = savedAgent && req.body.runner === undefined
&& savedAgent.adapterType === type
&& agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig);
const selection = retainsSavedExecution
? { adapterType: type, adapterConfig: adapterConfigForTest }
: await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: type, adapterConfig: adapterConfigForTest,
runner: req.body.runner,
defaultEnvironmentId: requestedEnvironmentId,
});
type = selection.adapterType;
adapterConfigForTest = selection.adapterConfig;
const adapter = requireServerAdapter(type);
if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, 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;
}
if (type === "paperclip_runner" && inputAdapterConfig.provider === "openai_dot") {
const dotEnabled = await dotRunnerBroker(db).enabled();
const binding = savedAgentId ? await dotRunnerBroker(db).bindingForAgent(companyId, savedAgentId) : null;
let resource: ReturnType<typeof publicMcpConfig> = null;
try { resource = publicMcpConfig(process.env); } catch { /* diagnostic below */ }
let environment = requestedEnvironmentId ? await environmentsSvc.getById(requestedEnvironmentId) : null;
const managedOnly = isCloudManagedInstance() || (await instanceSettings.getExperimental()).enableManagedSandboxOnly === true;
if (managedOnly && (!environment || environment.driver === "local")) {
environment = await environmentsSvc.findManagedSandboxEnvironment(companyId);
}
const controllerAvailable = !managedOnly || environment?.driver === "sandbox";
const checks: AdapterEnvironmentCheck[] = [
{ code: "dot_enabled", level: dotEnabled ? "info" : "error", message: dotEnabled ? "Dot is enabled." : "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings." },
{ code: "dot_public_endpoint", level: resource?.origin.startsWith("https://") ? "info" : "error", message: resource?.origin.startsWith("https://") ? "Public HTTPS MCP origin is configured." : "Configure a stable public HTTPS PAPERCLIP_PUBLIC_URL." },
{ code: "dot_unmetered", level: inputAdapterConfig.allowUnmeteredProvider === true ? "info" : "error", message: "Dot usage and provider cost are unavailable. Explicit externally billed provider acknowledgement is required." },
{ code: "dot_binding", level: binding?.status === "ready" && binding.subscriptionVerified && binding.id === inputAdapterConfig.dotBindingId ? "info" : "warn", message: binding?.status === "ready" && binding.subscriptionVerified ? "Binding has a verified event subscription and completed readiness challenge." : "Save this agent, pair it, subscribe to mailbox events and complete the event test." },
{ code: "dot_controller", level: controllerAvailable ? "info" : "error", message: controllerAvailable
? environment?.driver === "sandbox" ? "Dot assignments use the selected managed sandbox Runner." : "Dot assignments use the selected Runner environment."
: "Restore the managed sandbox before assigning Dot work. Dot cannot run on the Cloud control-plane host." },
...(environment?.driver === "sandbox" && inputAdapterConfig.dotWorkspaceAccess === true
? [{ code: "dot_workspace", level: "error" as const, message: "Managed Dot runners do not expose workspace access. Turn off workspace access before assigning work." }] : []),
];
res.json({ adapterType: type, status: checks.some(c => c.level === "error") ? "fail" : checks.some(c => c.level === "warn") ? "warn" : "pass", testedAt: new Date().toISOString(), checks });
return;
}
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:<id> 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 managedAiCredentialHome = type === "paperclip_runner" && asRecord(managed.config)?.provider === "codex" && managed.home
? path.join(managed.home, "provider") : undefined;
const tested = await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, managedAiCredentialHome, 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<AdapterAuthSignal> {
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<AdapterAuthSignal> {
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<ReturnType<typeof adapterLoginService.start>>;
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<string, unknown>,
);
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<string, unknown>,
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<string, unknown>));
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<string, unknown>));
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<string, unknown>));
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/identity", async (req, res) => {
const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!agent || !(await assertAgentReadAllowed(req, res, agent))) return;
res.json(await agentIdentityService(db).getPublicIdentity(agent.companyId, agent.id));
});
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,
);
const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {};
const existingRollbackProvider = asRecord(existing.adapterConfig)?.provider;
if (rollbackAdapterType !== existing.adapterType || (rollbackAdapterType === "paperclip_runner"
&& rollbackAdapterConfig.provider !== existingRollbackProvider
&& (rollbackAdapterConfig.provider === "openai_dot" || existingRollbackProvider === "openai_dot"))) {
await assertSelectableAdapterType(rollbackAdapterType, rollbackAdapterConfig);
}
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,
runner: hireRunner,
...hireInput
} = req.body;
const hiredAgentId = randomUUID();
const requestedHireConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedHireConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, hireInput.adapterType ?? "process", requestedHireConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, hireInput.adapterType ?? "process", Object.keys(requestedHireConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: hiredAgentId, companyId, name: hireInput.name, adapterConfig: requestedHireConfig });
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<string, unknown>;
const requestedRuntime = (hireInput.runtimeConfig ?? {}) as Record<string, unknown>;
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;
}
if (inheritRuntimeFrom === "caller" && hireRunner !== undefined) throw unprocessable("inheritRuntimeFrom=caller cannot be combined with runner");
Object.assign(hireInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...hireInput, runner: hireRunner }));
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
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 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",
requestedAdapterConfig,
),
),
"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<string, unknown> }> => {
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<string, unknown> | null)?.hireFingerprint === requestFingerprint,
);
if (match) {
const existingAgent = await svc.getById(match.entityId);
if (existingAgent && existingAgent.status !== "terminated") {
const priorApprovalId = (match.details as Record<string, unknown> | 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 svcLifecycle.requestHire(
companyId,
{
id: hiredAgentId,
...normalizedHireInput,
status,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
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<ReturnType<typeof approvalsSvc.getById>> | null = null;
const actor = getActorInfo(req);
if (requiresApproval) {
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
const requestedAdapterConfig =
redactEventPayload(
(agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record<string, unknown>,
) ?? {};
const requestedRuntimeConfig =
redactEventPayload(
(normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record<string, unknown>,
) ?? {};
const requestedMetadata =
redactEventPayload(
((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record<string, unknown>),
) ?? {};
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,
runner: createRunner,
...createInput
} = req.body;
const agentId = randomUUID();
const requestedCreateConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
assertNoAgentAdapterConfigMutation(req, requestedCreateConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId));
assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0);
assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig });
Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner }));
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
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 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",
requestedAdapterConfig,
),
),
"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 svcLifecycle.requestHire(
companyId,
{
id: agentId,
...createInput,
adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizedRuntimeConfig,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
},
{
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
runnerResolved: true,
responsibleUserId: responsibleUserForAiRequest(req),
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<string, unknown> = { ...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) => {
if (req.actor.source === "mcp_oauth" && req.body.path === "promptTemplate.legacy.md") {
throw unprocessable("Migrate legacy prompt instructions to a managed instruction file in Paperclip before editing through an assistant", { code: "MCP_LEGACY_INSTRUCTIONS_UNVERSIONED" });
}
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) {
await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId);
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.");
const completed = await intents.complete(interactionId, connectionId, userId, {
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection },
});
// Dispatch the committed continuation now. The persisted delivery remains
// available to the sweeper if this process exits or dispatch is deferred.
await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId);
res.json(completed);
});
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<string, unknown>) };
const runner = patchData.runner as AgentRunnerChoice | undefined;
delete patchData.runner;
const requestedStatus = patchData.status;
delete patchData.status;
if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status");
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.
if (runner !== undefined) {
await assertCanUpdateAgent(req, existing);
const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig;
Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, {
adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType,
adapterConfig: config, runner,
defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId,
}));
}
const nextAdapterType = hasOwn(patchData, "adapterType")
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
: existing.adapterType;
const requestedAdapterType = nextAdapterType === existing.adapterType
? nextAdapterType
: await assertSelectableAdapterType(nextAdapterType, patchData.adapterConfig);
let requestedRuntimeConfig: Record<string, unknown> | 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 = req.actor.source === "mcp_oauth" ? { ...existing.runtimeConfig, ...runtimeConfig } : 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 (req.actor.source === "mcp_oauth" && requestedAdapterConfig) {
rawEffectiveAdapterConfig = applyMcpReasoningEffort(requestedAdapterType, rawEffectiveAdapterConfig, requestedAdapterConfig);
}
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;
// Moving between the standalone Dot choice and general Runner is a new
// selection. Preserve historical edits within the general Runner rollout.
if (!changingAdapterType && requestedAdapterType === "paperclip_runner"
&& rawEffectiveAdapterConfig.provider !== existingRunnerProvider
&& (rawEffectiveAdapterConfig.provider === "openai_dot" || existingRunnerProvider === "openai_dot")) {
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
}
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<string, unknown>;
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 updateOptions = {
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,
},
};
const command = requestedStatus === "paused" ? "pause" : requestedStatus === "idle" ? "resume" : requestedStatus === "terminated" ? "terminate" : null;
const agent = command
? await svcLifecycle.updateAndTransition(id, command, patchData, updateOptions)
: await svc.update(id, patchData, updateOptions);
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/lifecycle/retry", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await getAccessibleAgent(req, res, id);
if (!existing) return;
await assertCanUpdateAgent(req, existing);
const agent = await svcLifecycle.retry(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.lifecycle_retried", entityType: "agent", entityId: agent.id });
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 svcLifecycle.pauseAgent(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 svcLifecycle.resumeAgent(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;
}
const decision = await approvalsSvc.approveHire(id, req.actor.userId ?? "board");
const agent = decision?.agent;
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
if (!decision.applied) {
res.status(409).json({ error: "Only pending approval agents can be approved" });
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: decision.approval?.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;
}
const agent = existing.status === "pending_approval"
? (await approvalsSvc.rejectHire(id, req.actor.userId ?? "board"))?.agent
: await svcLifecycle.terminateAgent(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<Awaited<ReturnType<typeof svc.getById>>>, payload: Record<string, unknown> | null) => unknown | Promise<unknown>;
};
const handleWakeupRoute = async (
req: Request,
res: Response,
opts: WakeupRouteOpts,
): Promise<void> => {
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<string, unknown> = {};
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<string, unknown> = {
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<typeof heartbeat.wakeup>[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<string, unknown>;
}
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.<KEY> 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(HEARTBEAT_RUN_LIST_MAX_LIMIT, parseInt(limitParam, 10) || HEARTBEAT_RUN_LIST_DEFAULT_LIMIT))
: HEARTBEAT_RUN_LIST_DEFAULT_LIMIT;
const summary = req.query.summary === "true" || req.query.summary === "1";
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
const readDecisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs);
res.json(await runRedactions.redactForRuns(companyId, runs.map((run, index) =>
readDecisions[index] ? run : redactHeartbeatRunListRow(run)),
req.actor.type === "board" ? getActorInfo(req).actorId : null));
});
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<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${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,
scopeKind: heartbeatRuns.scopeKind,
issueId: heartbeatRuns.issueId,
inputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
cachedInputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"),
outputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"),
totalTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"),
costUsd: sql<number | null>`coalesce(
(${heartbeatRuns.resultJson} ->> 'costUsd')::numeric,
(${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric,
(${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric
)`.as("costUsd"),
};
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) => !(await actorCanReadRun(req, run)) ? redactHeartbeatRunListRow(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),
}))), req.actor.type === "board" ? getActorInfo(req).actorId : null));
});
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;
if (!(await assertRunReadAllowed(req, res, run))) 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(),
),
req.actor.type === "board" ? getActorInfo(req).actorId : null,
));
});
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;
if (!(await assertRunReadAllowed(req, res, existing))) return;
const requestId = cancellationRequestId(req.body?.cancellationRequestId);
// 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", {
...(requestId ? { cancellationRequestId: requestId, cancellationRequestedByUserId: req.actor.userId ?? null } : {}),
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 ? await runRedactions.redactForRun(run.companyId, run.id, run,
getActorInfo(req).actorId) : null);
});
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;
if (!(await assertRunReadAllowed(req, res, 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;
if (!(await assertRunReadAllowed(req, res, run))) 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;
if (!(await assertRunReadAllowed(req, res, run))) 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;
if (!(await assertRunReadAllowed(req, res, run))) return;
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
const visibleOperations = [];
for (const operation of operations) {
if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation);
}
res.json(redactCurrentUserValue(visibleOperations, 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;
if (!(await canActorReadWorkspaceOperation(db, access, req.actor, operation))) {
res.status(404).json({ error: "Workspace operation not found" });
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,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
queuedCommentSourceWakeIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`.as("queuedCommentSourceWakeIds"),
status: heartbeatRuns.status,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
contextWakeCommentId: sql<string | null>`${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"]),
eq(heartbeatRuns.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";