mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Retain native import settings, explicitly select Legacy in qualification, pin updated CLI fixtures, and version shared source attestations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
176 lines
15 KiB
TypeScript
176 lines
15 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 { 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<string, unknown>;
|
|
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)
|
|
&& (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));
|
|
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "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<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 model = resolveClaudeModel(config.model, env);
|
|
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" };
|
|
}
|
|
|
|
/** Idempotent translation. Missing dependencies or credentials never select another runner. */
|
|
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"].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" });
|
|
// Explicit saved native profiles (including historical provider omission) retain execution.
|
|
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.defaultRunner !== "paperclip") throw unprocessable("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 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);
|
|
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 };
|
|
}
|
|
|
|
/** Use the execution environment selected by dispatch, without starting a provider. */
|
|
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 });
|
|
}
|
|
|
|
/** Shared creation/discovery target selection; no provider turn or environment lease. */
|
|
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) throw unprocessable("The required Kubernetes environment is unavailable.", { code: "kubernetes_environment_unavailable" });
|
|
}
|
|
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 }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture.
|
|
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;
|
|
}
|