diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 66d70867c58..50aba9a3119 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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. diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md index c6bac74ddd3..62b3cb642c5 100644 --- a/maps/agent-access-map/onboarding.md +++ b/maps/agent-access-map/onboarding.md @@ -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 diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 209f2e61072..6453e64178e 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -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; diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md index e15d3c7f827..3910b939c19 100644 --- a/maps/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -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. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 181fb514009..564f4598fed 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -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 diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 3029ddac5f1..c78a8cc35c2 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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 { + 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" }); + }); +}); diff --git a/packages/adapters/claude-local/src/server/sandbox-cli-version.ts b/packages/adapters/claude-local/src/server/sandbox-cli-version.ts new file mode 100644 index 00000000000..50677a9cc3d --- /dev/null +++ b/packages/adapters/claude-local/src/server/sandbox-cli-version.ts @@ -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[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.`, + } }; +} diff --git a/packages/adapters/claude-local/src/server/test.remote.test.ts b/packages/adapters/claude-local/src/server/test.remote.test.ts index 94c9633155a..d0bf348f14e 100644 --- a/packages/adapters/claude-local/src/server/test.remote.test.ts +++ b/packages/adapters/claude-local/src/server/test.remote.test.ts @@ -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 => 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, diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 2aefa947fc5..40859fa6455 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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.", }); } } diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 0211abcb545..52a671d8297 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -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, { diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index a76bdeb6673..98753282009 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -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. `??` diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 1d53b817270..cb74154cc40 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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({ {/* Show the checks on a pass too, so the target and the auth signals stay visible before the hire. */} - + ) : adapterEnvResult ? ( - + ) : null} {shouldSuggestUnsetAnthropicApiKey && ( @@ -3345,7 +3347,7 @@ function OnboardingWizardInner({ )} - {adapterEnvResult && adapterEnvResult.status === "fail" && ( + {adapterEnvResult && adapterEnvResult.status === "fail" && localLoginHealth.isSuccess && !cloudInstance && (

Manual debug

@@ -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}) )} - {check.hint && ( + {showHints && check.hint && ( Hint: {check.hint}