mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## 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>
7518 lines
329 KiB
TypeScript
7518 lines
329 KiB
TypeScript
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";
|