Files
PaperClipAI/server/src/services/agent-runner-selection.ts
T
e0d90339bf fix(runner): make task-capable plugin environments Runner-only (#17380)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An environment plugin can run Paperclip Runner tasks through a
`driver: "plugin"` environment.
> - Core lets only the `paperclip_runner` adapter use a plugin
environment.
> - A Claude agent cannot get onto the Runner there. Runner selection
needs a reported platform for Claude, and a plugin target reports none.
> - The adapter Test route also checks the driver against the requested
harness before it resolves the runner.
> - This pull request makes task-capable plugin environments Runner-only
and checks the Test driver after runner selection.
> - The benefit is that Claude and Codex agents can use a Runner task
environment, and the UI cannot produce a legacy agent there.

## Linked Issues or Issue Description

Refs #17029. Refs #16978.

**What happened?**

A Claude Code agent with a plugin environment that runs Runner tasks
fails the connection test. The error is: `Environment driver "plugin" is
not allowed here. Allowed drivers: local, ssh, sandbox`. The Runner
selector in Advanced shows Paperclip Runner as unavailable, so the user
cannot fix the agent in the form.

**Expected behavior**

A Claude or Codex agent on that environment uses Paperclip Runner. The
connection test passes the driver check. Legacy is not offered, because
a legacy adapter cannot run on a plugin environment.

**Steps to reproduce**

1. Install an environment plugin whose driver declares `supportsTasks:
true`.
2. Create a plugin environment for that driver.
3. Open an agent with the Claude Code harness and select that
environment.
4. Click Test. The test fails with the driver error above.

**Agent adapter(s) involved**

Claude Code, Codex (through Paperclip Runner).

**Additional context**

Two gates caused the failure:

- `agentRunnerAvailability` requires a platform for `claude_local`. A
plugin target is `{ driver: "plugin" }`, so Claude resolves to Legacy.
- The Test route ran `assertAdapterTestEnvironmentForCompany` with the
URL adapter type before `resolveNewAgentRunnerForCompany`. The plugin
driver is allowed only for `paperclip_runner`, so the check failed
before selection could change the adapter.

## What Changed

- Runner target resolution marks a plugin environment as `runnerTasks`
when its plugin manifest declares `supportsTasks` for the driver key and
has the `environment.drivers.register` capability. The check reads the
manifest only. Run admission still verifies a ready plugin and a live
worker.
- `agentRunnerAvailability` returns Paperclip Runner as the only runner
for a `runnerTasks` target. It skips the platform check, because the
plugin provisions the host and launches its own pinned Runner. Harnesses
that the Runner does not support keep the Legacy result.
- The adapter Test route runs the company-binding check first, as
before. It runs the driver check after runner selection, with the
resolved adapter type. The driver check still runs before secret
resolution, leases, and the adapter probe.
- A saved adapter that cannot run on the requested environment no longer
keeps its saved execution in the Test route. It re-resolves its runner.
- `assertAdapterTestEnvironmentBinding` is a new helper that holds the
binding half of the guard. `assertAdapterTestEnvironmentForCompany`
keeps its behavior and calls it.
- The agent form switches a Legacy runner choice to Paperclip Runner
when the selected environment offers no Legacy runner. The test and the
save then send the Runner choice.
- `docs/specs/agent-config-ui.md` records the Runner-only rule and the
Run test order.

## Verification

- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.
- `pnpm --filter @paperclipai/ui exec tsc -b` passes.
- Server suites pass: `agent-runner-selection.test.ts`,
`agent-test-environment-routes.test.ts`,
`agent-permissions-routes.test.ts`,
`environment-selection-route-guards.test.ts`,
`agent-harness-verification.test.ts`,
`plugin-runner-task.integration.test.ts`, `adapter-routes.test.ts`,
`adapter-auth-signal-routes.test.ts`,
`agent-adapter-validation-routes.test.ts`,
`agent-hire-ai-connections.test.ts`.
- UI suites pass: `AgentConfigForm.render.test.tsx` and
`NewAgent.test.tsx`.
- New tests cover these cases:
- A task-capable plugin target offers only Paperclip Runner for Codex
and Claude.
  - Claude saves as `paperclip_runner` with ACPX on that target.
- A driver without `supportsTasks`, a different driver key, a missing
capability, an unknown plugin, or a malformed config keeps the existing
plugin target.
  - The Test route checks the driver against the resolved adapter.
- The Test route still rejects a plugin environment for a legacy
adapter, before any secret resolution.
- A saved adapter re-resolves only when it cannot run on the requested
environment.
- The form switches a saved or new Legacy choice to Paperclip Runner on
a Runner-only environment.
- I ran the new route and form tests against the old code. They fail
without the change.
- The full server `typecheck` script did not complete locally. Its
Runner vendor step needs `cargo`, which this machine does not have. CI
runs the full script.
- I did not run a live plugin environment end to end.

## Risks

- Behavior change: Codex on a task-capable plugin environment now offers
only Paperclip Runner. Before, the selector also showed Legacy, but Core
rejected a legacy save on that environment. No working setup loses an
option.
- The `runnerTasks` decision reads the plugin manifest. If the plugin is
down, the agent still selects the Runner, and the run fails at admission
with the existing plugin error.
- The adapter auth-signal route still checks the driver with the
requested harness. This PR does not change that route.
- An explicit Legacy save through the API on a Runner-only environment
still fails with the existing driver error. The UI no longer offers that
choice.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) wrote the change and the tests in
Claude Code, with shell, file editing, and local test runs.
- Claude Fable 5.1 (`claude-fable-5-1`) did the earlier diagnosis in the
same session.
- The session does not expose the context window size.

