Wire per-adapter sandbox install commands through test and execute paths (#5280)

> **Stacked PR.** Sits on top of the e2b sandbox chain — #5278 (stdin
staging) and #5279 (honest-resolvability + login-profiles). The
cumulative diff against `master` includes both of those PRs' content;
the files touched by *this* PR's commit are the new
`maybeRunSandboxInstallCommand` helper in
`packages/adapter-utils/src/execution-target.ts` and the per-adapter
`index.ts`/`server/test.ts`/`server/execute.ts` wiring under
`packages/adapters/{claude,codex,cursor,gemini,opencode,pi}-local/`. The
honest resolvability check from #5279 is what gives this PR's install
command a meaningful "did it actually land on PATH" follow-up.

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Sandbox execution targets are ephemeral — each fresh lease starts
from a template image that may or may not have the agent CLIs
preinstalled
> - When a CLI isn't preinstalled, the resolvability probe fails at
`command -v` and the hello probe never runs
> - There's no shared mechanism for "before you probe or provision,
install the CLI on this sandbox"
> - This pull request adds a `SANDBOX_INSTALL_COMMAND` constant per
adapter and a `maybeRunSandboxInstallCommand` helper that runs it via
the existing sandbox login shell, captures structured output, and never
throws (so the resolvability + hello probe still run after); each
adapter's `test()` and `execute()` share the constant so the two
callsites can't drift
> - The benefit is a fresh sandbox lease without a preinstalled CLI now
installs it once via `sh -lc` before the resolvability probe and before
managed-runtime provisioning, with a uniform
`<adapter>_install_command_run` check on the test report

## What Changed

- `packages/adapter-utils/src/execution-target.ts`: add
`AdapterSandboxInstallCommandCheck` and `maybeRunSandboxInstallCommand`
(runs the install via existing sandbox shell, captures
exit/stdout/stderr, returns a structured info/warn check, never throws)
- Add `SANDBOX_INSTALL_COMMAND` to each adapter's `index.ts` so `test()`
and `execute()` share a single source of truth
- Wire each of the 6 affected adapter `testEnvironment()`s to call
`maybeRunSandboxInstallCommand` before
`ensureAdapterExecutionTargetCommandResolvable`
- Pass `installCommand: SANDBOX_INSTALL_COMMAND` through
`prepareAdapterExecutionTargetRuntime` in each adapter's `execute()`
- Per-adapter install commands use npm globals where possible so
binaries land on a PATH segment the template already exports:
  - claude → `npm install -g @anthropic-ai/claude-code`
  - codex → `npm install -g @openai/codex`
  - cursor → `curl https://cursor.com/install -fsS | bash`
  - gemini → `npm install -g @google/gemini-cli`
  - opencode → `npm install -g opencode-ai`
  - pi → `npm install -g @mariozechner/pi-coding-agent`

SSH and local targets ignore `installCommand` (SSH runtime takes no such
param; local short-circuits before runtime prep), so this is a no-op for
non-sandbox environments.

## Verification

- `pnpm typecheck` clean
- `pnpm vitest run --no-coverage --project @paperclipai/adapter-utils`
and per-adapter projects pass
- Manual sandbox matrix (claude, codex, cursor, gemini, opencode, pi) —
each goes `install_command_run → resolvable → hello_probe_passed` (Codex
and Pi land on `hello_probe_auth_required`, which is the
configured-credentials problem, not an install issue)
- SSH no-regression: SSH Claude still passes; the helper short-circuits
on non-sandbox targets

## Risks

Medium — adds a network/CPU cost (npm install / curl) on every fresh
sandbox lease. Cost is bounded (one-time per lease, typically tens of
seconds for npm globals), and the helper never throws so a failing
install still lets the report run resolvability and hello probes. If a
sandbox image already has the CLI, the install is an idempotent
reinstall.

## Model Used

Claude Opus 4.7 (1M context)

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-05-05 08:29:28 -07:00 committed by GitHub
parent af9386f879
commit 9578dc3da7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 355 additions and 29 deletions

View file

@ -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" },

View file

@ -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<Cl
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@ -430,6 +431,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
adapterKey: "claude",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",

View file

@ -14,6 +14,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@ -21,6 +22,7 @@ import {
import path from "node:path";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { isBedrockModelId } from "./models.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => 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({

View file

@ -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;

View file

@ -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<AdapterExec
target: executionTarget,
adapterKey: "codex",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "home",

View file

@ -11,6 +11,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@ -18,6 +19,7 @@ import {
import path from "node:path";
import os from "node:os";
import { parseCodexJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
import { buildCodexExecArgs } from "./codex-args.js";
@ -104,6 +106,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: "codex",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({

View file

@ -3,6 +3,15 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "cursor";
export const label = "Cursor CLI (local)";
// Cursor CLI is not distributed as an npm package — the official install
// path is the upstream installer script at cursor.com/install. Other adapters
// in this repo prefer `npm install -g <pkg>` 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 = [

View file

@ -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<AdapterExec
);
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@ -370,6 +370,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
adapterKey: "cursor",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [{
key: "skills",
localDir: localSkillsDir,

View file

@ -12,6 +12,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@ -19,7 +20,7 @@ import {
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
@ -148,6 +149,15 @@ export async function testEnvironment(
command = sandboxCommand.command;
env = sandboxCommand.env;
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "cursor",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({

View file

@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "gemini_local";
export const label = "Gemini CLI (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @google/gemini-cli";
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [

View file

@ -45,7 +45,7 @@ import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
describeGeminiFailure,
detectGeminiAuthRequired,
@ -292,7 +292,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@ -322,6 +322,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
adapterKey: "gemini",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [{
key: "skills",
localDir: localSkillsDir,

View file

@ -14,12 +14,13 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
@ -94,6 +95,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: "gemini",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({

View file

@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g opencode-ai";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export function isValidOpenCodeModelId(value: unknown): value is string {

View file

@ -50,6 +50,7 @@ import {
} from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -316,7 +317,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
@ -352,6 +353,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
adapterKey: "opencode",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",

View file

@ -12,6 +12,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
@ -19,6 +20,7 @@ import {
} from "@paperclipai/adapter-utils/execution-target";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@ -136,6 +138,15 @@ export async function testEnvironment(
detail: command,
});
} else {
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "opencode",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({

View file

@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
export const models: Array<{ id: string; label: string }> = [];
export const modelProfiles: AdapterModelProfileDefinition[] = [];

View file

@ -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<AdapterExec
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@ -400,6 +401,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
adapterKey: "pi",
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",

View file

@ -13,6 +13,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
@ -20,6 +21,7 @@ import {
} from "@paperclipai/adapter-utils/execution-target";
import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => 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({