diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 844fc31d..78d7c020 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -144,6 +144,8 @@ export async function prepareCommandManagedRuntime(input: { preserveAbsentOnRestore?: string[]; assets?: CommandManagedRuntimeAsset[]; installCommand?: string | null; + /** When provided alongside `installCommand`, skip the install if `command -v ` succeeds. */ + detectCommand?: string | null; }): Promise { const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000; const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; @@ -164,13 +166,52 @@ export async function prepareCommandManagedRuntime(input: { const shellCommand = preferredShellForSandbox(input.spec.shellCommand); if (input.installCommand?.trim()) { + const installCommand = input.installCommand.trim(); + const detectCommand = input.detectCommand?.trim(); + // Skip the install when the binary is already on PATH. Without this + // probe the install runs unconditionally on every execute() call (and + // also runs a second time after `ensureAdapterExecutionTargetCommandResolvable` + // has already installed it during the resolvability gate). + if (detectCommand) { + const probe = await input.runner.execute({ + command: shellCommand, + args: ["-lc", `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`], + cwd: workspaceRemoteDir, + timeoutMs, + }); + if (!probe.timedOut && (probe.exitCode ?? 1) === 0) { + return await prepareSandboxManagedRuntime({ + spec: runtimeSpec, + client, + adapterKey: input.adapterKey, + workspaceLocalDir: input.workspaceLocalDir, + workspaceRemoteDir, + workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude), + preserveAbsentOnRestore: input.preserveAbsentOnRestore, + assets: input.assets, + }); + } + } const result = await input.runner.execute({ command: shellCommand, - args: ["-lc", input.installCommand.trim()], + args: ["-lc", installCommand], cwd: workspaceRemoteDir, timeoutMs, }); - requireSuccessfulResult(result, input.installCommand.trim()); + // A failed install is not always fatal: the CLI may already be on PATH + // from a previous lease, the template image, or another path entry. Log + // and continue rather than aborting the agent run; downstream code that + // exec's the CLI will surface a clear "command not found" if it is in + // fact missing. The test path's `maybeRunSandboxInstallCommand` already + // honors this contract — keep them consistent. + if (result.timedOut || (result.exitCode ?? 0) !== 0) { + const tail = (text: string) => + text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480); + const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`; + console.warn( + `[paperclip] managed-runtime install command ${reason}: ${installCommand} :: ${tail(result.stderr || result.stdout)}`, + ); + } } return await prepareSandboxManagedRuntime({ diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 06445a2c..563d1795 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -230,9 +230,10 @@ export async function ensureAdapterExecutionTargetCommandResolvable( target: AdapterExecutionTarget | null | undefined, cwd: string, env: NodeJS.ProcessEnv, + options: { installCommand?: string | null } = {}, ) { if (target?.kind === "remote" && target.transport === "sandbox") { - await ensureSandboxCommandResolvable(command, target); + await ensureSandboxCommandResolvable(command, target, options.installCommand?.trim() || null); return; } await ensureCommandResolvable(command, cwd, env, { @@ -240,17 +241,10 @@ export async function ensureAdapterExecutionTargetCommandResolvable( }); } -async function ensureSandboxCommandResolvable( +async function probeSandboxCommandResolvable( command: string, target: AdapterSandboxExecutionTarget, -): Promise { - // Probe whether the binary is resolvable inside the sandbox. We previously - // short-circuited this for sandbox targets, which let the caller report a - // success message even when the CLI was missing from the image. Now we run - // a real `command -v` through the same runner the hello probe will use, so - // the first step honestly reflects whether the binary is on PATH. The - // sandbox provider is responsible for sourcing login profiles (e2b mirrors - // SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH. +): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> { const runner = requireSandboxRunner(target); const probeScript = `command -v ${shellQuote(command)}`; const result = await runner.execute({ @@ -259,14 +253,67 @@ async function ensureSandboxCommandResolvable( cwd: target.remoteCwd, timeoutMs: target.timeoutMs ?? 15_000, }); - if (result.timedOut) { + return { + resolved: !result.timedOut && (result.exitCode ?? 1) === 0, + timedOut: result.timedOut, + stderr: result.stderr.trim(), + }; +} + +async function ensureSandboxCommandResolvable( + command: string, + target: AdapterSandboxExecutionTarget, + installCommand: string | null, +): Promise { + // Probe whether the binary is resolvable inside the sandbox. We previously + // short-circuited this for sandbox targets, which let the caller report a + // success message even when the CLI was missing from the image. Now we run + // a real `command -v` through the same runner the hello probe will use, so + // the first step honestly reflects whether the binary is on PATH. The + // sandbox provider is responsible for sourcing login profiles (e2b mirrors + // SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH. + let probe = await probeSandboxCommandResolvable(command, target); + if (probe.resolved) return; + if (probe.timedOut) { throw new Error(`Timed out checking command "${command}" on sandbox target.`); } - if ((result.exitCode ?? 1) === 0) return; - const stderr = result.stderr.trim(); - const detail = stderr.length > 0 ? ` (${stderr})` : ""; + + // If the caller supplied an install command, attempt the install once via + // the sandbox runner (which the sandbox provider wraps in a login shell) + // and re-probe before reporting failure. This lets fresh sandbox leases + // bring up the CLI before the resolvability gate, mirroring the test path. + let installFailureDetail: string | null = null; + if (installCommand) { + const runner = requireSandboxRunner(target); + try { + const installResult = await runner.execute({ + command: "sh", + args: ["-lc", installCommand], + cwd: target.remoteCwd, + timeoutMs: target.timeoutMs ?? 300_000, + }); + if (installResult.timedOut) { + installFailureDetail = `install command timed out: ${installCommand}`; + } else if ((installResult.exitCode ?? 0) !== 0) { + const tail = (text: string) => + text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240); + const reason = tail(installResult.stderr || installResult.stdout) || `exit ${installResult.exitCode ?? "?"}`; + installFailureDetail = `install command exited ${installResult.exitCode ?? "?"}: ${reason}`; + } + } catch (err) { + installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`; + } + probe = await probeSandboxCommandResolvable(command, target); + if (probe.resolved) return; + if (probe.timedOut) { + throw new Error(`Timed out checking command "${command}" on sandbox target.`); + } + } + + const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : ""; + const installDetail = installFailureDetail ? `; ${installFailureDetail}` : ""; throw new Error( - `Command "${command}" is not installed or not on PATH in the sandbox environment${detail}.`, + `Command "${command}" is not installed or not on PATH in the sandbox environment${installDetail}.${probeStderr}`, ); } @@ -409,6 +456,111 @@ export async function runAdapterExecutionTargetShellCommand( ); } +export interface AdapterSandboxInstallCommandCheck { + code: string; + level: "info" | "warn" | "error"; + message: string; + detail?: string; + hint?: string; +} + +// Best-effort run of an adapter-supplied install command on a sandbox target +// before the resolvability + hello probe. Returns null for non-sandbox +// targets so callers can no-op. Returns a structured check otherwise — never +// throws — so the rest of the test still runs and reports the post-install +// state honestly. Caller pushes the check into its result array; the test +// report shows whether install was attempted and what came back. +export async function maybeRunSandboxInstallCommand(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + adapterKey: string; + installCommand: string; + /** When provided, skip the install if `command -v ` succeeds. */ + detectCommand?: string | null; + env?: Record; + timeoutSec?: number; +}): Promise { + const { target, adapterKey, installCommand } = input; + if (!target || target.kind !== "remote" || target.transport !== "sandbox") { + return null; + } + const trimmed = installCommand.trim(); + if (trimmed.length === 0) return null; + + const code = `${adapterKey}_install_command_run`; + + // Skip install when the binary is already on PATH. Avoids running + // network-dependent installers (e.g. `curl ... | bash`) on every test + // probe when the CLI is preinstalled on the lease/template. + const detectCommand = input.detectCommand?.trim(); + if (detectCommand) { + try { + const probe = await runAdapterExecutionTargetShellCommand( + input.runId, + target, + `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, + { + cwd: target.remoteCwd, + env: input.env ?? {}, + timeoutSec: 30, + graceSec: 5, + }, + ); + if (!probe.timedOut && probe.exitCode === 0) { + return { + code, + level: "info", + message: `${detectCommand} already on PATH; skipped install.`, + }; + } + } catch { + // Fall through to actually running the install — failure to probe + // is not a reason to skip the install gate. + } + } + + let result; + try { + result = await runAdapterExecutionTargetShellCommand(input.runId, target, trimmed, { + cwd: target.remoteCwd, + env: input.env ?? {}, + timeoutSec: input.timeoutSec ?? 240, + graceSec: 10, + }); + } catch (err) { + return { + code, + level: "warn", + message: "Install command threw before completion.", + detail: err instanceof Error ? err.message : String(err), + }; + } + const tail = (text: string) => + text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480); + if (result.timedOut) { + return { + code, + level: "warn", + message: `Install command timed out: ${trimmed}`, + detail: tail(result.stderr || result.stdout), + }; + } + if ((result.exitCode ?? 1) === 0) { + return { + code, + level: "info", + message: `Install command ran: ${trimmed}`, + ...(tail(result.stdout) ? { detail: tail(result.stdout) } : {}), + }; + } + return { + code, + level: "warn", + message: `Install command exited ${result.exitCode}: ${trimmed}`, + detail: tail(result.stderr || result.stdout), + }; +} + export async function readAdapterExecutionTargetHomeDir( runId: string, target: AdapterExecutionTarget | null | undefined, @@ -470,12 +622,43 @@ export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: onLog: input.onLog, }, ); + + // A failed or timed-out install is not necessarily fatal: the CLI may already + // be on PATH from a previous lease's install, the template image, or another + // path entry. Re-run the detect probe (when one is configured) so a transient + // install failure does not abort the agent run when the binary is reachable. + const installFailed = result.timedOut || (result.exitCode ?? 0) !== 0; + if (!installFailed) { + return; + } + if (detectCommand) { + const recheck = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec, + graceSec: input.graceSec, + }, + ); + if (!recheck.timedOut && recheck.exitCode === 0) { + if (input.onLog) { + const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`; + await input.onLog( + "stderr", + `[paperclip] Install command ${reason} (${installCommand}) but ${detectCommand} is on PATH; continuing.\n`, + ); + } + return; + } + } + if (result.timedOut) { throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`); } - if ((result.exitCode ?? 0) !== 0) { - throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`); - } + throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`); } export async function ensureAdapterExecutionTargetFile( @@ -666,6 +849,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: { preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; installCommand?: string | null; + /** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */ + detectCommand?: string | null; }): Promise { const target = input.target ?? { kind: "local" as const }; if (target.kind === "local") { @@ -707,6 +892,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, installCommand: input.installCommand, + detectCommand: input.detectCommand, }); return { target, diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 9b928ae2..8ad60fa5 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "claude_local"; export const label = "Claude Code (local)"; +export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; + export const models = [ { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index cd3ed45c..19daae24 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -54,6 +54,7 @@ import { prepareClaudeConfigSeed } from "./claude-config.js"; import { resolveClaudeDesiredSkillNames } from "./skills.js"; import { isBedrockModelId } from "./models.js"; import { prepareClaudePromptBundle } from "./prompt-cache.js"; +import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -261,7 +262,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise check.level === "error")) return "fail"; @@ -102,6 +104,15 @@ export async function testEnvironment( if (typeof value === "string") env[key] = value; } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const installCheck = await maybeRunSandboxInstallCommand({ + runId, + target, + adapterKey: "claude", + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + env, + }); + if (installCheck) checks.push(installCheck); try { await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index ae271820..d869dda8 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "codex_local"; export const label = "Codex (local)"; +export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex"; + export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 67c7633d..0c18406f 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -45,6 +45,7 @@ import { import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; import { buildCodexExecArgs } from "./codex-args.js"; +import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -374,6 +375,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise` which is content-addressed by the +// registry; cursor must use `curl | bash` until upstream publishes a registry +// artifact. Pinning a commit/version here would require shipping our own +// mirror of the installer; revisit if Cursor adds an npm/release-asset +// equivalent. +export const SANDBOX_INSTALL_COMMAND = "curl https://cursor.com/install -fsS | bash"; + export const DEFAULT_CURSOR_LOCAL_MODEL = "auto"; const CURSOR_FALLBACK_MODEL_IDS = [ diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 62147e6c..6611fafa 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -42,7 +42,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, } from "@paperclipai/adapter-utils/server-utils"; -import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js"; +import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js"; import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import { prepareCursorSandboxCommand } from "./remote-command.js"; import { normalizeCursorStreamLine } from "../shared/stream.js"; @@ -340,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = []; export const modelProfiles: AdapterModelProfileDefinition[] = []; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index feba3790..a89332ae 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -46,6 +46,7 @@ import { import { shellQuote } from "@paperclipai/adapter-utils/ssh"; import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; +import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -361,7 +362,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise check.level === "error")) return "fail"; @@ -134,6 +136,15 @@ export async function testEnvironment( detail: command, }); } else { + const installCheck = await maybeRunSandboxInstallCommand({ + runId, + target, + adapterKey: "pi", + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + env, + }); + if (installCheck) checks.push(installCheck); try { await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({