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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 <id>\\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<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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 <id>\\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<void> {
|
||||
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 <level>\\n --model <id>\\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<void> {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user