From ff7e7b4963b53a74c64de79b6e4703a114bf8997 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 4 Nov 2026 12:09:54 -0800 Subject: [PATCH] fix(claude-local): make sandbox tool commands the first OOM victims (#16844) 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 > - Hosted `claude_local` runs execute in a sandbox. The sandbox has a fixed memory limit (4 GiB in the observed sandboxes). > - In that sandbox, every process has the same `oom_score_adj`. When a Bash tool command uses most of the memory, the kernel kills the largest process. That can be the Claude Code process. > - When Claude Code is killed, `claude-agent-acp` reports "The connection to Claude was lost." and the whole run fails. Server logs show `Claude Agent process died: Claude Code process terminated by signal SIGKILL` for each failure. > - A failed tool command is recoverable. A dead agent is not. > - This pull request makes Claude's tool commands the first out-of-memory victims in managed sandboxes. > - The benefit is that a memory-heavy command fails with exit 137, and the agent continues the run. ## Linked Issues or Issue Description Refs #16366 (diagnostic PR for the same failure). Related: #14509 protects the host control plane from local agent processes. That PR does not cover a sandboxed agent and its own tool commands. **What happened?** Hosted `claude_local` runs fail with `acpx_turn_failed` and "The connection to Claude was lost." while one `execute` (Bash) tool call is pending. The pending commands are memory-heavy, for example `tsc --noEmit` with a 3.3 GB heap, a monorepo `pnpm install`, or a type check plus `vitest`. At the same second, the server log shows `Claude Agent process died: Claude Code process terminated by signal SIGKILL`. Over 10 days, all 12 such lines on one staging stack were `SIGKILL`, and they match the failed runs. The sandbox memory cgroup has `memory.max` 4 GiB and `memory.oom.group` 0, so the kernel kills one process. Every process has `oom_score_adj` 0, so the kernel picks the largest one. **Expected behavior** Under memory pressure, the kernel kills the tool command. Claude gets a failed command (exit 137) and continues the run. **Steps to reproduce** 1. Start a `claude_local` run on a sandbox target with a 4 GiB memory limit. 2. Ask the agent to run a memory-heavy command in a large repository, for example a full `tsc --noEmit` and a test run together. 3. The kernel kills the Claude Code process, and the run fails with "The connection to Claude was lost." **Agent adapter(s) involved** Claude Code (`claude_local`, ACP lane and CLI lane, managed sandbox targets). ## What Changed - `claude-config.ts`: add `CLAUDE_TOOL_SHELL_PREFIX_SCRIPT`. Claude Code calls a `CLAUDE_CODE_SHELL_PREFIX` with one argument, the full command string. The script raises its own `oom_score_adj` to 1000 (no privilege is necessary to raise it). Then it runs the command string in the shell that Claude Code selects: `CLAUDE_CODE_SHELL`, then `SHELL`, when either is an executable bash or zsh, else zsh or bash from `PATH` (bash first when `SHELL` names bash). - `buildRemoteClaudeConfigMaterializationCommand` can install the script (umask 077, write to `.partial`, `chmod 700`, `mv`). The install runs last in the existing materialization command, so it adds no sandbox round trip. It prints a marker only when the file is in place. - `materializeRemoteClaudeConfig` returns `toolShellPrefixInstalled`. It is true only when the command exits 0 and prints the marker. - ACP lane (`acp.ts`) and CLI lane (`execute.ts`): set `CLAUDE_CODE_SHELL_PREFIX` to the installed script only when the install is confirmed. A prefix path that does not exist would fail every Bash tool call, so a failed install keeps the previous behavior. An operator `CLAUDE_CODE_SHELL_PREFIX` in the adapter env is kept, and no script is installed. - `docs/adapters/claude-local.md`: add "Sandbox memory limits". ## Verification - `npx vitest run src/server/claude-config.test.ts` in `packages/adapters/claude-local`: 10 pass, 6 new. The new tests install the script with the real materialization command through `/bin/sh` and check: - the file content, mode 0700, the marker, and that no `.partial` file remains; - the command runs with `oom_score_adj` 1000 while the caller keeps its own score (Linux only); - shell selection: a valid bash `SHELL`, an invalid `CLAUDE_CODE_SHELL` that falls back to `SHELL`, and a `SHELL` of `/bin/sh` that falls back to bash from `PATH`; - quoting and the exit status of the command are kept; - an operator prefix wins, and no marker means no install. - `acp.test.ts`: the managed-config test now checks that `CLAUDE_CODE_SHELL_PREFIX` names an executable 0700 file next to the config dir. A new test checks that an operator prefix is kept and no script is installed. Both use the local sandbox runner, which runs the setup commands for real. - `tsc --noEmit -p packages/adapters/claude-local` is clean. - Two tests fail in my sandbox both with and without this change: `uses the same default model in ACP startup and session identity` and `reselects the full assignment and bootstrap guidance after a failed resume`. They also fail on clean `master` in the same environment, so I think they depend on that environment. CI covers them. - Real Claude Code check (Claude Code 2.1.286): with `CLAUDE_CODE_SHELL_PREFIX` set to the script, a Bash tool call printed `oom_score_adj` 1000 and ran in zsh (the selected shell), and its parent was the `claude` process at score 0. Without a prefix file that exists, every Bash tool call fails with exit 127. This is why the env is set only after a confirmed install. - Real OOM check in a 4 GiB Daytona sandbox (`memory.oom.group` 0). A stand-in "agent" process held 1.2 GB at score 0, and a tool command started 14 processes of 250 MB each: - without the prefix, the kernel killed the stand-in agent (SIGKILL, 137; `oom_kill` +1); - through the installed prefix, the stand-in agent stayed alive, and the kernel killed 5 tool processes (`oom_kill` +5). - Assumption that could invalidate the design: Claude Code passes one argument (the command string) to the prefix and honors the variable through `claude-agent-acp`. I checked the argument shape with a logging prefix, and `claude-agent-acp` 0.73.0 spawns the CLI with `{ ...process.env }`. ## Risks - Every Bash tool command and hook in a managed sandbox now runs through one more `sh` process and an `exec`. The cost is a few milliseconds for each command. - If a future Claude Code release changes how it calls `CLAUDE_CODE_SHELL_PREFIX`, Bash tool calls can fail. The tests above and the documented variable limit this risk. Removing the variable restores the old behavior. - The inner shell is not a login shell. Claude Code sources its own shell snapshot inside the command string, so the environment stays the same. If Claude Code runs without a snapshot, it uses a login shell. Only exported variables then pass through to the inner shell. - If the selected shell differs from the one that Claude Code chose (for example, a custom `CLAUDE_CODE_SHELL` that is not bash or zsh), the script uses the same fallback order as Claude Code. - This does not help when the Claude Code process itself uses most of the memory. The local (non-sandbox) lanes do not change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use and code execution. It read the repository and the Claude Code CLI behavior, ran the OOM experiment, edited the code, and ran the tests. ## 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 - [ ] I have run tests locally and they pass (two tests fail on clean `master` in my sandbox too; see Verification) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Paperclip --- docs/adapters/claude-local.md | 17 +++ .../claude-local/src/server/acp.test.ts | 56 ++++++++ .../adapters/claude-local/src/server/acp.ts | 8 +- .../src/server/claude-config.test.ts | 131 +++++++++++++++++- .../claude-local/src/server/claude-config.ts | 50 ++++++- .../claude-local/src/server/execute.ts | 11 +- 6 files changed, 266 insertions(+), 7 deletions(-) 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)) {