From c0743482bc7a6283bd61e9ccc69493a890b57231 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 20 Jun 2026 13:05:10 -0700 Subject: [PATCH] fix(claude-local): tolerate sandboxes whose Claude CLI lacks --effort (#8393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The claude-local adapter launches the Claude CLI inside execution environments (local, SSH, and ephemeral sandboxes such as Daytona) > - Newer adapter code passes `--effort` to the CLI, but the Claude binary baked into some sandbox images is older and rejects it with `error: unknown option '--effort'`, so every run in those environments fails > - This needs addressing because the failure is environment-dependent and silent from the operator's perspective — the run just dies with a CLI usage error > - This pull request probes the in-sandbox CLI for `--effort` support once, caches the result per environment, and strips the flag (with a warning) when the CLI does not support it > - The benefit is that sandboxes with older Claude CLIs keep working instead of failing, with negligible probe overhead because the capability check is cached and reused across ephemeral leases ## Linked Issues or Issue Description No public GitHub issue exists. Describing the bug inline following the bug report template: ### What happened? Runs using the `claude-local` adapter inside certain sandbox/execution environments fail with `error: unknown option '--effort'`. The adapter unconditionally appends `--effort` to the Claude CLI invocation, but the Claude CLI version present in some sandbox base images predates that flag, so the process exits with a usage error and the run dies. ### Expected behavior The adapter should detect that the target environment's Claude CLI does not support `--effort` and degrade gracefully — drop the flag and emit a warning — rather than failing the run. ### Steps to reproduce 1. Configure an execution environment (e.g. a sandbox image) whose bundled Claude CLI is old enough to predate the `--effort` option. 2. Run any claude-local task that resolves to an effort level (so `--effort` is appended). 3. Observe the run fail immediately with `error: unknown option '--effort'`. ### Paperclip version or commit `master` at the time of this PR (branch forked from current `master`). ### Deployment mode Self-hosted / local instance using execution environments (reproducible with ephemeral sandbox providers such as Daytona where `reuseLease: false`). ### Agent adapter(s) involved Claude Code (`claude-local`). ## What Changed - Add a CLI capability probe (`cli-capabilities.ts`) that runs the target Claude binary's `--help` inside the execution environment to detect `--effort` support. - Strip `--effort` from the CLI args (emitting a warning) when the probe reports the flag is unsupported; keep it otherwise. - Cache probe results keyed by `sandbox:providerKey:environmentId:command` (no lease id) so the probe is reused across ephemeral leases — important for `reuseLease: false` sandbox configs like Daytona, which would otherwise re-probe on every run. - Conservative fallback: if the probe itself can't run/parse, assume the flag is supported (preserves prior behavior). ## Verification - `node_modules/.bin/vitest run src/__tests__/claude-local-execute.test.ts src/__tests__/claude-local-adapter-environment.test.ts` → **2 files, 30 tests passed**. - Regression test issues two `execute()` calls with distinct lease ids and asserts the in-sandbox `--help` probe runs exactly once (cache reuse across leases). - Added tests covering: flag stripped when unsupported, flag retained when supported, warning emitted, and conservative fallback when the probe fails. ## Risks Low risk. The change is additive and gated behind a probe with a conservative default (assume supported on probe failure), so existing environments that support `--effort` are unaffected. Worst case for an environment where the probe is unreliable is the prior behavior (flag passed through). ## Model Used Claude Opus 4.8 (claude-opus-4-8), extended thinking, with tool use / code execution via Claude Code. ## 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 - [ ] If this change affects the UI, I have included before/after screenshots (N/A — no UI change) - [ ] I have updated relevant documentation to reflect my changes (N/A — no doc-facing behavior change) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending review) - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/server/cli-capabilities.ts | 94 +++++++ .../claude-local/src/server/execute.ts | 22 +- .../adapters/claude-local/src/server/index.ts | 4 + .../adapters/claude-local/src/server/test.ts | 34 ++- .../claude-local-adapter-environment.test.ts | 91 +++++- .../__tests__/claude-local-execute.test.ts | 263 +++++++++++++++++- 6 files changed, 496 insertions(+), 12 deletions(-) create mode 100644 packages/adapters/claude-local/src/server/cli-capabilities.ts diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.ts b/packages/adapters/claude-local/src/server/cli-capabilities.ts new file mode 100644 index 00000000..fb345b30 --- /dev/null +++ b/packages/adapters/claude-local/src/server/cli-capabilities.ts @@ -0,0 +1,94 @@ +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target"; +import path from "node:path"; + +const effortFlagSupportCache = new Map>(); + +export function claudeCommandLooksLike(command: string, expected = "claude"): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | null | undefined): string { + if (!target) return `local::${command}`; + if (target.kind === "local") { + return `local:${target.environmentId ?? ""}:${target.leaseId ?? ""}:${command}`; + } + if (target.transport === "sandbox") { + return [ + "sandbox", + target.providerKey ?? "", + target.environmentId ?? "", + command, + ].join(":"); + } + return [ + "ssh", + target.environmentId ?? "", + target.leaseId ?? "", + target.spec.host, + target.spec.port ?? "", + target.spec.username ?? "", + command, + ].join(":"); +} + +async function probeClaudeCommandSupportsEffortFlag(input: { + runId: string; + command: string; + target: AdapterExecutionTarget | null | undefined; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; +}): Promise { + const help = await runAdapterExecutionTargetProcess( + input.runId, + input.target, + input.command, + ["--help"], + { + cwd: input.cwd, + env: input.env, + timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)), + graceSec: Math.max(1, Math.min(input.graceSec, 5)), + onLog: async () => {}, + }, + ); + + if (help.timedOut) return null; + const output = `${help.stdout}\n${help.stderr}`; + if (output.includes("--effort")) return true; + if ((help.exitCode ?? 0) === 0) return false; + return null; +} + +export async function claudeCommandSupportsEffortFlag(input: { + runId: string; + command: string; + target: AdapterExecutionTarget | null | undefined; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; +}): Promise { + if (!claudeCommandLooksLike(input.command, "claude")) return null; + + const key = cacheKeyForTarget(input.command, input.target); + const cached = effortFlagSupportCache.get(key); + if (cached) return cached; + + // A thrown probe (e.g. sandbox connection error, ENOENT spawning the binary) + // must degrade to the conservative fallback rather than killing the run, so we + // resolve to null and drop the cache entry to retry on the next lease. + const probe = probeClaudeCommandSupportsEffortFlag(input).catch(() => { + effortFlagSupportCache.delete(key); + return null; + }); + effortFlagSupportCache.set(key, probe); + return probe; +} + +export function resetClaudeCliCapabilitiesCacheForTests() { + effortFlagSupportCache.clear(); +} diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 57848b73..ca4dce8e 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -59,6 +59,7 @@ import { isClaudeImageProcessingError, } from "./parse.js"; import { prepareClaudeConfigSeed, resolveSharedClaudeConfigDir } from "./claude-config.js"; +import { claudeCommandSupportsEffortFlag } from "./cli-capabilities.js"; import { resolveClaudeDesiredSkillNames } from "./skills.js"; import { isBedrockModelId } from "./models.js"; import { prepareClaudePromptBundle } from "./prompt-cache.js"; @@ -593,6 +594,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push("--max-turns", String(maxTurns)); // On resumed sessions the instructions are already in the session cache; // re-injecting them via --append-system-prompt-file wastes 5-10K tokens diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 1529033d..31ba501f 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -2,6 +2,10 @@ export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js"; export { testEnvironment } from "./test.js"; +export { + claudeCommandSupportsEffortFlag, + resetClaudeCliCapabilitiesCacheForTests, +} from "./cli-capabilities.js"; export { parseClaudeStreamJson, describeClaudeFailure, diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 66e451cc..c29a4485 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -19,8 +19,8 @@ import { describeAdapterExecutionTarget, resolveAdapterExecutionTargetCwd, } from "@paperclipai/adapter-utils/execution-target"; -import path from "node:path"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; +import { claudeCommandLooksLike, claudeCommandSupportsEffortFlag } from "./cli-capabilities.js"; import { isBedrockModelId } from "./models.js"; import { buildClaudeProbePermissionArgs } from "./permissions.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; @@ -44,11 +44,6 @@ function firstNonEmptyLine(text: string): string { ); } -function commandLooksLike(command: string, expected: string): boolean { - const base = path.basename(command).toLowerCase(); - return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; -} - function summarizeProbeDetail(stdout: string, stderr: string): string | null { const raw = firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); if (!raw) return null; @@ -181,7 +176,7 @@ export async function testEnvironment( const canRunProbe = checks.every((check) => check.code !== "claude_cwd_invalid" && check.code !== "claude_command_unresolvable"); if (canRunProbe) { - if (!commandLooksLike(command, "claude")) { + if (!claudeCommandLooksLike(command, "claude")) { checks.push({ code: "claude_hello_probe_skipped_custom_command", level: "info", @@ -201,6 +196,29 @@ export async function testEnvironment( return asStringArray(config.args); })(); + let effectiveEffort = effort; + if (targetIsSandbox && effort) { + const supportsEffort = await claudeCommandSupportsEffortFlag({ + runId, + command, + target, + cwd, + env, + timeoutSec: 45, + graceSec: 5, + }); + if (supportsEffort === false) { + effectiveEffort = ""; + checks.push({ + code: "claude_effort_flag_unsupported", + level: "warn", + message: + "Claude CLI in the sandbox does not advertise --effort; the probe omitted the configured reasoning effort.", + hint: "Upgrade the sandbox CLI/template to a newer Claude Code release to restore reasoning-effort control.", + }); + } + } + const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; args.push(...buildClaudeProbePermissionArgs({ dangerouslySkipPermissions, targetIsSandbox })); if (chrome) args.push("--chrome"); @@ -208,7 +226,7 @@ export async function testEnvironment( if (model && (!hasBedrock || isBedrockModelId(model))) { args.push("--model", model); } - if (effort) args.push("--effort", effort); + if (effectiveEffort) args.push("--effort", effectiveEffort); if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); if (extraArgs.length > 0) args.push(...extraArgs); diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index da800e4d..7847d0e1 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -2,13 +2,15 @@ import { afterEach, describe, expect, it } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { testEnvironment } from "@paperclipai/adapter-claude-local/server"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; +import { resetClaudeCliCapabilitiesCacheForTests, testEnvironment } from "@paperclipai/adapter-claude-local/server"; const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY; const ORIGINAL_BEDROCK = process.env.CLAUDE_CODE_USE_BEDROCK; const ORIGINAL_BEDROCK_URL = process.env.ANTHROPIC_BEDROCK_BASE_URL; afterEach(() => { + resetClaudeCliCapabilitiesCacheForTests(); if (ORIGINAL_ANTHROPIC === undefined) { delete process.env.ANTHROPIC_API_KEY; } else { @@ -26,6 +28,58 @@ afterEach(() => { } }); +async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const argv = process.argv.slice(2); +if (argv.includes("--help")) { + process.stdout.write("Usage: claude [options]\\n --print\\n --model \\n"); + process.exit(0); +} +if (argv.includes("--effort")) { + process.stderr.write("error: unknown option '--effort'\\n"); + process.exit(1); +} +console.log(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + counter += 1; + return runChildProcess( + `claude-envtest-sandbox-${counter}`, + input.command, + input.args ?? [], + { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + onSpawn: input.onSpawn + ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) + : undefined, + }, + ); + }, + }; +} + describe("claude_local environment diagnostics", () => { it("returns a warning (not an error) when ANTHROPIC_API_KEY is set in host environment", async () => { delete process.env.CLAUDE_CODE_USE_BEDROCK; @@ -278,4 +332,39 @@ describe("claude_local environment diagnostics", () => { // approval that no human is present to answer. expect(probeCall?.args).toContain("--allowedTools"); }); + + it("warns and omits --effort for sandbox probes when the installed Claude CLI does not advertise it", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-envtest-sandbox-effort-")); + const workspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "remote-workspace"); + const commandPath = path.join(root, "claude"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await writeHelpWithoutEffortClaudeCommand(commandPath); + + try { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "claude_local", + config: { + command: commandPath, + cwd: workspace, + effort: "low", + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd: remoteWorkspace, + runner: createLocalSandboxRunner(), + }, + environmentName: "QA Daytona", + }); + + expect(result.checks.some((check) => check.code === "claude_effort_flag_unsupported")).toBe(true); + expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 6dba0ac8..281c8527 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; -import { claudeSessionCwdMatchesExecutionTarget, execute } from "@paperclipai/adapter-claude-local/server"; +import { + claudeCommandSupportsEffortFlag, + claudeSessionCwdMatchesExecutionTarget, + execute, + resetClaudeCliCapabilitiesCacheForTests, +} from "@paperclipai/adapter-claude-local/server"; async function writeFailingClaudeCommand( commandPath: string, @@ -74,6 +79,83 @@ console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-811 await fs.chmod(commandPath, 0o755); } +async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const argv = process.argv.slice(2); +if (argv.includes("--help")) { + process.stdout.write("Usage: claude [options]\\n --print\\n --model \\n"); + process.exit(0); +} +if (argv.includes("--effort")) { + process.stderr.write("error: unknown option '--effort'\\n"); + process.exit(1); +} +const addDirIndex = argv.indexOf("--add-dir"); +const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; +const instructionsIndex = argv.indexOf("--append-system-prompt-file"); +const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv, + prompt: fs.readFileSync(0, "utf8"), + addDir, + instructionsFilePath, + instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "33333333-3333-4333-8333-333333333333", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "33333333-3333-4333-8333-333333333333", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-8333-333333333333", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const argv = process.argv.slice(2); +if (argv.includes("--help")) { + const helpCountPath = process.env.PAPERCLIP_TEST_HELP_COUNT_PATH; + if (helpCountPath) { + const current = fs.existsSync(helpCountPath) ? Number(fs.readFileSync(helpCountPath, "utf8")) || 0 : 0; + fs.writeFileSync(helpCountPath, String(current + 1), "utf8"); + } + process.stdout.write("Usage: claude [options]\\n --print\\n --effort \\n --model \\n"); + process.exit(0); +} +const addDirIndex = argv.indexOf("--add-dir"); +const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; +const instructionsIndex = argv.indexOf("--append-system-prompt-file"); +const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv, + prompt: fs.readFileSync(0, "utf8"), + addDir, + instructionsFilePath, + instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "44444444-4444-4444-8444-444444444444", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "44444444-4444-4444-8444-444444444444", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "44444444-4444-4444-8444-444444444444", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + type CapturePayload = { argv: string[]; prompt: string; @@ -90,6 +172,10 @@ type CapturePayload = { appendedSystemPromptFileContents?: string | null; }; +afterEach(() => { + resetClaudeCliCapabilitiesCacheForTests(); +}); + async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node const fs = require("node:fs"); @@ -731,6 +817,179 @@ describe("claude execute", () => { } }, 10_000); + it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, { + commandWriter: writeHelpWithoutEffortClaudeCommand, + }); + const remoteWorkspace = path.join(root, "sandbox-workspace"); + await fs.mkdir(remoteWorkspace, { recursive: true }); + + try { + const result = await execute({ + runId: "run-sandbox-effort-fallback", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + effort: "low", + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Fallback cleanly if the sandbox CLI is old.", + }, + context: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).not.toContain("--effort"); + } 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, { + commandWriter: writeHelpWithEffortClaudeCommand, + }); + const helpCountPath = path.join(root, "help-count.txt"); + const remoteWorkspace = path.join(root, "sandbox-workspace"); + await fs.mkdir(remoteWorkspace, { recursive: true }); + + const baseInput = { + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + effort: "low", + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + PAPERCLIP_TEST_HELP_COUNT_PATH: helpCountPath, + }, + promptTemplate: "Keep the requested effort when supported.", + }, + context: {}, + executionTarget: { + kind: "remote" as const, + transport: "sandbox" as const, + providerKey: "daytona", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }; + + try { + const first = await execute({ + runId: "run-sandbox-effort-supported-1", + ...baseInput, + }); + const second = await execute({ + runId: "run-sandbox-effort-supported-2", + ...baseInput, + executionTarget: { + ...baseInput.executionTarget, + leaseId: "lease-2", + }, + }); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toContain("--effort"); + expect(capture.argv).toContain("low"); + expect(await fs.readFile(helpCountPath, "utf8")).toBe("1"); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }, 10_000); + + it("degrades to the conservative fallback (returns null) when the sandbox probe throws, and retries on the next lease", async () => { + let calls = 0; + const throwingRunner = { + execute: async () => { + calls += 1; + throw new Error("sandbox connection error"); + }, + }; + const target = { + kind: "remote" as const, + transport: "sandbox" as const, + providerKey: "daytona", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: "/remote/workspace", + timeoutMs: 30_000, + runner: throwingRunner, + }; + const probeInput = { + runId: "run-probe-throws", + command: "/usr/local/bin/claude", + cwd: "/host/workspace", + env: {}, + timeoutSec: 20, + graceSec: 5, + }; + + // A thrown probe must resolve to null (unknown) rather than reject and kill the run. + await expect( + claudeCommandSupportsEffortFlag({ ...probeInput, target }), + ).resolves.toBeNull(); + + // The failed result is not cached: a second lease re-probes instead of reusing the rejection. + await expect( + claudeCommandSupportsEffortFlag({ + ...probeInput, + target: { ...target, leaseId: "lease-2" }, + }), + ).resolves.toBeNull(); + expect(calls).toBe(2); + }); + it("allows remote session resumes when saved cwd is the host workspace", () => { expect(claudeSessionCwdMatchesExecutionTarget({ runtimeSessionCwd: "/host/workspace",