mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - When someone messages an agent, the turn must start fast. Time to first token (TTFT) is a first-class metric. > - The Phase 0 baseline (#19846) found that every local turn launches a new runner and loads the provider session again, because `per_turn` is the default lifecycle. That made a follow-up message slower than a new task. > - Warm runners keep the runner and provider session alive between runs. #19874 fixed the defect that broke warm OpenCode follow-ups, and #19879 bounds an idle warm runner whose controller dies. > - This pull request makes warm the default for local Codex and OpenCode agents, and migrates existing agents that only carried the old creation default. > - The benefit: follow-up TTFT p50 drops from 12.0 s to 4.7 s (Codex) and from 21.7 s to 4.2 s (OpenCode), with no new restart-chaos failures. ## Linked Issues or Issue Description Refs #19846 (TTFT baseline), #19852 (startup sub-spans), #19874 (warm completion contract), #19879 (warm reconnect grace). **What existing behavior does this improve?** The default runner lifecycle for native agents, and how a follow-up message starts. **Current behavior** Agent creation writes `lifecycleMode: "per_turn"` into every native agent. Every follow-up launches a new runner and restores the provider session: 4.2 s (Codex) and 16.3 s (OpenCode) at p50 before the turn starts. **Proposed behavior** New native agents store no lifecycle. On a local target, an unset lifecycle resolves to `warm` with the default 5-minute idle window for Codex and OpenCode. Other harnesses, remote targets and managed providers without warm support stay `per_turn`. An explicit choice always wins. Migration 0443 clears the stored creation default from existing agents, except where an edit set "Turn by turn" explicitly. **Reason and benefit** Measured locally (`doc/plans/2026-11-07-warm-follow-up-ttft.md`). Warm follow-ups are 2.6–5× faster for Codex and OpenCode, and restart recovery latency is equal or lower. ## What Changed - `native-lifecycle.ts`: an unset `agentMode` resolves to warm (5-minute idle window) for local `codex_app_server` and `opencode_server` agents. `runtime-selection.ts` passes `localTarget`. Explicit `per_turn` or `warm` wins. An admitted session keeps its persisted policy. ACPX Claude stays `per_turn`: it relaunches its provider process on every follow-up by design, so warm did not shorten its follow-up (10.7 s warm vs 10.5 s per_turn). - Creation and configuration writes no longer store `lifecycleMode: "per_turn"` for Codex, Claude or OpenCode. This covers `normalizePaperclipRunnerAdapterConfig` (runs on every create and update), `paperclipRunnerTransitionConfig` (switching to the native runner), `agent-runner-selection.ts`, `codex-local` `build-config.ts`, and the simplified new-agent setup (`paperclipRunnerCreationLifecycle` in `@paperclipai/shared`). Grok, Pi and Cursor keep their explicit value. - Agent form: the Runner lifecycle field shows the unset state as "Automatic". "Turn by turn" and "Warm session" remain. - Migration `0443_native_warm_follow_up_default.sql`: clears `lifecycleMode = 'per_turn'` from native Codex, OpenCode and Claude agents unless `agent_config_revisions` shows an edit that set it to `per_turn`. Each migrated agent gets an `agent.updated` activity entry with the old value (`actor_id = migration:native-warm-follow-up-default`). Creation records no revision, so a "Turn by turn" pick made at creation cannot be told apart from the old default. The product owner approved migrating those agents. Claude agents follow the default, which is `per_turn` today. - Restart chaos harness and TTFT driver: `--lifecycle per_turn|warm|default` (saved in the wave plan). `default` removes the catalog's pinned lifecycle, so a wave exercises the real default path. - Docs: `doc/DEVELOPING.md` "Runner lifecycle default", and the results in `doc/plans/2026-11-07-warm-follow-up-ttft.md`, which also documents baseline finding 4 (Claude's resumed turn-start gap is ACPX's per-turn process generation, not the provider). ## Verification - `server`: `native-lifecycle.test.ts` (new default cases: local Codex and OpenCode warm, Claude, remote, unknown target, unmeasured harness, managed Claude, admitted per-turn session, explicit choices, non-native adapter), `agent-runner-selection.test.ts`, `runtime-selection.test.ts` (335 pass). - `packages/db`: `native-warm-follow-up-default-migration.test.ts` on embedded Postgres. Default agents are cleared, the explicit edit is kept, other harnesses and legacy adapters are untouched, audit rows are written once, and a rerun is a no-op. Without the explicit-edit filter the test fails. `check:migrations` passes. - `codex-local` `build-config.test.ts` (65), `ui` `config-fields.test.tsx` (40), restart-chaos unit tests (134), `tsc --noEmit` for `server`. - `adapter-utils` `paperclip-runner-lifecycle-default.test.ts`: configuration writes and adapter conversion store no lifecycle for Codex, OpenCode and Claude, keep `per_turn` for the others, and preserve explicit choices. It fails without the fix. - End-to-end default check on this branch (`ttft-baseline.ts --lifecycle default`, codex and opencode, 2 samples): the agents read back with no stored lifecycle, and every follow-up reused the runner and provider session (no `runner.process.launch`). Follow-up TTFT was 3.6–4.8 s. This check found the per-write `per_turn` injection fixed here. - Restart chaos (codex and opencode, A1/B1/B5, K1/K2/K6, seed 4242), 8/8 PASS each with no open signatures: forced warm, forced per_turn, and `--lifecycle default` on this branch (agents created without a lifecycle). - `pnpm verify` selects broader CI (harness config is a global input), so required CI supplies the broad coverage. ## Risks - Behavior change: local Codex and OpenCode agents keep a runner and provider process for up to 5 minutes after a run. Instance sleep waits for that idle window. The process is reaped by the idle timer, by graceful shutdown, or by the #19879 reconnect grace if its controller dies. - Migration: agents whose "Turn by turn" was chosen at creation become Automatic. The activity entry records the old value; choosing "Turn by turn" restores it. - Previous-release story (`rules/upgrade-safe-run-state.mdc`): a task session keeps the lifecycle policy it was admitted with, so sessions saved before this change stay `per_turn`. New sessions get the default. No run, checkpoint or launch state changes shape. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edit, test execution). ## 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 #` / `Refs #` 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
37 KiB
TypeScript
501 lines
37 KiB
TypeScript
import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agentHarnessType, paperclipRunnerProfileForHarness, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
|
|
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
|
|
import { resolveClaudeModel } from "@paperclipai/adapter-claude-local";
|
|
import { DEFAULT_GROK_LOCAL_MODEL } from "@paperclipai/adapter-grok-local";
|
|
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
|
|
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
|
|
import { adapterSettingsService, effectiveDisabledAdapterTypes } from "./adapter-settings.js";
|
|
import { listServerAdapters } from "../adapters/registry.js";
|
|
import { isCloudManagedInstance } from "./cloud-instance.js";
|
|
import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
|
import { forbidden, unprocessable } from "../errors.js";
|
|
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
|
|
import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime, resolveSandboxProviderSecretRefPaths } from "./environment-config.js";
|
|
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
|
|
import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@paperclipai/shared";
|
|
import { usesManagedCloudSandboxImage } from "./sandbox-snapshot-qualification.js";
|
|
import { environmentCustomImageTemplateMatchesBaseConfig } from "./environment-custom-image-runtime.js";
|
|
import type { ExecutionPolicy } from "./execution-allowlist.js";
|
|
import { pluginEnvironmentRunnerTaskDriver } from "./plugin-runner-ai-connection.js";
|
|
|
|
export type RunnerTarget = {
|
|
driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string;
|
|
/** A plugin environment whose driver declares Runner task execution. It runs agents only through Paperclip Runner. */
|
|
runnerTasks?: boolean;
|
|
managedCloudImage?: boolean;
|
|
warmTaskVersion?: 1;
|
|
executionMode?: ExecutionPolicy["executionMode"];
|
|
};
|
|
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
|
|
export interface AgentRunnerSelection {
|
|
adapterType?: string | null;
|
|
disabledTypes?: string[];
|
|
adapterConfig?: Record<string, unknown>;
|
|
runner?: AgentRunnerChoice;
|
|
target?: RunnerTarget;
|
|
/** Server-owned inactive setup drafts may precede model selection. Never use for a hire or run. */
|
|
setupOnly?: boolean;
|
|
onConfigurationWarnings?: (warnings: AgentConfigurationWarning[]) => void;
|
|
}
|
|
|
|
function localDisabledTypes(): string[] {
|
|
if (isCloudManagedInstance()) throw new Error("Managed runner selection requires a fresh adapter settings snapshot");
|
|
return effectiveDisabledAdapterTypes(getDisabledAdapterTypes(), () => listServerAdapters().map((adapter) => adapter.type));
|
|
}
|
|
|
|
function runnerAdaptersAvailable(harness: string, driver: string, disabledTypes: string[]): boolean {
|
|
const disabled = new Set(disabledTypes);
|
|
return !!paperclipRunnerProfileForHarness(harness)
|
|
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
|
|
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
|
|
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
|
|
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
|
|
}
|
|
|
|
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget(), disabledTypes: string[] = localDisabledTypes()): AgentRunnerAvailability {
|
|
if (harness === "cursor_cloud") {
|
|
if (target.executionMode === "kubernetes") return { supportedRunners: [], defaultRunner: "legacy" };
|
|
const controller = localAgentRunnerTarget();
|
|
const supported = runnerAdaptersAvailable(harness, "local", disabledTypes)
|
|
&& paperclipRunnerSupportsPlatform(harness, controller.platform, controller.architecture, controller.libc, controller.glibcVersion);
|
|
const legacy = isEnvironmentDriverSupportedForAdapter(harness, target.driver);
|
|
return { supportedRunners: [...(supported ? ["paperclip" as const] : []), ...(legacy ? ["legacy" as const] : [])],
|
|
defaultRunner: legacy || !supported ? "legacy" : "paperclip" };
|
|
}
|
|
if (target.driver === "unavailable") return { supportedRunners: [], defaultRunner: "legacy" };
|
|
if (target.runnerTasks || target.managedCloudImage) {
|
|
return runnerAdaptersAvailable(harness, target.driver, disabledTypes)
|
|
? { supportedRunners: target.runnerTasks ? ["paperclip"] : ["paperclip", "legacy"], defaultRunner: "paperclip" }
|
|
: { supportedRunners: ["legacy"], defaultRunner: "legacy" };
|
|
}
|
|
const supported = runnerAdaptersAvailable(harness, target.driver, disabledTypes)
|
|
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
|
|
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
|
|
&& (["claude_local", "grok_local"].includes(harness)
|
|
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)
|
|
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
|
|
const automatic = supported && !(harness === "claude_local" && target.driver === "ssh" && target.platform === "darwin");
|
|
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: automatic ? "paperclip" : "legacy" };
|
|
}
|
|
|
|
function incompatible(fields: string[]): never {
|
|
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them before retrying.`, { code: "agent_runner_config_incompatible", fields });
|
|
}
|
|
|
|
function resolveGrokRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const unsupported = ["extraArgs", "args", "permissionMode", "maxTurns"]
|
|
.filter(key => config[key] !== undefined && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
|
if (config.command && config.command !== "grok") unsupported.push("command");
|
|
if (config.alwaysApprove !== undefined && config.alwaysApprove !== true) unsupported.push("alwaysApprove");
|
|
if (config.disableWebSearch !== undefined && typeof config.disableWebSearch !== "boolean") unsupported.push("disableWebSearch");
|
|
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
|
if (unsupported.length) incompatible(unsupported);
|
|
const translated = { ...config };
|
|
const effort = config.reasoningEffort ?? config.modelReasoningEffort ?? config.effort;
|
|
for (const key of ["command", "extraArgs", "args", "alwaysApprove", "maxTurns", "permissionMode", "reasoningEffort", "modelReasoningEffort", "effort"]) delete translated[key];
|
|
return { ...translated, provider: "acpx", acpxAgent: "grok", disableWebSearch: config.disableWebSearch ?? true,
|
|
model: typeof config.model === "string" && config.model.trim() && config.model.trim() !== DEFAULT_GROK_LOCAL_MODEL ? config.model.trim() : "grok-4.7",
|
|
...(effort === undefined || effort === "" ? {} : { reasoningEffort: effort }),
|
|
acpxPermissionMode: config.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" };
|
|
}
|
|
|
|
function resolvePiRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const unsupported = ["extraArgs", "args", "modelReasoningEffort", "reasoningEffort", "effort"]
|
|
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
|
|
if (config.command && config.command !== "pi") unsupported.push("command");
|
|
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
|
if (config.stdoutLogMode !== undefined && config.stdoutLogMode !== "compact") unsupported.push("stdoutLogMode");
|
|
const thinking = config.piThinkingLevel ?? config.thinking;
|
|
if (thinking !== undefined && !["off", "low", "high", "max"].includes(String(thinking))) unsupported.push("thinking");
|
|
if (config.piThinkingLevel !== undefined && config.thinking !== undefined && config.piThinkingLevel !== config.thinking) unsupported.push("thinking", "piThinkingLevel");
|
|
if (unsupported.length) incompatible(unsupported);
|
|
const translated = { ...config };
|
|
for (const key of ["command", "extraArgs", "args", "thinking", "stdoutLogMode"]) delete translated[key];
|
|
return {
|
|
...translated,
|
|
provider: "acpx", acpxAgent: "pi",
|
|
model: typeof config.model === "string" ? config.model.trim() : "",
|
|
piThinkingLevel: thinking ?? "low",
|
|
acpxPermissionMode: config.acpxPermissionMode ?? "approve-all",
|
|
lifecycleMode: config.lifecycleMode ?? "per_turn",
|
|
};
|
|
}
|
|
|
|
function resolveOpenCodeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const unsupported = ["extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort"]
|
|
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
|
|
if (config.command && config.command !== "opencode") unsupported.push("command");
|
|
// Legacy false inherits local OpenCode permission rules, which the isolated Runner cannot retain.
|
|
if (config.dangerouslySkipPermissions !== undefined && config.dangerouslySkipPermissions !== true) unsupported.push("dangerouslySkipPermissions");
|
|
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
|
if (unsupported.length) incompatible(unsupported);
|
|
const translated = { ...config };
|
|
for (const key of ["command", "extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort", "dangerouslySkipPermissions"]) delete translated[key];
|
|
return {
|
|
...translated,
|
|
provider: "opencode",
|
|
model: typeof config.model === "string" ? config.model.trim() : "",
|
|
opencodePermissionMode: config.opencodePermissionMode ?? "allow",
|
|
// Absent means the product default: warm on a local target (native-lifecycle.ts).
|
|
...(config.lifecycleMode === undefined ? {} : { lifecycleMode: config.lifecycleMode }),
|
|
};
|
|
}
|
|
|
|
function resolveClaudeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const translated = { ...config };
|
|
const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined;
|
|
if (legacyPermission !== undefined) {
|
|
const mode = legacyPermission.trim();
|
|
if (!["approve-all", "approve-reads", "deny-all"].includes(mode)
|
|
|| (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== mode)) incompatible(["permissionMode", "acpPermissionMode"]);
|
|
translated.acpxPermissionMode = mode;
|
|
}
|
|
for (const key of ["permissionMode", "acpPermissionMode"]) {
|
|
if (config[key] !== undefined && typeof config[key] !== "string") incompatible([key]);
|
|
delete translated[key];
|
|
}
|
|
const unsupported = claudeRunnerUnsupportedSettings(translated);
|
|
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
|
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
|
if (unsupported.length) incompatible(unsupported);
|
|
for (const key of ["engine", "command", "mode", "acpMode", "nonInteractivePermissions", "acpNonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key];
|
|
const env = config.env && typeof config.env === "object" ? config.env as Record<string, unknown> : {};
|
|
const modelEnv = { ...env };
|
|
if (!(typeof config.model === "string" && config.model.trim())) {
|
|
for (const key of ["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"]) {
|
|
const binding = env[key];
|
|
if (binding === undefined || typeof binding === "string") continue;
|
|
if (binding && typeof binding === "object" && "type" in binding && binding.type === "plain"
|
|
&& "value" in binding && typeof binding.value === "string") modelEnv[key] = binding.value;
|
|
else incompatible([`env.${key}`]);
|
|
}
|
|
}
|
|
const model = resolveClaudeModel(config.model, modelEnv);
|
|
if (!model) incompatible(["model"]);
|
|
return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", ...(config.lifecycleMode === undefined ? {} : { lifecycleMode: config.lifecycleMode }) };
|
|
}
|
|
|
|
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
|
|
const result = resolveAgentRunner(input);
|
|
if (result.adapterType === "paperclip_runner" && agentHarnessType(result.adapterType, result.adapterConfig) === "claude_local") {
|
|
const normalized = normalizeClaudeRunnerOptions(input.adapterConfig ?? {});
|
|
result.adapterConfig = normalizeClaudeRunnerOptions(result.adapterConfig).config;
|
|
input.onConfigurationWarnings?.(normalized.warnings);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
|
|
const adapterType = input.adapterType ?? "process";
|
|
let config = { ...input.adapterConfig };
|
|
const disabledTypes = input.disabledTypes ?? localDisabledTypes();
|
|
const harness = agentHarnessType(adapterType, config);
|
|
const native = adapterType === "paperclip_runner";
|
|
if (paperclipRunnerProfileForHarness(adapterType) && disabledTypes.includes(adapterType)) throw unprocessable(`${adapterType === "grok_local" ? "Grok Build" : adapterType === "pi_local" ? "Pi" : adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
|
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
|
const availability = agentRunnerAvailability(harness, input.target, disabledTypes);
|
|
const choice = input.runner ?? "auto";
|
|
const useNative = choice === "paperclip" || (choice === "auto" && harness !== "cursor_cloud" && availability.defaultRunner === "paperclip");
|
|
if (!useNative) {
|
|
if (harness === "cursor_cloud") {
|
|
if (!native) {
|
|
if (choice !== "legacy" && !availability.supportedRunners.includes("legacy")) throw unprocessable(
|
|
"Automatic Cursor selection is unavailable for this environment. Choose a supported runner in Advanced.",
|
|
{ code: "agent_runner_unavailable" },
|
|
);
|
|
return { adapterType, adapterConfig: config };
|
|
}
|
|
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
|
for (const [current, legacy] of [["cloudEnvType", "runtimeEnvType"], ["cloudEnvName", "runtimeEnvName"], ["repoRef", "repoStartingRef"], ["prUrl", "repoPullRequestUrl"]]) {
|
|
if (config[current] !== undefined) config[legacy] = config[current];
|
|
delete config[current];
|
|
}
|
|
if (config.cursorCloudTimeoutMs !== undefined) incompatible(["cursorCloudTimeoutMs"]);
|
|
for (const key of ["provider", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
|
return { adapterType: "cursor_cloud", adapterConfig: config };
|
|
}
|
|
if (harness === "grok_local") {
|
|
if (!native) return { adapterType, adapterConfig: config };
|
|
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
|
|
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
|
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
|
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "effort"]) delete config[key];
|
|
return { adapterType: "grok_local", adapterConfig: { ...config, disableWebSearch: config.disableWebSearch ?? false, ...(effort === undefined ? {} : { reasoningEffort: effort }) } };
|
|
}
|
|
if (harness === "pi_local") {
|
|
if (!native) return { adapterType, adapterConfig: config };
|
|
const thinking = config.thinking ?? config.piThinkingLevel ?? "low";
|
|
const unsupported = [
|
|
...(config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all" ? ["acpxPermissionMode"] : []),
|
|
...(config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn" ? ["lifecycleMode"] : []),
|
|
...(config.acpxSessionMode !== undefined ? ["acpxSessionMode"] : []),
|
|
...(!["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(thinking)) ? [config.thinking !== undefined ? "thinking" : "piThinkingLevel"] : []),
|
|
];
|
|
if (unsupported.length) throw unprocessable(`Legacy Pi cannot honor these settings: ${unsupported.join(", ")}. Keep Paperclip Runner or explicitly choose compatible settings.`, { code: "agent_runner_config_incompatible", fields: unsupported });
|
|
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "acpxSessionMode", "piThinkingLevel", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
|
return { adapterType: "pi_local", adapterConfig: { ...config, thinking } };
|
|
}
|
|
if (harness === "opencode_local") {
|
|
if (!native) return { adapterType, adapterConfig: config };
|
|
if (config.opencodePermissionMode !== undefined && config.opencodePermissionMode !== "allow") incompatible(["opencodePermissionMode"]);
|
|
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
|
for (const key of ["provider", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
|
return { adapterType: "opencode_local", adapterConfig: { ...config, dangerouslySkipPermissions: true } };
|
|
}
|
|
if (harness === "claude_local") {
|
|
if (!native) return { adapterType, adapterConfig: config };
|
|
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
|
|
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
|
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
|
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "reasoningEffort"]) delete config[key];
|
|
if (effort !== undefined) config.effort = effort;
|
|
return { adapterType: "claude_local", adapterConfig: { ...config, dangerouslySkipPermissions: config.dangerouslySkipPermissions ?? true } };
|
|
}
|
|
if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config };
|
|
if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config };
|
|
if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" });
|
|
if (config.codexPermissionMode !== undefined && config.codexPermissionMode !== "never") incompatible(["codexPermissionMode"]);
|
|
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
|
for (const key of ["provider", "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key];
|
|
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } };
|
|
}
|
|
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
|
if (!availability.supportedRunners.includes("paperclip")) throw unprocessable(input.target?.driver === "sandbox"
|
|
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc."
|
|
: "Paperclip Runner is unavailable for this harness and environment. Choose a supported harness or environment.", { code: "agent_runner_unavailable" });
|
|
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
|
if (harness === "cursor_cloud") {
|
|
const unsupported = ["promptTemplate", "bootstrapPromptTemplate"].filter(key => typeof config[key] === "string" && config[key].trim());
|
|
if (unsupported.length) incompatible(unsupported);
|
|
for (const [legacy, current] of [["runtimeEnvType", "cloudEnvType"], ["runtimeEnvName", "cloudEnvName"], ["repoStartingRef", "repoRef"], ["repoPullRequestUrl", "prUrl"]]) {
|
|
if (config[legacy] !== undefined) {
|
|
if (config[current] !== undefined && config[current] !== config[legacy]) incompatible([legacy, current]);
|
|
config[current] = config[legacy];
|
|
}
|
|
delete config[legacy];
|
|
}
|
|
config = { ...config, provider: "cursor_cloud", lifecycleMode: "per_turn" };
|
|
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "cursor_cloud_api", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
if (harness === "grok_local") {
|
|
config = resolveGrokRunnerConfig(config);
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
if (harness === "pi_local") {
|
|
config = resolvePiRunnerConfig(config);
|
|
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
if (typeof config.model !== "string" || !/^[^/\s]+\/\S+$/.test(config.model)) throw unprocessable("Pi requires an explicit provider/model. Select a model before testing or hiring.", { code: "agent_runner_config_incompatible", fields: ["model"] });
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
if (harness === "opencode_local") {
|
|
config = resolveOpenCodeRunnerConfig(config);
|
|
if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
if (harness === "claude_local") {
|
|
config = resolveClaudeRunnerConfig(config);
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
const aliasSources = new Map<string, string>();
|
|
for (const [canonical, alias] of [
|
|
["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"],
|
|
["mode", "acpMode"], ["permissionMode", "acpPermissionMode"],
|
|
["nonInteractivePermissions", "acpNonInteractivePermissions"],
|
|
]) {
|
|
if (config[alias] === undefined) continue;
|
|
const canonicalValue = typeof config[canonical] === "string" ? config[canonical].trim() : "";
|
|
const aliasValue = typeof config[alias] === "string" ? config[alias].trim() : "";
|
|
if (canonicalValue || aliasValue) {
|
|
config[canonical] = canonicalValue || aliasValue;
|
|
if (!canonicalValue) aliasSources.set(canonical, alias);
|
|
} else if (config[alias] !== null && typeof config[alias] !== "string") incompatible([alias]);
|
|
else if (typeof config[canonical] === "string") delete config[canonical];
|
|
delete config[alias];
|
|
}
|
|
if (config.acpWarmHandleIdleMs !== undefined) {
|
|
if (config.warmHandleIdleMs == null && config.acpWarmHandleIdleMs != null) {
|
|
config.warmHandleIdleMs = config.acpWarmHandleIdleMs;
|
|
aliasSources.set("warmHandleIdleMs", "acpWarmHandleIdleMs");
|
|
}
|
|
delete config.acpWarmHandleIdleMs;
|
|
}
|
|
const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"]
|
|
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
|
if (config.command && config.command !== "codex") unsupported.push("command");
|
|
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
|
|
if (config.engine && config.engine !== "cli" && config.engine !== "acp") unsupported.push("engine");
|
|
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
|
|
if (config.dangerouslyBypassApprovalsAndSandbox === false) unsupported.push("dangerouslyBypassApprovalsAndSandbox");
|
|
if (config.dangerouslyBypassSandbox === false) unsupported.push("dangerouslyBypassSandbox");
|
|
if (unsupported.length) incompatible(unsupported.map(field => aliasSources.get(field) ?? field));
|
|
const env = config.env && typeof config.env === "object" && !Array.isArray(config.env)
|
|
? config.env as Record<string, unknown> : {};
|
|
const network = env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS;
|
|
const binding = network && typeof network === "object" && !Array.isArray(network)
|
|
? network as Record<string, unknown> : null;
|
|
if (config.engine !== "cli" && (network === "false"
|
|
|| (binding?.type === "plain" && binding.value === "false")
|
|
|| binding?.type === "secret_ref" || binding?.type === "user_secret_ref")) {
|
|
incompatible(["env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS"]);
|
|
}
|
|
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
|
for (const key of ["engine", "command", "extraArgs", "args", "agentCommand", "mode", "nonInteractivePermissions", "stateDir", "warmHandleIdleMs", "dangerouslyBypassApprovalsAndSandbox", "dangerouslyBypassSandbox", "fastMode", "reasoningEffort", "effort"]) delete config[key];
|
|
if (config.model !== undefined && typeof config.model !== "string") incompatible(["model"]);
|
|
config = { ...config, provider: "codex", model: typeof config.model === "string" && config.model.trim() ? config.model.trim() : PAPERCLIP_RUNNER_DEFAULT_MODELS.codex, codexPermissionMode: config.codexPermissionMode ?? "never", ...(config.lifecycleMode === undefined ? {} : { lifecycleMode: config.lifecycleMode }), ...(effort === undefined ? {} : { modelReasoningEffort: effort }) };
|
|
try { resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: config }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { adapterType: "paperclip_runner", adapterConfig: config };
|
|
}
|
|
|
|
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
|
|
const disabledTypes = await adapterSettingsService(db).getDisabledTypes();
|
|
input = { ...input, disabledTypes };
|
|
const cursor = agentHarnessType(input.adapterType ?? "process", input.adapterConfig) === "cursor_cloud";
|
|
const nativeCursor = cursor && input.runner !== "legacy"
|
|
&& (input.adapterType === "paperclip_runner" || input.runner === "paperclip");
|
|
const cursorSetupDefault = cursor && input.setupOnly === true
|
|
&& (input.runner === undefined || input.runner === "auto");
|
|
if (nativeCursor || cursorSetupDefault) {
|
|
const { instanceSettingsService } = await import("./instance-settings.js");
|
|
const settings = await instanceSettingsService(db).get();
|
|
// Only a held new setup may adopt the API-only managed default. Existing
|
|
// legacy agents still require an explicit conversion decision.
|
|
const useSetupDefault = cursorSetupDefault && managedCursorApiDefaultEnabled(settings.experimental);
|
|
if (nativeCursor || useSetupDefault) {
|
|
if (disabledTypes.includes("cursor_cloud") || disabledTypes.includes("paperclip_runner")) {
|
|
throw unprocessable("Cursor Cloud is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
|
|
}
|
|
assertProviderApiPlacementPolicy(settings.general ?? {});
|
|
const selected = resolveNewAgentRunner({ ...input, ...(useSetupDefault ? { runner: "paperclip" as const } : {}), target: localAgentRunnerTarget() });
|
|
try { if (!input.setupOnly) resolveProviderApiPlacement({ agent: selected }); }
|
|
catch (error) {
|
|
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
|
throw error;
|
|
}
|
|
return { ...selected, defaultEnvironmentId: null };
|
|
}
|
|
}
|
|
if (!paperclipRunnerProfileForHarness(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
|
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
|
|
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId, input.disabledTypes);
|
|
return resolveNewAgentRunner({ ...input, target });
|
|
}
|
|
|
|
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null, disabledTypes?: string[]): Promise<RunnerTarget> {
|
|
await waitForExternalAdapters();
|
|
const { environment, driver, executionMode } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
|
|
const withPolicy = (value: RunnerTarget): RunnerTarget => ({ ...value, ...(executionMode ? { executionMode } : {}) });
|
|
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
|
|
: { driver };
|
|
if (driver === "plugin" && environment) {
|
|
const taskDriver = await pluginEnvironmentRunnerTaskDriver(db, environment);
|
|
if (taskDriver) target = { driver, runnerTasks: true, ...(taskDriver.warmTaskVersion === 1 ? { warmTaskVersion: 1 as const } : {}) };
|
|
}
|
|
if (driver === "sandbox" && environment) {
|
|
if (usesManagedCloudSandboxImage(environment, null)) {
|
|
const { environmentCustomImageService } = await import("./environment-custom-images.js");
|
|
const template = await environmentCustomImageService(db).getActiveTemplate({
|
|
environmentId: environment.id,
|
|
provider: typeof environment.config.provider === "string" ? environment.config.provider : null,
|
|
});
|
|
if (!template?.templateRef) return withPolicy({ driver, managedCloudImage: true });
|
|
try {
|
|
const parsed = parseEnvironmentDriverConfig(environment);
|
|
if (parsed.driver === "sandbox" && !environmentCustomImageTemplateMatchesBaseConfig({
|
|
template,
|
|
baseConfig: parsed.config,
|
|
secretRefExcludePaths: await resolveSandboxProviderSecretRefPaths(db, template.provider),
|
|
})) return withPolicy({ driver, managedCloudImage: true });
|
|
} catch {
|
|
return withPolicy({ driver });
|
|
}
|
|
}
|
|
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
|
|
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
|
|
if (qualified) target = { driver, ...qualified };
|
|
}
|
|
const disabledForSsh = driver === "ssh" && environment
|
|
? disabledTypes ?? await adapterSettingsService(db).getDisabledTypes() : [];
|
|
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local", "grok_local"].some(harness => runnerAdaptersAvailable(harness, driver, disabledForSsh))) {
|
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
|
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
|
let output: string;
|
|
try { output = (await runSshCommand(parsed.config, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 })).stdout; }
|
|
catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry.", { code: "agent_runner_platform_probe_failed" }); }
|
|
const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/);
|
|
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) };
|
|
}
|
|
return withPolicy(target);
|
|
}
|
|
|
|
export async function resolveAgentRunnerEnvironmentForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) {
|
|
const resolved = await resolveAgentEnvironmentForCompany(db, companyId, defaultEnvironmentId);
|
|
if (resolved.environment?.driver === "sandbox" && resolved.environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs.");
|
|
return resolved;
|
|
}
|
|
|
|
// Hiring defaults inspect capabilities without probing or qualifying an already selected runner.
|
|
export async function resolveAgentRunnerTaskDriverForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) {
|
|
const { environment, driver } = await resolveAgentEnvironmentForCompany(db, companyId, defaultEnvironmentId);
|
|
return driver === "plugin" && environment ? pluginEnvironmentRunnerTaskDriver(db, environment) : undefined;
|
|
}
|
|
|
|
async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) {
|
|
const { instanceSettingsService } = await import("./instance-settings.js");
|
|
const { environmentService } = await import("./environments.js");
|
|
const settings = await instanceSettingsService(db).get();
|
|
const executionMode = settings.general?.executionMode;
|
|
const envs = environmentService(db);
|
|
const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId;
|
|
let environment = environmentId ? await envs.getById(environmentId) : null;
|
|
if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) {
|
|
environment = await envs.findManagedSandboxEnvironment(companyId);
|
|
if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Sandbox and retry.", {
|
|
code: "managed_sandbox_unavailable", ...(executionMode ? { executionMode } : {}),
|
|
});
|
|
}
|
|
if (settings.general?.executionMode === "kubernetes") {
|
|
environment = await envs.findKubernetesEnvironment(companyId);
|
|
if (!environment) return { environment: null, driver: "sandbox", executionMode };
|
|
}
|
|
if (environmentId && !environment) throw unprocessable("Environment not found.");
|
|
if (environment) {
|
|
const boundCompanyIds = await envs.listBoundCompanyIds(environment.id);
|
|
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
|
|
if (environment.status === "archived") throw unprocessable("Environment is archived.");
|
|
}
|
|
return { environment, driver: environment?.driver ?? "local", executionMode };
|
|
}
|
|
|