mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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:
6 files changed
+266
-7
No files matched your search
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user