diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index e1d09f0ca3f..92e286bc47e 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -132,6 +132,23 @@ This differs from [`codex_local`](/adapters/codex-local), where a Paperclip-managed sandbox run uploads a host-owned `CODEX_HOME/auth.json` and therefore shadows any Codex login already present inside the sandbox image. +## Sandbox memory limits + +A sandbox has a fixed memory limit. When a tool command (for example a type +check or a dependency install) uses most of that memory, the kernel kills one +process. Without help it can kill the Claude Code process, and the run fails +with "The connection to Claude was lost." + +For managed sandbox runs, the same materialization step installs a +`tool-shell-prefix.sh` script in the run's Claude runtime directory and sets +`CLAUDE_CODE_SHELL_PREFIX` to it. Claude Code runs each Bash tool command and +hook through that script. The script raises the command's `oom_score_adj` to +1000 and then runs the command in the shell that Claude Code selects. The +kernel then kills the tool command first, and Claude sees a failed command +(exit 137) that it can recover from. Paperclip sets the variable only after the +sandbox confirms the install. An operator `CLAUDE_CODE_SHELL_PREFIX` in the +adapter env is kept, and no script is installed. + For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: ```sh diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 304c2d6f852..cd837397020 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -914,6 +914,62 @@ describe("claude_local ACP lane", () => { ); // C4 — no XDG_* variable is introduced for in-sandbox credential discovery. expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]); + const toolShellPrefix = String(meta[0]?.env?.CLAUDE_CODE_SHELL_PREFIX ?? ""); + expect(toolShellPrefix).toBe(path.posix.join(path.posix.dirname(remappedConfigDir), "tool-shell-prefix.sh")); + expect((await fs.stat(toolShellPrefix)).mode & 0o777).toBe(0o700); + }); + + it("keeps an operator CLAUDE_CODE_SHELL_PREFIX and installs no tool shell prefix", async () => { + const root = await makeTempRoot("paperclip-claude-acp-operator-prefix-"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + const sharedClaudeConfig = path.join(root, "shared-claude-config"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.mkdir(sharedClaudeConfig, { recursive: true }); + await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8"); + process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig; + + const meta: AdapterInvocationMeta[] = []; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never, + }); + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + env: { CLAUDE_CODE_SHELL_PREFIX: "/opt/operator/audit-shell.sh" }, + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + onMeta: async (payload: AdapterInvocationMeta) => { + meta.push(payload); + }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(meta[0]?.env?.CLAUDE_CODE_SHELL_PREFIX).toBe("/opt/operator/audit-shell.sh"); + const remoteConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? ""); + await expect( + fs.stat(path.join(path.dirname(remoteConfigDir), "tool-shell-prefix.sh")), + ).rejects.toMatchObject({ code: "ENOENT" }); }); it("test_claude_acp_seam_registers_workspace_sync_back", async () => { diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 957c4810500..5e24a81d5d7 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -44,6 +44,7 @@ import { materializeRemoteClaudeConfig, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime, + resolveRemoteClaudeToolShellPrefixPath, } from "./claude-config.js"; import { buildAdapterTestTargetCheck, @@ -296,13 +297,15 @@ async function prepareClaudeRemoteManagedHome( const remoteClaudeConfigSeedDir = stagedRuntime.assetDirs["config-seed"] ?? path.posix.join(remoteClaudeRuntimeRoot, "config-seed"); const remoteClaudeConfigDir = path.posix.join(remoteClaudeRuntimeRoot, "config"); + const remoteToolShellPrefixPath = resolveRemoteClaudeToolShellPrefixPath(env, remoteClaudeRuntimeRoot); await onLog("stdout", `[paperclip] Materializing Claude auth/config into ${remoteClaudeConfigDir}.\n`); - await materializeRemoteClaudeConfig({ + const { toolShellPrefixInstalled } = await materializeRemoteClaudeConfig({ runId, target: executionTarget, remoteClaudeConfigDir, remoteClaudeConfigSeedDir, + remoteToolShellPrefixPath, options: { cwd: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir, env, @@ -313,6 +316,9 @@ async function prepareClaudeRemoteManagedHome( }); // Repoint CLAUDE_CONFIG_DIR onto the in-sandbox config dir. env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir; + if (toolShellPrefixInstalled && remoteToolShellPrefixPath) { + env.CLAUDE_CODE_SHELL_PREFIX = remoteToolShellPrefixPath; + } return { stagedRuntime, teardown: registerWorkspaceSyncBack(stagedRuntime) }; } diff --git a/packages/adapters/claude-local/src/server/claude-config.test.ts b/packages/adapters/claude-local/src/server/claude-config.test.ts index 5eb0b701db9..3817f9260ac 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -22,7 +22,18 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { }; }); -import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + buildRemoteClaudeConfigMaterializationCommand, + CLAUDE_TOOL_SHELL_PREFIX_SCRIPT, + materializeRemoteClaudeConfig, + prepareClaudeConfigSeed, + prepareSandboxClaudeProbeRuntime, + resolveRemoteClaudeToolShellPrefixPath, +} from "./claude-config.js"; + +const execFileAsync = promisify(execFile); describe("prepareClaudeConfigSeed", () => { const cleanupDirs: string[] = []; @@ -222,3 +233,121 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { warnSpy.mockRestore(); }); }); + +describe("Claude tool shell prefix", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function installPrefix() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-tool-prefix-")); + cleanupDirs.push(root); + const prefixPath = path.join(root, "runtime", "tool-shell-prefix.sh"); + const command = buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: path.join(root, "runtime", "config"), + remoteClaudeConfigSeedDir: path.join(root, "runtime", "config-seed"), + remoteToolShellPrefixPath: prefixPath, + }); + const { stdout } = await execFileAsync("/bin/sh", ["-c", command], { env: { PATH: process.env.PATH ?? "" } }); + return { root, prefixPath, stdout }; + } + + async function runThroughPrefix(prefixPath: string, commandString: string, env: Record) { + const { stdout } = await execFileAsync(prefixPath, [commandString], { env }); + return stdout.trim(); + } + + it("installs the script as a private executable and reports the install", async () => { + const { prefixPath, stdout } = await installPrefix(); + await expect(fs.readFile(prefixPath, "utf8")).resolves.toBe(CLAUDE_TOOL_SHELL_PREFIX_SCRIPT); + expect((await fs.stat(prefixPath)).mode & 0o777).toBe(0o700); + expect(stdout).toContain("[paperclip] Installed the Claude tool shell prefix."); + await expect(fs.stat(`${prefixPath}.partial`)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("leaves the materialization command unchanged without a prefix path", () => { + const command = buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: "/sandbox/runtime/config", + remoteClaudeConfigSeedDir: "/sandbox/runtime/config-seed", + }); + expect(command.endsWith("done")).toBe(true); + expect(command).not.toContain("tool shell prefix"); + }); + + it.runIf(process.platform === "linux")( + "raises the tool command's out-of-memory score and keeps the caller's score", + async () => { + const { prefixPath } = await installPrefix(); + const before = (await fs.readFile("/proc/self/oom_score_adj", "utf8")).trim(); + const score = await runThroughPrefix(prefixPath, "cat /proc/self/oom_score_adj", { + PATH: process.env.PATH ?? "", + SHELL: "/bin/bash", + }); + expect(score).toBe("1000"); + expect((await fs.readFile("/proc/self/oom_score_adj", "utf8")).trim()).toBe(before); + }, + ); + + it("runs the command string in the shell Claude Code selects", async () => { + const { root, prefixPath } = await installPrefix(); + const bash = (await execFileAsync("/bin/sh", ["-c", "command -v bash"])).stdout.trim(); + const probe = 'if [ -n "${BASH_VERSION:-}" ]; then echo bash; else echo other; fi'; + await expect(runThroughPrefix(prefixPath, probe, { PATH: process.env.PATH ?? "", SHELL: bash })) + .resolves.toBe("bash"); + await expect(runThroughPrefix(prefixPath, probe, { + PATH: process.env.PATH ?? "", + SHELL: bash, + CLAUDE_CODE_SHELL: "/missing/bash", + })).resolves.toBe("bash"); + const binDir = path.join(root, "bin"); + await fs.mkdir(binDir, { recursive: true }); + await fs.symlink(bash, path.join(binDir, "bash")); + await expect(runThroughPrefix(prefixPath, probe, { PATH: binDir, SHELL: "/bin/sh" })) + .resolves.toBe("bash"); + await expect(runThroughPrefix(prefixPath, `printf '%s' "q'uote"`, { PATH: process.env.PATH ?? "", SHELL: bash })) + .resolves.toBe("q'uote"); + await expect(execFileAsync(prefixPath, ["exit 7"], { env: { PATH: process.env.PATH ?? "", SHELL: bash } })) + .rejects.toMatchObject({ code: 7 }); + }); + + it("keeps an operator prefix", () => { + expect(resolveRemoteClaudeToolShellPrefixPath({ CLAUDE_CODE_SHELL_PREFIX: "/opt/audit.sh" }, "/sandbox/runtime")) + .toBeNull(); + expect(resolveRemoteClaudeToolShellPrefixPath({}, "/sandbox/runtime")) + .toBe("/sandbox/runtime/tool-shell-prefix.sh"); + }); + + it("reports no install when the sandbox does not confirm it", async () => { + const target: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd: "/home/daytona/paperclip-workspace", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }; + await expect(materializeRemoteClaudeConfig({ + runId: "run-1", + target, + remoteClaudeConfigDir: "/sandbox/runtime/config", + remoteClaudeConfigSeedDir: "/sandbox/runtime/config-seed", + remoteToolShellPrefixPath: "/sandbox/runtime/tool-shell-prefix.sh", + options: { cwd: "/home/daytona/paperclip-workspace", env: {} }, + })).resolves.toEqual({ toolShellPrefixInstalled: false }); + }); +}); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index 20eece2fa4e..adc836e7fcc 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -218,10 +218,37 @@ export async function prepareClaudeConfigSeed( return targetDir; } +export const CLAUDE_TOOL_SHELL_PREFIX_SCRIPT = [ + "#!/bin/sh", + "echo 1000 > /proc/self/oom_score_adj 2>/dev/null || :", + "shell=", + 'case "${CLAUDE_CODE_SHELL:-}" in *bash*|*zsh*) [ -x "$CLAUDE_CODE_SHELL" ] && shell=$CLAUDE_CODE_SHELL ;; esac', + 'if [ -z "$shell" ]; then', + ' case "${SHELL:-}" in *bash*|*zsh*) [ -x "$SHELL" ] && shell=$SHELL ;; esac', + "fi", + 'if [ -z "$shell" ]; then', + ' case "${SHELL:-}" in *bash*) order="bash zsh" ;; *) order="zsh bash" ;; esac', + " for name in $order; do", + ' candidate=$(command -v "$name" 2>/dev/null) && [ -x "$candidate" ] && { shell=$candidate; break; }', + " done", + "fi", + 'exec "${shell:-/bin/sh}" -c "$1"', + "", +].join("\n"); + +const CLAUDE_TOOL_SHELL_PREFIX_INSTALLED_MARKER = "[paperclip] Installed the Claude tool shell prefix."; + export function buildRemoteClaudeConfigMaterializationCommand(input: { remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteToolShellPrefixPath?: string | null; }): string { + const prefixPath = input.remoteToolShellPrefixPath; + const installToolShellPrefix = prefixPath + ? `; { umask 077 && printf '%s' ${shellQuote(CLAUDE_TOOL_SHELL_PREFIX_SCRIPT)} > ${shellQuote(`${prefixPath}.partial`)} && ` + + `chmod 700 ${shellQuote(`${prefixPath}.partial`)} && mv -f ${shellQuote(`${prefixPath}.partial`)} ${shellQuote(prefixPath)} && ` + + `echo ${shellQuote(CLAUDE_TOOL_SHELL_PREFIX_INSTALLED_MARKER)}; }` + : ""; return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` + `if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` + `cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` + @@ -230,7 +257,16 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: { `if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` + `cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` + `fi; ` + - `done`; + `done` + + installToolShellPrefix; +} + +export function resolveRemoteClaudeToolShellPrefixPath( + env: Record, + remoteClaudeRuntimeRoot: string, +): string | null { + if (isNonEmptyString(env.CLAUDE_CODE_SHELL_PREFIX)) return null; + return path.posix.join(remoteClaudeRuntimeRoot, "tool-shell-prefix.sh"); } export async function materializeRemoteClaudeConfig(input: { @@ -238,17 +274,25 @@ export async function materializeRemoteClaudeConfig(input: { target: AdapterExecutionTarget | null | undefined; remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteToolShellPrefixPath?: string | null; options: AdapterExecutionTargetShellOptions; -}): Promise { - await runAdapterExecutionTargetShellCommand( +}): Promise<{ toolShellPrefixInstalled: boolean }> { + const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, buildRemoteClaudeConfigMaterializationCommand({ remoteClaudeConfigDir: input.remoteClaudeConfigDir, remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir, + remoteToolShellPrefixPath: input.remoteToolShellPrefixPath, }), input.options, ); + return { + toolShellPrefixInstalled: Boolean(input.remoteToolShellPrefixPath) && + result.exitCode === 0 && + !result.timedOut && + result.stdout.includes(CLAUDE_TOOL_SHELL_PREFIX_INSTALLED_MARKER), + }; } function isNonEmptyString(value: unknown): value is string { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c78a8cc35c2..faecffe2b00 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -84,6 +84,7 @@ import { materializeRemoteClaudeConfig, prepareClaudeConfigSeed, resolveManagedClaudeRuntimeStateDir, + resolveRemoteClaudeToolShellPrefixPath, resolveSharedClaudeConfigDir, writePaperclipClaudeMcpConfig, } from "./claude-config.js"; @@ -710,18 +711,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise> = null; if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {