Handle older Claude sandboxes without --effort

This commit is contained in:
Devin Foley committed 2026-06-20 11:53:19 -07:00
1 parent e93d78b46c
commit 97bf7de45c
5 files changed
+270 -9

No files matched your search

@@ -0,0 +1,40 @@
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import path from "node:path";
export function claudeCommandLooksLike(command: string, expected = "claude"): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
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 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;
}
@@ -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";
@@ -592,6 +593,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 ?? "");
@@ -702,7 +722,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
@@ -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,6 +2,7 @@ import { afterEach, describe, expect, it } 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 { testEnvironment } from "@paperclipai/adapter-claude-local/server";
const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY;
@@ -26,6 +27,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 +331,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 });
}
});
});
@@ -74,6 +74,44 @@ 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);
}
type CapturePayload = {
argv: string[];
prompt: string;
@@ -731,6 +769,63 @@ 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("allows remote session resumes when saved cwd is the host workspace", () => {
expect(claudeSessionCwdMatchesExecutionTarget({
runtimeSessionCwd: "/host/workspace",