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 { 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 }; 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; runner?: AgentRunnerChoice; target?: RunnerTarget; } function runnerAdaptersAvailable(harness: string, driver: string): boolean { const disabled = new Set(getDisabledAdapterTypes()); return ["codex_local", "claude_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 { 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 resolveClaudeRunnerConfig(config: Record): Record { 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 : {}; 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 } { const adapterType = input.adapterType ?? "process"; let config = { ...input.adapterConfig }; const harness = agentHarnessType(adapterType, config); const native = adapterType === "paperclip_runner"; if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${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 === "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 === "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(); 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 : {}; const network = env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS; const binding = network && typeof network === "object" && !Array.isArray(network) ? network as Record : 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"].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 { 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 === "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"].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; }