fix(claude): prepare compatible sandbox runtimes during onboarding (#17259)

## Thinking Path

> - Paperclip connects agent accounts and prepares their execution
environments.
> - A managed sandbox image can outlive the model defaults shipped by
the app.
> - Claude setup skipped installation whenever a CLI was already on
PATH.
> - The selected model then failed its minimum-version check after
sign-in succeeded.
> - This pull request prepares a compatible private CLI before testing
or running it.
> - Cloud onboarding also stops asking customers to run inaccessible
shell commands.

## Linked Issues or Issue Description

**What happened?**

A new managed Cloud stack completed Claude subscription sign-in. Its
environment test then failed because the snapshot contained Claude Code
2.1.263, while the selected `claude-opus-5-5` model requires 2.1.280.
The screen displayed Manual debug commands that a hosted customer cannot
run.

**Expected behavior**

Paperclip prepares its managed runtime before testing the selected
account. A stale image CLI does not require the customer to upgrade a
server package. Failed preparation still blocks hiring. Cloud does not
display manual shell debugging.

**Steps to reproduce**

1. Start a managed sandbox with Claude Code 2.1.263 on PATH.
2. Connect a Claude subscription and select `claude-opus-5-5` on the CLI
lane.
3. Connect the agent. Installation is skipped because the command
exists, then the version gate blocks the hello test.

**Paperclip version or commit**

Source baseline: `6204239cfc8c3be79fc185cf3f468ebaa0ab1e3e`.

**Deployment mode**

Authenticated managed Cloud with a Daytona named snapshot. The
correction also applies to the default CLI in other managed sandbox
transports.

Related work: Refs #14993 sets model/runtime requirements; Refs #15993
changes native defaults on qualified targets. Neither prepares an
outdated default sandbox CLI on this path. Searches found no open
duplicate.

## What Changed

- Check the actual default CLI against the selected model's existing
minimum version.
- When a sandbox's default command is outdated, install the exact
required npm package into a private versioned workspace prefix. Reuse a
compatible private installation. Keep the image's global CLI intact.
- Verify the private executable and use that same command for the
account hello test and normal CLI runs, with matching command metadata.
Probe effort support after preparing that executable so the chosen
effort is retained. Keep the model, account, engine and permissions
unchanged.
- Return safe preparation checks to environment tests and normal run
logs, distinguishing installation failure causes without returning raw
installer output or credential-bearing URLs.
- Exclude provider and Paperclip credentials from the installer
environment. Preserve the existing failing version gate if installation
or verification cannot succeed. Local, SSH and explicit custom commands
remain operator-managed.
- Hide Manual debug and legacy operator check hints on Cloud and while
deployment metadata is unknown; preserve check messages and details.
Retain self-hosted help and the detailed failed checks. Cloud copy asks
for a retry instead of asking the customer to repair the environment.
- Add subprocess-backed runtime controls, an account-probe regression
and mounted Cloud/self-hosted wizard controls. Update development
guidance and the affected capability maps.

## Verification

- Passed all Claude source suites and the server Claude execution suite
on head `c941ae44c22d8dafec0df033a129e39ce4ebd966`: **412 passed**, one
existing opt-in test skipped. These include the new command/effort
ordering, safe installer diagnostics, environment forwarding and run-log
regressions.
- On final head `0ecae0e9304f495a57524158b3700b8e9bc21cb8`, passed **208
tests** across all 174 mounted onboarding wizard tests and 34 Claude
execution tests. The failure fixture includes the real command-bearing
hint, and execution asserts both metadata command fields agree.
- Passed `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`,
`node scripts/check-maps.mjs` and `git diff --check`. Full typecheck and
build were repeated successfully on final head
`0ecae0e9304f495a57524158b3700b8e9bc21cb8`. [Latest-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39879208694)
is green: 54 checks passed and two skipped, including browser E2E and
serialized database suites.
- The new runtime controls execute real subprocesses with a controlled
installer. They cover an old CLI, an already compatible CLI, failed or
ineffective installation, local/custom commands, and an unknown version.
The probe regression asserts that the hello call uses the private
executable.
- A separate non-root local subprocess check installed the real npm
`@anthropic-ai/claude-code@2.1.280` package. Its private command
reported 2.1.280, the simulated image CLI remained 2.1.263, and a second
preparation reused the private copy. This ran on macOS ARM64. It did not
use a provider account, send a model prompt, or prove live Daytona
behavior.
- Attempted `pnpm test:run`. Embedded PostgreSQL initialization failed
after five attempts on this Mac with `Postgres init script exited with
code null`. The unchanged native-session database cases then failed.
Stopped only that full-suite process tree after recording the repeated
startup failure. No full local-suite pass is claimed; Linux CI must
cover those tests.
- The first new subprocess fixture had a quoting syntax error. It was
corrected before the passing focused runs. The one-off real-package
script first required an ESM extension correction, then passed. Logs
retain both attempts. A new execution assertion initially inspected the
final sandbox cleanup shell instead of the launched command; it now
checks execution metadata. A broader directory selection also picked up
generated `dist` tests whose Markdown fixtures are not copied by the
build (two ENOENT failures). The explicit source-suite run above passes;
no generated-test pass is claimed.
- Apex review: **5/5**, reviewed final head
`0ecae0e9304f495a57524158b3700b8e9bc21cb8`, all five earlier findings
addressed and resolved, no new findings.
- Merge, deployment and a fresh live Cloud subscription test are still
required. No customer account or production runtime was changed by this
branch.

## Risks

- An outdated sandbox needs npm registry access and up to 120 seconds
for one bounded installation attempt. A failed or unverified install
continues to block execution.
- The versioned package uses sandbox workspace storage. Compatible
cached copies avoid repeat installation within that workspace. Existing
runtime cleanup and sandbox lifecycle still apply.
- The compatible version comes from the adapter's existing model-version
contract. This does not add a model fallback or change the selected
execution engine.
- No schema, credential, permission, workflow or lockfile change.

## Model Used

OpenAI Codex, based on GPT-6, assisted with implementation and
verification. The exact serving model ID and context-window limit are
not exposed in this session. Capabilities used: code editing, shell
execution, API inspection and test execution.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-22 18:44:53 -07:00
1 parent 650581b2f0
commit c5ca29dbef
13 files changed
+409 -36

No files matched your search

+16 -2
View File
@@ -2103,9 +2103,23 @@ concurrent listings do not skip healthy logs because another listing is busy.
### Preinstalled remote runner runtime
Claude CLI tests and runs prepare a compatible runtime when a managed sandbox's
default `claude` command is older than the selected model requires. Preparation
installs the exact minimum supported package into a versioned workspace runtime
directory, then verifies and launches that executable. It does not replace the
image's global CLI or change the model, account, or execution engine. Compatible
private installations are reused. Custom commands, local and SSH targets retain
their existing operator-managed version checks. Installation failure or an
unverified version still blocks launch. Cloud onboarding omits manual shell
debugging instructions and legacy operator hints while retaining check messages
and details; self-hosted onboarding retains that guidance after deployment
metadata is known.
For fast sandbox startup, bake `paperclip-runnerd` and the latest stable agent
CLIs into the sandbox image. Keep one version of each CLI shared by native and
local adapters; never retain an older global CLI beside a newer private copy.
CLIs into the sandbox image. Image builds should keep one current version of each
CLI shared by native and local adapters rather than baking multiple versions.
The private compatibility preparation above is a fallback for existing stale
images; refreshing the image remains the preferred startup path.
Pin the resolved releases at image build time for reproducibility and refresh
the runner's qualification versions and binary digests together with those pins.
The ACP bridges remain separately qualified protocol dependencies.
+13
View File
@@ -96,6 +96,19 @@ testing an existing agent; interactive account login additionally requires a
board user and owner-bound session. An agent's right to hire is not login or
instance-environment administration authority.
Scoped source-only runtime preparation: the existing authorized adapter test
prepares the default Claude CLI in its already acquired sandbox when the image
version is too old for the selected model. It installs a pinned package into a
workspace-private versioned prefix and verifies the executable before the hello
test. This adds no installation API, account grant, environment authority or
engine fallback. Cloud UI omits manual shell debugging and legacy operator hints
while retaining check messages and details. Local, SSH and custom
commands remain operator-managed. Controls:
[runtime](../../packages/adapters/claude-local/src/server/sandbox-cli-version.test.ts),
[probe](../../packages/adapters/claude-local/src/server/test.remote.test.ts), and
[wizard](../../ui/src/components/OnboardingWizard.test.tsx). Live managed
authorization and subscription execution remain `not-run` for this change.
For fresh managed Codex Auto setup, the wizard's explicit Connect path can call
`POST /api/environments/{environmentId}/probe?companyId={companyId}` before
selected-runtime testing, then refresh scoped adapter discovery. This reuses the
+10
View File
@@ -114,6 +114,16 @@ remaining operations before qualifying the whole capability.
Gaps: Environment test path checks creation/configuration policy; missing provider auth
must use its owner setup flow.
Scoped source-only Claude CLI preparation: both the selected environment test
and normal CLI execution use
[versioned sandbox preparation](../../packages/adapters/claude-local/src/server/sandbox-cli-version.ts).
An outdated default sandbox command can use a private pinned compatible CLI;
local, SSH and custom commands are not replaced. The selected model, engine,
account and existing actor/company checks remain authoritative. Installer
failure cannot turn an incompatible CLI into a passing probe. Local subprocess
controls exercise version checks and installer outcomes; live agent and
subscription access are `not-run` for this change.
Scoped Legacy Codex shell delivery: resolved task configuration can explicitly
assign service-secret variables to shell tools through ACP or CLI execution.
The existing configuration and secret-access checks still authorize that binding;
+10
View File
@@ -70,6 +70,16 @@ Use [runner validation](./runs-adapters.md#adapter-setup) and
[environment qualification](./execution-environments.md#environment-settings)
for the prerequisites; a successful login alone is insufficient.
For managed Cloud setup, an outdated default Claude CLI is prepared automatically
in a private versioned runtime directory before its hello test. Verify that the
selected model and account stay unchanged and that the compatible executable is
actually used. A failed installation still blocks hiring. Cloud failures must
not display Manual debug shell commands or legacy operator hints; self-hosted
failures retain that help.
[Wizard controls](../../ui/src/components/OnboardingWizard.test.tsx) and
[runtime controls](../../packages/adapters/claude-local/src/server/sandbox-cli-version.test.ts)
cover these boundaries. They do not establish live Cloud onboarding success.
For a new Claude agent on an explicit immutable sandbox image or a Daytona
named snapshot, use Connect to
qualify the selected environment through the existing permission-gated probe.
+7
View File
@@ -54,6 +54,13 @@ Claude native setup uses the existing Anthropic API-key or subscription connecti
flow. Record the selected account separately from successful execution; unavailable
models/authentication and unsupported Legacy-only options must fail visibly.
Claude CLI tests and runs also prepare a compatible private executable when a
managed sandbox image's default CLI is too old for the selected model. Verify
the command used for the hello test and task, reuse of a compatible private
installation, and a blocking failure when installation cannot establish a valid
version. Local, SSH and explicit custom commands retain operator management.
Cloud onboarding must not offer shell commands the customer cannot run.
Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible.
For Legacy Codex, assign a service secret such as `SLACK_BOT_TOKEN` through agent
@@ -92,8 +92,8 @@ import {
claudeCommandLooksLike,
claudeCommandSupportsEffortFlag,
minimumClaudeCliVersionForModel,
readClaudeCommandVersion,
} from "./cli-capabilities.js";
import { prepareCompatibleClaudeCommand } from "./sandbox-cli-version.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
@@ -474,7 +474,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
authToken,
onLog,
});
const {
let {
command,
resolvedCommand,
cwd,
@@ -758,24 +758,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
let effectiveEffort = effort;
if (executionTargetIsSandbox && effort) {
const supportsEffort = await claudeCommandSupportsEffortFlag({
runId,
command,
target: runtimeExecutionTarget,
cwd,
env,
timeoutSec,
graceSec,
});
if (supportsEffort === false) {
effectiveEffort = "";
await onLog(
"stderr",
`[paperclip] Claude CLI in the environment does not advertise --effort; omitting configured effort "${effort}". Upgrade the environment CLI/image to restore reasoning-effort control.\n`,
);
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -1316,7 +1298,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? minimumClaudeCliVersionForModel(model)
: null;
if (minimumCliVersion) {
const detectedCliVersion = await readClaudeCommandVersion({
const prepared = await prepareCompatibleClaudeCommand({
runId,
command,
target: runtimeExecutionTarget,
@@ -1324,7 +1306,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
env,
timeoutSec,
graceSec,
minimumVersion: minimumCliVersion,
});
if (prepared.check) {
await onLog("stderr", `[paperclip] ${prepared.check.message} ${prepared.check.detail ?? ""}\n`);
}
command = prepared.command;
resolvedCommand = prepared.command === "claude" ? resolvedCommand : prepared.command;
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: loggedEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
const detectedCliVersion = prepared.version;
if (
!detectedCliVersion ||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
@@ -1355,6 +1349,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
if (executionTargetIsSandbox && effort) {
const supportsEffort = await claudeCommandSupportsEffortFlag({
runId,
command,
target: runtimeExecutionTarget,
cwd,
env,
timeoutSec,
graceSec,
});
if (supportsEffort === false) {
effectiveEffort = "";
await onLog(
"stderr",
`[paperclip] Claude CLI in the environment does not advertise --effort; omitting configured effort "${effort}". Upgrade the environment CLI/image to restore reasoning-effort control.\n`,
);
}
}
const initial = await runAttempt(sessionId ?? null);
const sessionErrorKind =
sessionId &&
@@ -0,0 +1,114 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { afterEach, describe, expect, it } from "vitest";
import { prepareCompatibleClaudeCommand } from "./sandbox-cli-version.js";
const runFile = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
});
async function fixture(version: string, installer = "success") {
const root = await mkdtemp(path.join(tmpdir(), "claude-version-fixture-"));
roots.push(root);
const versionFile = path.join(root, "version");
const receipt = path.join(root, "install-receipt");
await writeFile(versionFile, version);
await writeFile(path.join(root, "claude"), '#!/bin/sh\nif test -f "$0.version"; then cat "$0.version"; else cat "$VERSION_FILE"; fi\n', { mode: 0o700 });
await writeFile(path.join(root, "npm"), [
"#!/bin/sh",
'printf "%s\\n" "$*" > "$INSTALL_RECEIPT"',
'test "$INSTALLER_RESULT" != fail || exit 1',
'test "$INSTALLER_RESULT" != unchanged || exit 0',
'if test "$INSTALLER_RESULT" != success; then printf "%s\\n" "$INSTALLER_RESULT https://registry-secret@example.test/private" >&2; exit 7; fi',
'mkdir -p "$4/bin"',
'cp "$SOURCE_CLAUDE" "$4/bin/claude"',
'printf "%s\\n" "${5##*@}" > "$4/bin/claude.version"',
'chmod 700 "$4/bin/claude"',
].join("\n"), { mode: 0o700 });
const env = { PATH: `${root}:${process.env.PATH}`, VERSION_FILE: versionFile, INSTALL_RECEIPT: receipt, INSTALLER_RESULT: installer, SOURCE_CLAUDE: path.join(root, "claude") };
const target: AdapterExecutionTarget = {
kind: "remote", transport: "sandbox", providerKey: "fixture", remoteCwd: root,
runner: {
async execute(input) {
const startedAt = new Date().toISOString();
if (input.args?.some(arg => arg.includes("npm install"))) {
if (installer == "timeout") return { stdout: "", stderr: "registry-secret", exitCode: null, signal: "SIGTERM", timedOut: true, pid: null, startedAt };
if (installer == "throw") throw new Error("registry-secret");
}
try {
const result = await runFile(input.command, input.args ?? [], {
cwd: input.cwd ?? root, env: { ...process.env, ...env, ...input.env }, timeout: input.timeoutMs,
});
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
} catch (error) {
const result = error as Error & { code?: number; stdout?: string; stderr?: string; signal?: string; killed?: boolean };
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", exitCode: result.code ?? 1,
signal: result.signal ?? null, timedOut: result.killed === true, pid: null, startedAt };
}
},
},
};
return { target, receipt, input: { runId: "version-fixture", command: "claude", target, cwd: root, env, timeoutSec: 10, graceSec: 1, minimumVersion: "2.1.280" } };
}
describe("managed sandbox Claude CLI compatibility", () => {
it("updates an existing outdated CLI to the exact required version and verifies it", async () => {
const { input, receipt } = await fixture("2.1.263");
expect(await prepareCompatibleClaudeCommand(input)).toMatchObject({ version: "2.1.280", command: path.join(input.cwd, ".paperclip-runtime/claude-cli/2.1.280/bin/claude"), check: { code: "claude_runtime_prepared", level: "info" } });
expect(await readFile(path.join(input.cwd, "version"), "utf8")).toBe("2.1.263");
expect((await readFile(receipt, "utf8")).trim()).toBe(`install --global --prefix ${path.join(input.cwd, ".paperclip-runtime/claude-cli/2.1.280")} @anthropic-ai/claude-code@2.1.280`);
});
it.each(["2.1.280", "2.1.286"])("keeps a compatible CLI %s without installing", async version => {
const { input, receipt } = await fixture(version);
expect(await prepareCompatibleClaudeCommand(input)).toEqual({ command: "claude", version });
await expect(readFile(receipt)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["fail", "unchanged"])("preserves the failed version gate after an installer result of %s", async installer => {
const { input, receipt } = await fixture("2.1.263", installer);
expect(await prepareCompatibleClaudeCommand(input)).toMatchObject({ command: "claude", version: "2.1.263", check: { code: "claude_runtime_preparation_failed", level: "warn" } });
expect((await readFile(receipt, "utf8")).trim()).toBe(`install --global --prefix ${path.join(input.cwd, ".paperclip-runtime/claude-cli/2.1.280")} @anthropic-ai/claude-code@2.1.280`);
});
it.each([
["EACCES", "The package installation was denied filesystem access."],
["ENOTFOUND", "The package installation could not reach the registry."],
["ETARGET", "The required package version could not be resolved from the registry."],
["unknown-error", "The package installer exited with code 7."],
["timeout", "The package installation timed out."],
["throw", "The package installer could not complete execution."],
["unchanged", "The prepared runtime did not report its version."],
])("reports safe diagnostics after %s without exposing installer output", async (installer, detail) => {
const { input } = await fixture("2.1.263", installer);
const result = await prepareCompatibleClaudeCommand(input);
expect(result).toMatchObject({ command: "claude", version: "2.1.263", check: { code: "claude_runtime_preparation_failed", detail } });
expect(JSON.stringify(result)).not.toContain("registry-secret");
expect(JSON.stringify(result)).not.toContain("example.test");
});
it("does not replace a caller's custom command", async () => {
const { input, receipt } = await fixture("2.1.263");
expect(await prepareCompatibleClaudeCommand({ ...input, command: path.join(input.cwd, "claude") })).toEqual({ command: path.join(input.cwd, "claude"), version: "2.1.263" });
await expect(readFile(receipt)).rejects.toMatchObject({ code: "ENOENT" });
});
it("does not install when the CLI version is unknown", async () => {
const { input, receipt } = await fixture("unknown");
expect(await prepareCompatibleClaudeCommand(input)).toEqual({ command: "claude", version: null });
await expect(readFile(receipt)).rejects.toMatchObject({ code: "ENOENT" });
});
it("does not install into a local execution target", async () => {
const { input, receipt } = await fixture("2.1.263");
expect(await prepareCompatibleClaudeCommand({ ...input, target: null })).toEqual({ command: "claude", version: "2.1.263" });
await expect(readFile(receipt)).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -0,0 +1,61 @@
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import { maybeRunSandboxInstallCommand, type AdapterSandboxInstallCommandCheck } from "@paperclipai/adapter-utils/execution-target";
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
import path from "node:path";
import { claudeCliVersionAtLeast, readClaudeCommandVersion } from "./cli-capabilities.js";
function installationFailureDetail(check: AdapterSandboxInstallCommandCheck | null): string {
if (check?.message.includes("timed out")) return "The package installation timed out.";
const detail = check?.detail ?? "";
if (/\b(?:EACCES|EPERM)\b/.test(detail)) return "The package installation was denied filesystem access.";
if (/\b(?:EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNREFUSED)\b/.test(detail)) return "The package installation could not reach the registry.";
if (/\b(?:E404|ETARGET|ENOVERSIONS)\b/.test(detail)) return "The required package version could not be resolved from the registry.";
if (check?.message.includes("threw before completion")) return "The package installer could not complete execution.";
const exitCode = check?.message.match(/^Install command exited (\d+):/)?.[1];
return exitCode ? `The package installer exited with code ${exitCode}.` : "The package installation did not complete successfully.";
}
export async function prepareCompatibleClaudeCommand(
input: Parameters<typeof readClaudeCommandVersion>[0] & { minimumVersion: string },
): Promise<{ command: string; version: string | null; check?: AdapterEnvironmentCheck }> {
const detected = await readClaudeCommandVersion(input);
const original = { command: input.command, version: detected };
if (
!detected || claudeCliVersionAtLeast(detected, input.minimumVersion) ||
input.target?.kind !== "remote" || input.target.transport !== "sandbox" ||
input.command !== "claude" || !/^\d+\.\d+\.\d+$/.test(input.minimumVersion)
) return original;
const prefix = path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "claude-cli", input.minimumVersion);
const command = path.posix.join(prefix, "bin", "claude");
const existing = await readClaudeCommandVersion({ ...input, command });
if (existing && claudeCliVersionAtLeast(existing, input.minimumVersion)) return { command, version: existing };
const installation = await maybeRunSandboxInstallCommand({
runId: input.runId,
target: input.target,
adapterKey: "claude",
installCommand: `npm install --global --prefix ${shellQuote(prefix)} @anthropic-ai/claude-code@${input.minimumVersion}`,
env: Object.fromEntries(Object.entries(input.env).filter(([key]) => [
"PATH", "HOME", "TMPDIR", "TEMP", "TMP", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
"NPM_CONFIG_REGISTRY", "npm_config_registry",
].includes(key))),
timeoutSec: 120,
});
if (installation?.level !== "info") return { ...original, check: {
code: "claude_runtime_preparation_failed", level: "warn",
message: "Paperclip could not install a compatible Claude runtime.",
detail: installationFailureDetail(installation),
} };
const version = await readClaudeCommandVersion({ ...input, command });
if (!version || !claudeCliVersionAtLeast(version, input.minimumVersion)) return { ...original, check: {
code: "claude_runtime_preparation_failed", level: "warn",
message: "Paperclip installed the Claude runtime but could not verify a compatible version.",
detail: version ? `The prepared runtime reported Claude Code ${version}.` : "The prepared runtime did not report its version.",
} };
return { command, version, check: {
code: "claude_runtime_prepared", level: "info",
message: `Paperclip prepared Claude Code ${version} for the selected model.`,
} };
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterExecutionTarget, AdapterSandboxInstallCommandCheck } from "@paperclipai/adapter-utils/execution-target";
const {
ensureAdapterExecutionTargetDirectory,
@@ -17,7 +17,7 @@ const {
probeResult,
ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}),
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
maybeRunSandboxInstallCommand: vi.fn(async () => null),
maybeRunSandboxInstallCommand: vi.fn(async (): Promise<AdapterSandboxInstallCommandCheck | null> => null),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: probeResult.value.exitCode,
signal: null,
@@ -141,6 +141,47 @@ describe("claude sandbox auth-missing check", () => {
});
describe("claude CLI model compatibility check", () => {
it("runs the hello probe with the compatible private CLI after updating an old sandbox", async () => {
const command = "/home/daytona/paperclip-workspace/.paperclip-runtime/claude-cli/2.1.280/bin/claude";
const response = { signal: null, timedOut: false, pid: 123, startedAt: new Date().toISOString(), stderr: "" };
runAdapterExecutionTargetProcess
.mockResolvedValueOnce({ ...response, exitCode: 0, stdout: "2.1.263 (Claude Code)\n" })
.mockResolvedValueOnce({ ...response, exitCode: 1, stdout: "" })
.mockResolvedValueOnce({ ...response, exitCode: 0, stdout: "2.1.280 (Claude Code)\n" });
maybeRunSandboxInstallCommand.mockResolvedValueOnce(null).mockResolvedValueOnce({
code: "claude_install_command_run", level: "info", message: "Installed compatible Claude runtime.",
});
probeResult.value = { exitCode: 0, stdout: [initLine,
JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "hello", session_id: "abc" }),
].join("\n"), stderr: "" };
const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local",
config: { engine: "cli", model: "claude-opus-5-5", env: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-subscription" } },
executionTarget: sandboxTarget, environmentName: "Daytona" });
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed" }));
const hello = runAdapterExecutionTargetProcess.mock.calls.find(call => (call as unknown as [string, unknown, string, string[]])[3]?.includes("--print"));
expect((hello as unknown as [string, unknown, string])[2]).toBe(command);
});
it("includes a safe runtime preparation failure in the environment checks", async () => {
const response = { signal: null, timedOut: false, pid: 123, startedAt: new Date().toISOString(), stderr: "" };
runAdapterExecutionTargetProcess
.mockResolvedValueOnce({ ...response, exitCode: 0, stdout: "2.1.263 (Claude Code)\n" })
.mockResolvedValueOnce({ ...response, exitCode: 1, stdout: "" });
maybeRunSandboxInstallCommand.mockResolvedValueOnce(null).mockResolvedValueOnce({
code: "claude_install_command_run", level: "warn", message: "Install command exited 1: npm install",
detail: "npm error code ENOTFOUND https://registry-secret@example.test/private",
});
const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local",
config: { engine: "cli", model: "claude-opus-5-5" }, executionTarget: sandboxTarget });
expect(result.status).toBe("fail");
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_runtime_preparation_failed",
detail: "The package installation could not reach the registry." }));
expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_cli_version_incompatible" }));
expect(JSON.stringify(result)).not.toContain("registry-secret");
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(2);
});
it.each([
["claude-fable-5-1", "2.1.251", "2.1.247"],
["claude-opus-5-5", "2.1.280", "2.1.279"],
@@ -157,7 +198,7 @@ describe("claude CLI model compatibility check", () => {
adapterType: "claude_local",
config: {
engine: "cli",
command: "claude",
command: "/opt/operator/claude",
model,
},
executionTarget: sandboxTarget,
@@ -29,8 +29,8 @@ import {
claudeCommandLooksLike,
claudeCommandSupportsEffortFlag,
minimumClaudeCliVersionForModel,
readClaudeCommandVersion,
} from "./cli-capabilities.js";
import { prepareCompatibleClaudeCommand } from "./sandbox-cli-version.js";
import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs, claudeSandboxPermissionEnv } from "./permissions.js";
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
@@ -87,7 +87,7 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "claude");
let command = asString(config.command, "claude");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
@@ -274,7 +274,7 @@ export async function testEnvironment(
});
} else if (canRunProbe && minimumCliVersion && versionProbeCommand) {
const versionProbeEnv = localProbe?.env ?? env;
const detectedCliVersion = await readClaudeCommandVersion({
const prepared = await prepareCompatibleClaudeCommand({
runId,
command: versionProbeCommand,
target,
@@ -282,7 +282,11 @@ export async function testEnvironment(
env: versionProbeEnv,
timeoutSec: 45,
graceSec: 5,
minimumVersion: minimumCliVersion,
});
if (prepared.check) checks.push(prepared.check);
command = prepared.command;
const detectedCliVersion = prepared.version;
if (
!detectedCliVersion ||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
@@ -295,7 +299,9 @@ export async function testEnvironment(
detail: detectedCliVersion
? `Detected Claude Code ${detectedCliVersion}.`
: "Could not determine the installed Claude Code version.",
hint: "Upgrade Claude Code or restore the default ACP lane, then retry the Test.",
hint: targetIsSandbox && command === "claude"
? "Paperclip could not prepare a compatible Claude runtime. Retry connecting."
: "Upgrade Claude Code or restore the default ACP lane, then retry the Test.",
});
}
}
@@ -1049,6 +1049,59 @@ describe("claude execute", () => {
}
}, 10_000);
it.each(["compatible", "network-error"])("prepares the selected Claude command before probing effort and logs the %s outcome", async state => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-prepared-effort-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
commandWriter: writeHelpWithoutEffortClaudeCommand,
});
const remoteWorkspace = path.join(root, "sandbox-workspace");
const preparedCommand = path.join(remoteWorkspace, ".paperclip-runtime/claude-cli/2.1.280/bin/claude");
const logs: string[] = [];
const commands: string[] = [];
const loggedCommands: string[] = [];
try {
await fs.writeFile(commandPath, (await fs.readFile(commandPath, "utf8")).replace("2.1.286", "2.1.263"));
await fs.mkdir(remoteWorkspace, { recursive: true });
if (state === "compatible") {
await fs.mkdir(path.dirname(preparedCommand), { recursive: true });
await writeHelpWithEffortClaudeCommand(preparedCommand);
}
const runner = createLocalSandboxRunner();
const result = await execute({
runId: `run-prepared-effort-${state}`,
agent: { id: "agent-1", companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: { engine: "cli", command: "claude", cwd: workspace, model: "claude-opus-5-5", effort: "high",
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, promptTemplate: "Keep the selected reasoning effort." },
context: {},
executionTarget: { kind: "remote", transport: "sandbox", providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd: remoteWorkspace,
runner: { execute: async input => {
if (input.args?.some(arg => arg.includes("npm install"))) return { exitCode: 1, signal: null, timedOut: false,
stdout: "", stderr: "npm error code ENOTFOUND https://registry-secret@example.test/private", pid: null, startedAt: new Date().toISOString() };
return runner.execute(input);
} } },
authToken: "run-jwt-token", onLog: async (_stream, chunk) => { logs.push(chunk); },
onMeta: async meta => { commands.push(meta.command); loggedCommands.push(meta.env.PAPERCLIP_RESOLVED_COMMAND); },
});
if (state === "compatible") {
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toEqual(expect.arrayContaining(["--effort", "high"]));
expect(commands).toContain(preparedCommand);
expect(loggedCommands).toEqual(commands);
expect(logs.join("")).not.toContain("omitting configured effort");
} else {
expect(result.errorCode).toBe("claude_cli_version_incompatible");
expect(logs.join("")).toContain("The package installation could not reach the registry.");
expect(logs.join("")).not.toContain("registry-secret");
await expect(fs.readFile(capturePath)).rejects.toMatchObject({ code: "ENOENT" });
}
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
}, 10_000);
it("passes through --effort and reuses the sandbox capability probe across sandbox leases when the installed Claude CLI advertises it", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-supported-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
@@ -732,6 +732,33 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it.each([false, true])("shows manual CLI debugging only outside managed Cloud (managed: %s)", async managed => {
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated", ...(managed ? { cloud: { managed: true, managedBy: "paperclip-cloud" } } : {}) });
mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "fail", testedAt: new Date().toISOString(),
checks: [{ code: "claude_hello_probe_failed", level: "error", message: "The runtime test did not finish.",
detail: "The provider exited with code 1.",
hint: "Exit code 1. Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug." }] });
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
try {
await clickByText(t => t.startsWith("Continue"));
await act(async () => setControlledValue(document.body.querySelector("#onboarding-agent-name") as HTMLInputElement, "Ada"));
await flushReact();
await clickByText(isArcPrimary);
await pickFirstSource(clickByText);
await clickByText(isArcPrimary);
expect(document.body.textContent).toContain("The runtime test did not finish.");
expect(document.body.textContent).toContain("The provider exited with code 1.");
expect(document.body.textContent?.includes("manually in this directory")).toBe(!managed);
expect(document.body.textContent?.includes("Manual debug")).toBe(!managed);
expect(document.body.textContent?.includes("--output-format stream-json")).toBe(!managed);
expect(document.body.textContent?.includes("Fix the reported checks")).toBe(!managed);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
await act(async () => root.unmount());
}
});
it("hires from a legacy draft that saved an empty role", async () => {
// `agentRole: ""` was this field's default before the arc stopped asking
// for a role, so every draft saved by an earlier build carries it. `??`
+10 -6
View File
@@ -2470,7 +2470,9 @@ function OnboardingWizardInner({
? result.checks.find(check => check.level === "error")?.message : undefined;
setError(
nativeCodexError || (result.status === "fail"
? "The environment test failed. Fix the reported checks before you hire this agent."
? cloudInstance
? "Paperclip couldn't finish connecting this agent. Try connecting again."
: "The environment test failed. Fix the reported checks before you hire this agent."
: "No working authentication was found. Fix the reported checks before you hire this agent."),
);
return;
@@ -3315,10 +3317,10 @@ function OnboardingWizardInner({
</div>
{/* Show the checks on a pass too, so the target and the
auth signals stay visible before the hire. */}
<AdapterEnvironmentResult result={adapterEnvResult} />
<AdapterEnvironmentResult result={adapterEnvResult} showHints={localLoginHealth.isSuccess && !cloudInstance} />
</div>
) : adapterEnvResult ? (
<AdapterEnvironmentResult result={adapterEnvResult} />
<AdapterEnvironmentResult result={adapterEnvResult} showHints={localLoginHealth.isSuccess && !cloudInstance} />
) : null}
{shouldSuggestUnsetAnthropicApiKey && (
@@ -3345,7 +3347,7 @@ function OnboardingWizardInner({
</div>
)}
{adapterEnvResult && adapterEnvResult.status === "fail" && (
{adapterEnvResult && adapterEnvResult.status === "fail" && localLoginHealth.isSuccess && !cloudInstance && (
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-(length:--text-micro) space-y-1.5">
<p className="font-medium">Manual debug</p>
<p className="text-muted-foreground font-mono break-all">
@@ -3514,9 +3516,11 @@ function OnboardingWizardInner({
}
function AdapterEnvironmentResult({
result
result,
showHints,
}: {
result: AdapterEnvironmentTestResult;
showHints: boolean;
}) {
const statusLabel =
result.status === "pass"
@@ -3555,7 +3559,7 @@ function AdapterEnvironmentResult({
({check.detail})
</span>
)}
{check.hint && (
{showHints && check.hint && (
<span className="block opacity-90 break-words">
Hint: {check.hint}
</span>