## 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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:21:09 -07:00

340 lines
25 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import { agentHarnessType, 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 { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
import { getDisabledAdapterTypes } from "./adapter-plugin-store.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 } from "./environment-config.js";
import { localAgentRunnerTarget } from "../lib/agent-runner-platform.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;
};
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;
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;
}
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
const disabled = new Set(getDisabledAdapterTypes());
return ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(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()): AgentRunnerAvailability {
if (target.runnerTasks) {
// The plugin provisions the host and launches its own pinned Runner, so
// Core has no platform to qualify. Legacy adapters cannot run on a plugin
// environment, so Paperclip Runner is the only choice.
return runnerAdaptersAvailable(harness, target.driver)
? { supportedRunners: ["paperclip"], defaultRunner: "paperclip" }
: { supportedRunners: ["legacy"], defaultRunner: "legacy" };
}
const supported = runnerAdaptersAvailable(harness, target.driver)
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
&& (harness === "claude_local"
? !!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 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 adapterType = input.adapterType ?? "process";
let config = { ...input.adapterConfig };
const harness = agentHarnessType(adapterType, config);
const native = adapterType === "paperclip_runner";
if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${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);
const choice = input.runner ?? "auto";
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
if (!useNative) {
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 === "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", "search", "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 }) {
if (!["codex_local", "claude_local", "opencode_local", "pi_local"].includes(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);
return resolveNewAgentRunner({ ...input, target });
}
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise<RunnerTarget> {
await waitForExternalAdapters();
const { instanceSettingsService } = await import("./instance-settings.js");
const { environmentService } = await import("./environments.js");
const settings = await instanceSettingsService(db).get();
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 Computer and retry.", { code: "managed_sandbox_unavailable" });
}
if (settings.general?.executionMode === "kubernetes") {
environment = await envs.findKubernetesEnvironment(companyId);
if (!environment) return { driver: "sandbox" };
}
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.");
if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs.");
}
const driver = environment?.driver ?? "local";
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
: { driver };
if (driver === "plugin" && environment && await pluginEnvironmentRunsRunnerTasks(db, environment)) target = { driver, runnerTasks: true };
if (driver === "sandbox" && environment) {
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
if (qualified) target = { driver, ...qualified };
}
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
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 target;
}
// The plugin's manifest declaration decides whether its environment runs
// Runner tasks. Run admission separately verifies a ready plugin and a live
// worker, so a plugin that is down still selects the Runner and fails its run.
async function pluginEnvironmentRunsRunnerTasks(db: Db, environment: Parameters<typeof parseEnvironmentDriverConfig>[0]): Promise<boolean> {
let config: { pluginKey: string; driverKey: string };
try {
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "plugin") return false;
config = parsed.config;
} catch {
return false;
}
const { pluginRegistryService } = await import("./plugin-registry.js");
const plugin = await pluginRegistryService(db).getByKey(config.pluginKey);
return Boolean(plugin?.manifestJson.capabilities.includes("environment.drivers.register")
&& plugin.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.supportsTasks === true));
}