fix(claude-local): tolerate sandboxes whose Claude CLI lacks --effort (#8393)

## 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
This commit is contained in:
Devin Foley
2026-06-20 13:05:10 -07:00
committed by GitHub
parent 07e98d2b2c
commit c0743482bc
6 changed files with 496 additions and 12 deletions
@@ -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<string, Promise<boolean | null>>();
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<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<boolean | null> {
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<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<boolean | null> {
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();
}
@@ -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<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 sandbox does not advertise --effort; omitting configured effort "${effort}". Upgrade the sandbox CLI/image to restore reasoning-effort control.\n`,
);
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -703,7 +723,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (model && (!isBedrockAuth(effectiveEnv) || 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));
// On resumed sessions the instructions are already in the session cache;
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
@@ -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,
@@ -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);