fix(claude-local): make sandbox tool commands the first OOM victims (#16844)

## 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 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-11-04 12:09:54 -08:00
1 parent e107a75755
commit ff7e7b4963
6 files changed
+266 -7

No files matched your search

+17
View File
@@ -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
@@ -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 () => {
@@ -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) };
}
@@ -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<string, string>) {
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 });
});
});
@@ -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<string, string>,
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<void> {
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 {
@@ -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<AdapterExec
const remoteClaudeConfigDir = useManagedRemoteClaudeConfig && remoteClaudeRuntimeRoot
? path.posix.join(remoteClaudeRuntimeRoot, "config")
: null;
if (remoteClaudeConfigDir && remoteClaudeConfigSeedDir) {
if (remoteClaudeConfigDir && remoteClaudeConfigSeedDir && remoteClaudeRuntimeRoot) {
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
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,
env,
@@ -730,6 +733,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
onLog,
},
});
if (toolShellPrefixInstalled && remoteToolShellPrefixPath) {
env.CLAUDE_CODE_SHELL_PREFIX = remoteToolShellPrefixPath;
loggedEnv.CLAUDE_CODE_SHELL_PREFIX = remoteToolShellPrefixPath;
}
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {