Files
PaperClipAI/server/src/services/agent-runner-selection.ts
T
Nicky LeachandClaude Opus 5.5 f9d366acdf fix(runner): require a managed AI connection for plugin Runner-task environments (#19341)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A plugin can provide Runner execution through an environment driver
that declares `supportsTasks: true`. Agent computers use this path.
> - Core let an agent without a managed AI connection run on that path.
The run then used the account that was installed on the plugin's
execution host.
> - That is not a supported credential source. When the host Runner did
not match, the run failed three times, 30 seconds apart, and then
blocked the task with a generic error. The error did not name the fix.
> - The plugin cannot refuse these runs itself. `describe` has no
connection data, and Core replaces plugin error text before the user
sees it.
> - This pull request makes a managed AI connection a requirement for
every plugin Runner-task environment. Core refuses a run without one
before it acquires a lease or calls the plugin.
> - The benefit is one clear notice that tells the operator to choose an
AI connection, and no wasted retries or plugin work.

## Linked Issues or Issue Description

Refs #19326. That pull request makes a Runner revision mismatch retry.
It noted that runs without a managed AI connection reach the Runner
check on a different path, which still blocks after 3 attempts. This
pull request removes that path for plugin Runner-task environments.

**Problem or motivation**
A `paperclip_runner` agent with no managed AI connection can run in a
plugin environment that provides Runner tasks. The run uses the account
installed on the execution host. If the host Runner does not match, the
native session retries 3 times and then blocks the task with
"Plugin-provided Runner execution operation unavailable". The operator
cannot tell what to fix.

**Proposed solution**
Refuse these runs in Core before any lease, plugin call, secret
resolution, or provider work. Use one stable reason,
`ai_connection_required`, for run admission, the setup probe, and agent
verification. Show a task notice that names the fix.

**Alternatives considered**
A refusal in the plugin. It cannot see the connection at `describe`,
which runs first. Its error text is also replaced twice before the user
sees it. A new structured plugin error code would still not cover
`describe`.

## What Changed

- Add `server/src/services/plugin-runner-ai-connection.ts`. It holds the
`ai_connection_required` code and message, the probe result, and the
manifest lookup for a Runner-task driver. The lookup moves here from
`agent-runner-selection.ts`, so run admission and runner selection use
the same rule.
- `heartbeat/execution-configuration.ts`: refuse a new
`paperclip_runner` run in a Runner-task environment when it has no
managed AI connection. This also applies when `homeMode:
"preconfigured"` drops the connection. The run fails as
`configuration_incomplete` with reason `ai_connection_required` and an
action URL to the agent's runtime settings. The check runs before
observability, secrets, the managed runtime, the workspace, and the
lease. A resumed native run keeps its admitted contract.
- `recovery/stranded-notice.ts`: add an "AI connection required" task
notice.
- `run-failure-report.ts`: treat this pre-dispatch refusal as an owner
action, not an unexpected failure.
- `adapters/registry.ts`: the unmanaged probe for a Runner-task target
now returns a failed `ai_connection_required` check. It calls no task
operation. Managed probes still go through `testManagedEnvironment`.
- `agent-harness-verification.ts` and
`agent-lifecycle/application/failure.ts`: classify the check as
`ai_connection_required` with a fixed message. Verification waits for
the configuration to change instead of retrying.
- Docs: `PLUGIN_RUNNER_EXECUTION.md` states the requirement.
`PLUGIN_ENVIRONMENT_TASKS.md` no longer mentions installed accounts.
`agent-config-ui.md` notes the requirement.
- Tests: the "plugin-owned account" heartbeat test described the
behavior that this change removes. It now checks the assembled heartbeat
refusal: no plugin call, no native boundary, no adapter, no workspace
files.

## Verification

- `npx vitest run` in `server/` for these files. All tests passed:
- `src/__tests__/heartbeat-execution-configuration.test.ts`. New cases:
refused without a connection, refused for a preconfigured home, managed
passes, resumed native input passes, non-task plugin driver passes. The
refused cases also check that observability, secrets, and the managed
runtime did not start.
- `src/__tests__/heartbeat-plugin-environment.test.ts`. One test,
"inherits the instance default environment across companies…", also
fails on unmodified `master` in my environment. This change does not
touch it, because its driver does not declare `supportsTasks`.
- `src/adapters/registry.test.ts`,
`src/__tests__/agent-harness-verification.test.ts`,
`src/modules/agent-lifecycle/__tests__/diagnostics.test.ts`,
`src/services/recovery/stranded-notice.test.ts`, and
`src/services/__tests__/run-failure-report.test.ts`.
- 17 more suites that use plugin Runner-task environments, with 705
tests: `plugin-runner-task.integration`,
`plugin-warm-tasks.integration`, `task-creation-recovery.integration`,
`native-startup-cleanup-recovery`,
`environment-task-authority.integration`,
`plugin-task-credential-writeback`, agent-lifecycle `lifecycle`,
`setup`, and `environment-plugins`, `workspace-preparation`,
`agent-runner-selection`, `agent-harness-verification-provider-api`,
`agent-harness-verification-remote`, `plugin-environment-driver-seam`,
`environment-task-runtime`, `agent-test-environment-routes`, and
`adapter-registry`.
- `pnpm typecheck` in `server/` reported no errors in the changed files.
Its only errors came from sandbox-provider packages that my local
install did not link.
- Not run: `plugin-runner-probe.test.ts`, because it needs a Cargo
build. This change does not modify `probePluginRunnerEnvironment`. Also
not run: the full `pnpm test:run` and `pnpm build`. CI runs them.

## Risks

- Behavior change: an agent in a plugin Runner-task environment with no
managed AI connection now stops before dispatch. Before, it ran with the
host's installed account. The agent's owner must choose an AI
connection. This includes agents with `homeMode: "preconfigured"` in
these environments.
- Runs that were already admitted and resume from saved native input are
not refused. They keep the earlier behavior until they finish.
- The check reads the plugin manifest on each new run admission for
plugin environments. This is the same lookup that runner selection
already does.
- This pull request and #19326 both edit `stranded-notice.ts` and
`failure.ts`. The changes are additive, but whichever merges second
needs a small rebase.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (file edits, shell, test runs). Default context window,
extended thinking on.

## 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
- [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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:59:40 -08:00

500 lines
36 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 or select Legacy runner in Advanced.`, { 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",
lifecycleMode: config.lifecycleMode ?? "per_turn",
};
}
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", lifecycleMode: config.lifecycleMode ?? "per_turn" };
}
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, or select Legacy runner in Advanced."
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { 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", lifecycleMode: config.lifecycleMode ?? "per_turn", ...(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, or select Legacy runner in Advanced.", { 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 };
}