diff --git a/vendor/paperclip-adapter-claude-k8s/PROVENANCE-CHANGES.md b/vendor/paperclip-adapter-claude-k8s/PROVENANCE-CHANGES.md index fbc801180adc..dd56ce8d091a 100644 --- a/vendor/paperclip-adapter-claude-k8s/PROVENANCE-CHANGES.md +++ b/vendor/paperclip-adapter-claude-k8s/PROVENANCE-CHANGES.md @@ -60,3 +60,4 @@ asserted by `scripts/__tests__/provenance-union-merge.test.mjs`. | [BLO-33894](https://paperclip.blockcast.net/BLO/issues/BLO-33894) | `src/server/job-manifest.test.ts` | Gave the bare-line trust in `claudeLineIsHarnessAuthored` a mechanical tripwire. The row above closes that guard against *unknown event types*; it stays open against **bare** lines, which it trusts outright (`if (!match) return true`). [BLO-31955](https://paperclip.blockcast.net/BLO/issues/BLO-31955) established that this is safe **structurally** rather than empirically — the pod log the guard parses has exactly one writer, the `tee` in the `claudeInvocation` pipeline, and that pipeline carries no `2>&1`, so only Claude's stdout reaches the parse surface — and recorded it in a source comment. A comment is the same class of protection that failed on each of the four prior iterations of this defect family ([BLO-7991](https://paperclip.blockcast.net/BLO/issues/BLO-7991) -> [#1525](https://github.com/Blockcast/paperclip/pull/1525) -> BLO-31794 x2 review rounds): it depends on a reviewer reading a *different* file from the one being edited. Adding `2>&1` before the `tee` — a reasonable-looking edit, e.g. to capture CLI diagnostics in the pod log — would begin routing operator- and MCP-authored stderr onto the parse surface as bare, trusted lines, **with no diff on the guard itself**. One assertion in the existing suite now pins it. Deliberately scoped to the substring between the launcher command and the `tee` rather than the whole command: `>/dev/null 2>&1` appears legitimately in the ccrotate preflight and the git plumbing that precede it in the same string, so a whole-command assertion would be red today, and one written loosely enough to be green would no longer discriminate the real case. That scoping is itself pinned by a negative control asserting `2>&1` IS present upstream of the launcher, so the test cannot pass vacuously if the pipeline is restructured. Verified as a tripwire rather than assumed: inserting `2>&1` before the `tee` reddens it with `expected 'cat /tmp/prompt/prompt.txt | claude \…' not to contain '2>&1'`. Tests only — no runtime behaviour change. Ally review follow-up on [#1662](https://github.com/Blockcast/paperclip/pull/1662) (the single remaining Suggestion, rated non-blocking); filed rather than folded in because that PR is reviewed clean at head and editing a vendored file forces a hash recompute, a version bump and a full re-review. | | [#1730](https://github.com/Blockcast/paperclip/pull/1730) | `package.json`, `package-lock.json` | Added an npm `overrides` floor of `js-yaml` `>=4.3.2 <5`, moving this lockfile's resolution from `4.1.1` to `4.3.2`. GHSA-2883-xcg3-v3hh (CVE-2026-84375) covers `>=4.0.0 <4.3.2`: an empty merge source bypasses the `maxTotalMergeKeys` accounting, so a small document with many empty merges still burns unbounded CPU. This directory is excluded from `pnpm-workspace.yaml` and carries its own npm lockfile, so the root `pnpm.overrides` fix in the same PR could not reach it — the Dockerfile `vendor` stage installs exactly these pins with `npm ci` before building and packing the adapter. Bounded to the 4.x line deliberately: a bare `>=4.3.2` resolves to `5.4.1`, which npm `overrides` would force past `@kubernetes/client-node`'s declared `^4.1.0`. `npm ci`, `tsc --noEmit` and 891/891 adapter tests pass on `4.3.2`; the floor is locked by a second case in `scripts/js-yaml-security-override.test.js`. From an Ally review finding on this PR. | | [BLO-33279](https://paperclip.blockcast.net/BLO/issues/BLO-33279) | `src/server/inherit-allowlist.ts`, `src/server/inherit-allowlist.test.ts` | Allowlisted `PENSTOCK_READY_TIMEOUT_MS` for inheritance into agent Jobs. The Caveman readiness budget is read by the launcher **inside the agent pod**, so the fleet-wide default is set as a literal on `worker.extraEnv` (`values.blockcast.yaml`, PR #1766, deployed 2026-09-12). `isAgentInheritableEnvName` is default-deny and the name was not listed, so `k8s-client.ts` dropped it and every agent Job kept the launcher's 15000 ms default — a rendered-green manifest that changed nothing, confirmed by reading a live `ac-*` pod spec on 2026-09-14 (5 other `PENSTOCK_*` present, this one absent) while the defect was still firing. The value is a non-secret integer, bounded at 300000 ms by the launcher and scrubbed from both child processes, so admitting it does not widen the credential boundary the allowlist exists to hold. The general guard lives outside this package, in `deploy/helm/paperclip/tests/penstock-worker-secret.test.mjs`: any literal in `worker.extraEnv` that this allowlist does not admit now fails the build, naming the variable. The runbook that produced the bug claimed "there is no name allowlist or denylist" — false since BLO-22514 — and is corrected in the same change. | +| [#1937](https://github.com/Blockcast/paperclip/pull/1937) | `src/server/job-manifest.ts`, `src/server/job-manifest.test.ts`, `src/server/config-schema.ts` | Capped tool-spawned children with `RLIMIT_DATA` ([BLO-34477](https://paperclip.blockcast.net/BLO/issues/BLO-34477)) so one runaway Bash child fails alone with ENOMEM instead of walking the cgroup to its limit and OOM-killing the whole run. The write-prompt init container writes a `ulimit -d` line onto the per-pod runtime-cache emptyDir and the claude container points `BASH_ENV` and `ZDOTDIR` at it. **Ally's re-review dismissed her own earlier approval of this head and caught that the bash half was inert**, which is the part worth recording. bash's `run_startup_files()` treats a shell as "run by rshd/sshd" when `isnetconn(fileno(stdin)) && SHLVL < 2`; on that branch it sources `~/.bashrc` and **returns before `$BASH_ENV` is consulted**. libuv allocates child stdio with `socketpair()`, so fd 0 of anything Claude Code spawns is a socket and that test always holds — the cap was advertised and never applied to a bash tool shell. Fixed by shipping `SHLVL=2` in the claude container env (declared `SAFE_LITERAL` under the BLO-29804 gate): the smallest of the three candidate deliveries and the only one with no `$HOME` dependency, `$HOME` being exactly what the emptyDir design exists to avoid. The live exposure was config-dependent rather than universal — this image's tool shell is zsh, where the `ZDOTDIR` arm already applies the cap and grandchildren inherit it — so what was dead is a bash spawned directly by Claude Code, i.e. any image or config whose tool shell is bash. `config-schema.ts`'s operator hint nonetheless claimed the cap reached "every shell the agent spawns", which was false for bash and is now narrowed to say what plain `sh` does not get. **The second Critical was the test that should have caught the first.** `bashHonorsBashEnv()` probed through `ulimitD`, which spawns with default stdio (socketpair) and rebuilds the child environment as `{ PATH, ...overrides }` so `SHLVL` is never inherited — both branch conditions are therefore satisfied on every POSIX host, the probe returned false universally, and the bash assertions at the call site were unreachable. A detector for this exact defect, wired to always report clean, and the reason two prior passes came back green; its in-source comment blamed three causes (`euid != uid`, POSIX mode, a non-GNU `bash`) that were all wrong. Replaced with unconditional assertions on the production spawn shape, plus a negative case pinning that the cap is *not* applied without `SHLVL`, so the manifest arm cannot later be deleted as a redundant-looking assignment. Both halves mutation-tested rather than assumed: removing `SHLVL` from the test env arm fails the behavioural case, removing `merged.SHLVL` fails the manifest case, and the baseline restores green. Reproduced independently of the agent image (bash 5.2.21 here, 5.2.37 in the image): socket stdin with `SHLVL` unset reports `ulimit -d` unlimited, the same spawn with `SHLVL=2` reports the cap. **Second review round on the fix itself, same PR:** `SHLVL=2` does not *add* a startup file, it **swaps** one — off the rshd branch bash stops sourcing `$HOME/.bashrc`, and `rlimit.sh` carried no chain back to it, so the cap would have cost a directly-spawned bash the pod's environment file. Measured on this image: `BASH_ENV` alone left `CCROTATE_SERVE_BASE_URL` PRESENT with the cap unapplied; `BASH_ENV` + `SHLVL=2` applied the cap and left it ABSENT. `/paperclip/.bashrc` is a real 2210-byte file setting `ANTHROPIC_BASE_URL`, `CCROTATE_SERVE_*`, `CODEX_HOME`, `JAVA_HOME`/`ANDROID_HOME` and three `PATH` prefixes, and 2 of 9 then-running claude pods had `HOME=/paperclip`, so losing it would have quietly pointed a bash-spawned `claude`/`codex` away from the rotation proxy. Fixed with the shape this change already used for zsh: `BASH_ENV` now targets a bash-specific stub (`TOOL_RLIMIT_BASHENV`, `/bashenv.sh`) that sources `rlimit.sh` then chains `$HOME/.bashrc`. It has to be a separate file — the `.zshenv` stub and the POSIX-`sh` path source `rlimit.sh` directly, so chaining a bash rc inside it would pull `.bashrc` into zsh and sh; a test asserts that non-containment explicitly. Mutation-tested like the rest: removing `chain(".bashrc")` fails both the init-shell assertion and a new behavioural case checking cap AND chain together, and repointing `BASH_ENV` at `rlimit.sh` fails two manifest cases. Two suggestions landed with it — the negative SHLVL case now asserts equality against a measured uncapped baseline instead of `.not.toBe(CAP)`, so a bash that fails to start can no longer pass it vacuously, and the source records that bash increments `SHLVL` *before* evaluating `shell_level < 2` (so `1` would already clear the branch; `2` is margin), stopping the next reader reading it as an off-by-one. | diff --git a/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.test.ts b/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.test.ts index 56712913e7cf..abb37155ef46 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.test.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.test.ts @@ -54,6 +54,16 @@ describe("getConfigSchema", () => { ]); }); + it("exposes the tool-child memory cap next to the memory limit it derives from (BLO-34477)", () => { + const schema = getConfigSchema(); + const keys = schema.fields.map((f: ConfigFieldSchema) => f.key); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "resources.limits.toolMemoryKb"); + expect(field?.type).toBe("number"); + expect(field?.hint).toMatch(/half of Memory Limit/); + expect(field?.hint).toMatch(/0 disables/); + expect(keys.indexOf("resources.limits.toolMemoryKb")).toBe(keys.indexOf("resources.limits.memory") + 1); + }); + it("reattachOrphanedJobs defaults to true", () => { const schema = getConfigSchema(); const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "reattachOrphanedJobs"); diff --git a/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts b/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts index 948eb046aac6..4fe20ca953a0 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts @@ -138,6 +138,12 @@ export function getConfigSchema(): AdapterConfigSchema { label: "Memory Limit", hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).", }, + { + type: "number", + key: "resources.limits.toolMemoryKb", + label: "Tool Child Memory Cap (KiB)", + hint: "RLIMIT_DATA ceiling (ulimit -d, KiB) applied to every bash or zsh the agent spawns — Bash tool commands and their children — and inherited by their descendants, including plain `sh`. Not applied to the claude process itself, nor to a bare `sh -c` launched outside a tool shell (POSIX sh reads neither BASH_ENV nor ZDOTDIR). A runaway child then fails alone with ENOMEM instead of the cgroup OOM-killing the whole run (BLO-34477). Default: half of Memory Limit. 0 disables.", + }, // Scheduling { type: "textarea", diff --git a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.test.ts b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.test.ts index bf0cc51cf3eb..bb5a44bb71d0 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.test.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.test.ts @@ -18,6 +18,15 @@ import { validateAgentCommand, validatePonytailPluginPath, validatePonytailDefaultMode, + buildToolRlimitInitShell, + parseMemoryQuantityToKiB, + resolveToolMemoryLimitKb, + TOOL_MEMORY_LIMIT_CONFIG_KEY, + TOOL_RLIMIT_DIR, + TOOL_RLIMIT_FILE, + TOOL_RLIMIT_BASHENV, + TOOL_RLIMIT_ZDOTDIR, + ZSH_DOTFILES, } from "./job-manifest.js"; import type { SelfPodInfo } from "./k8s-client.js"; @@ -2872,3 +2881,319 @@ describe("env name classification gate (BLO-29804)", () => { expect(Object.keys(envSecret?.data ?? {}).sort()).toEqual(EXPECTED_SECRET_BACKED); }); }); + +// BLO-34477: a Bash-tool command that outlives its timeout is backgrounded, +// not killed, and under `memory.oom.group=1` one runaway child walks the whole +// `claude` cgroup to its limit and the kernel SIGKILLs the run (exit 137, +// OOMKilled; 12 pods >5 GiB in 24 h against a ~0.5 GiB norm). The adapter +// bounds each child with RLIMIT_DATA applied by every shell the agent spawns, +// while the `claude` process itself — exec'd from POSIX `sh -c` — stays uncapped. +describe("tool-child memory cap (BLO-34477)", () => { + let ctx: AdapterExecutionContext; + let selfPod: SelfPodInfo; + let tempDirs: string[]; + + beforeEach(() => { + ctx = makeCtx(); + selfPod = makeSelfPod(); + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + + const initCommand = (): string => { + const { job } = buildJobManifest({ ctx, selfPod }); + return job.spec?.template?.spec?.initContainers?.[0]?.command?.[2] ?? ""; + }; + const claudeEnv = (): Map => { + const { job } = buildJobManifest({ ctx, selfPod }); + const claude = job.spec?.template?.spec?.containers?.find((c) => c.name === "claude"); + return new Map((claude?.env ?? []).map((e) => [e.name, e.value])); + }; + + describe("parseMemoryQuantityToKiB", () => { + it("converts binary, decimal and bare-byte quantities to whole KiB", () => { + expect(parseMemoryQuantityToKiB("8Gi", "f")).toBe(8 * 1024 * 1024); + expect(parseMemoryQuantityToKiB("6144Mi", "f")).toBe(6144 * 1024); + expect(parseMemoryQuantityToKiB("1536Mi", "f")).toBe(1536 * 1024); + expect(parseMemoryQuantityToKiB("2G", "f")).toBe(Math.floor(2e9 / 1024)); + expect(parseMemoryQuantityToKiB("4096", "f")).toBe(4); + expect(parseMemoryQuantityToKiB(" 1Ki ", "f")).toBe(1); + }); + + it("rejects fractional, milli, negative, empty and malformed quantities", () => { + for (const bad of ["1.5Gi", "8gi", "500m", "-1Gi", "", "8Gi; rm -rf /", "abc"]) { + expect(() => parseMemoryQuantityToKiB(bad, "resources.limits.memory")).toThrow(/integer Kubernetes memory quantity/); + } + expect(() => parseMemoryQuantityToKiB("512", "f")).toThrow(/at least 1Ki/); + }); + }); + + describe("resolveToolMemoryLimitKb", () => { + it("defaults to half the container memory limit", () => { + expect(resolveToolMemoryLimitKb({}, "8Gi")).toBe(4 * 1024 * 1024); + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: "" }, "6144Mi")).toBe(3072 * 1024); + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: null }, "4Gi")).toBe(2 * 1024 * 1024); + }); + + it("lets an explicit non-negative integer (number or digit string) win, and 0 disables", () => { + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: 1048576 }, "8Gi")).toBe(1048576); + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: " 2097152 " }, "8Gi")).toBe(2097152); + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: 0 }, "8Gi")).toBe(0); + expect(resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: "0" }, "8Gi")).toBe(0); + }); + + it("refuses anything that is not a plain integer — the value lands in a shell command", () => { + for (const bad of ["4Gi", "-1", -1, 1.5, "1048576; rm -rf /", "$(id)", true, {}]) { + expect(() => resolveToolMemoryLimitKb({ [TOOL_MEMORY_LIMIT_CONFIG_KEY]: bad }, "8Gi")).toThrow( + /toolMemoryKb must be a non-negative integer number of KiB/, + ); + } + }); + + it("degrades an unparseable container memory limit to no cap with a warning instead of aborting the Job", () => { + const warnings: string[] = []; + expect(resolveToolMemoryLimitKb({}, "1.5Gi", (message) => warnings.push(message))).toBe(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/resources\.limits\.memory="1\.5Gi" cannot be halved/); + expect(warnings[0]).toMatch(/no RLIMIT_DATA cap/); + expect(warnings[0]).toMatch(/toolMemoryKb/); + }); + + it("still refuses a malformed explicit toolMemoryKb (it is interpolated into a shell command)", () => { + const warnings: string[] = []; + expect(() => resolveToolMemoryLimitKb({ "resources.limits.toolMemoryKb": "1.5Gi" }, "8Gi", (message) => warnings.push(message))).toThrow( + /toolMemoryKb must be a non-negative integer number of KiB/, + ); + expect(warnings).toEqual([]); + }); + }); + + describe("manifest", () => { + it("writes the cap onto the runtime-cache emptyDir at half the default 8Gi limit", () => { + const cmd = initCommand(); + expect(TOOL_RLIMIT_DIR).toBe("/runtime-cache/tool-rlimit"); + expect(cmd).toContain(`mkdir -p '${TOOL_RLIMIT_ZDOTDIR}'`); + expect(cmd).toContain(`'ulimit -d 4194304 2>/dev/null || true' > '${TOOL_RLIMIT_FILE}'`); + // zsh entry point: apply the cap, then defer to the user's own file. + // The inner single quotes are escaped the POSIX way ('\'') by the same + // quoter the rest of the init command uses. + expect(cmd).toContain( + `printf '%s\\n' '. '\\''${TOOL_RLIMIT_FILE}'\\''' 'if [ -r "$HOME/.zshenv" ]; then . "$HOME/.zshenv"; fi' > '${TOOL_RLIMIT_ZDOTDIR}/.zshenv'`, + ); + // bash entry point: same shape as the zsh one. It must chain .bashrc + // because SHLVL=2 stops bash reading that file on its own, so without + // this the cap would REPLACE the pod's environment file rather than add + // to it (shared-HOME agents lose ANTHROPIC_BASE_URL/CCROTATE_SERVE_*). + expect(cmd).toContain( + `printf '%s\\n' '. '\\''${TOOL_RLIMIT_FILE}'\\''' 'if [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi' > '${TOOL_RLIMIT_BASHENV}'`, + ); + // ...and the bash rc must NOT be chained from rlimit.sh itself, which zsh + // and POSIX sh source directly. + expect(cmd).not.toContain(`.bashrc"; fi' > '${TOOL_RLIMIT_FILE}'`); + // Every other zsh dotfile is a pure chaining stub, so ZDOTDIR loses nothing. + for (const name of ZSH_DOTFILES.filter((n) => n !== ".zshenv")) { + expect(cmd).toContain(`printf '%s\\n' 'if [ -r "$HOME/${name}" ]; then . "$HOME/${name}"; fi' > '${TOOL_RLIMIT_ZDOTDIR}/${name}'`); + } + }); + + it("derives the cap from a configured memory limit", () => { + ctx.config["resources.limits.memory"] = "6144Mi"; + expect(initCommand()).toContain("ulimit -d 3145728 2>/dev/null || true"); + }); + + it("honours an explicit resources.limits.toolMemoryKb over the derivation", () => { + ctx.config["resources.limits.memory"] = "8Gi"; + ctx.config[TOOL_MEMORY_LIMIT_CONFIG_KEY] = 1048576; + expect(initCommand()).toContain("ulimit -d 1048576 2>/dev/null || true"); + }); + + it("0 disables the cap but still writes a readable BASH_ENV file", () => { + ctx.config[TOOL_MEMORY_LIMIT_CONFIG_KEY] = 0; + const cmd = initCommand(); + expect(cmd).not.toContain("ulimit -d"); + expect(cmd).toContain(`'# cap disabled (${TOOL_MEMORY_LIMIT_CONFIG_KEY}=0)' > '${TOOL_RLIMIT_FILE}'`); + }); + + it("fails the manifest build on a non-integer cap rather than interpolating it", () => { + ctx.config[TOOL_MEMORY_LIMIT_CONFIG_KEY] = "4Gi"; + expect(() => buildJobManifest({ ctx, selfPod })).toThrow(/toolMemoryKb must be a non-negative integer/); + }); + + it("points the claude container's BASH_ENV and ZDOTDIR at the emptyDir files, classified SAFE_LITERAL", () => { + const env = claudeEnv(); + expect(env.get("BASH_ENV")).toBe(TOOL_RLIMIT_BASHENV); + expect(env.get("ZDOTDIR")).toBe(TOOL_RLIMIT_ZDOTDIR); + expect(classifyEnvName("BASH_ENV")).toBe("SAFE_LITERAL"); + expect(classifyEnvName("ZDOTDIR")).toBe("SAFE_LITERAL"); + }); + + // Without this, BASH_ENV is set but never read for a bash tool shell: + // libuv gives the child socket stdio, and bash skips $BASH_ENV entirely on + // its rshd/sshd branch (isnetconn(fd 0) && SHLVL < 2). Pinned so the arm + // cannot be dropped as a redundant-looking assignment; the behavioural half + // is asserted by the "without SHLVL ..." case below. + it("ships SHLVL=2 so bash does not skip BASH_ENV on its rshd branch", () => { + expect(claudeEnv().get("SHLVL")).toBe("2"); + expect(classifyEnvName("SHLVL")).toBe("SAFE_LITERAL"); + }); + + it("keeps BASH_ENV/ZDOTDIR on the emptyDir even when HOME is a per-run isolated root", () => { + setRuntimeIsolation(ctx, { + isolationMode: "run", + isolationKey: "run:run-abc12345", + workspaceRoot: "/runtime-cache/paperclip-runs/run-abc12345/workspace", + homeRoot: "/runtime-cache/paperclip-runs/run-abc12345/home", + sessionRoot: "/runtime-cache/paperclip-runs/run-abc12345/session", + cacheRoot: "/runtime-cache/paperclip-runs/run-abc12345/cache", + tmpRoot: "/runtime-cache/paperclip-runs/run-abc12345/tmp", + storage: isolatedStorage("ephemeral"), + }); + const env = claudeEnv(); + expect(env.get("HOME")).toBe("/runtime-cache/paperclip-runs/run-abc12345/home"); + expect(env.get("BASH_ENV")).toBe(TOOL_RLIMIT_BASHENV); + expect(env.get("ZDOTDIR")).toBe(TOOL_RLIMIT_ZDOTDIR); + }); + + it("both containers mount the runtime-cache volume the cap lives on, and the init command parses", () => { + const { job } = buildJobManifest({ ctx, selfPod }); + const spec = job.spec?.template?.spec; + const init = spec?.initContainers?.[0]; + const claude = spec?.containers?.find((c) => c.name === "claude"); + const mountedAt = (c: k8s.V1Container | undefined) => (c?.volumeMounts ?? []).find((m) => m.mountPath === "/runtime-cache")?.name; + expect(mountedAt(init)).toBe("runtime-cache"); + expect(mountedAt(claude)).toBe("runtime-cache"); + const syntaxCheck = spawnSync("/bin/sh", ["-n", "-c", init?.command?.[2] ?? ""], { encoding: "utf8" }); + expect(syntaxCheck.stderr).toBe(""); + expect(syntaxCheck.status).toBe(0); + }); + }); + + describe("generated shell, executed", () => { + // Runs the exact init lines in a real POSIX sh against a temp dir standing + // in for /runtime-cache, then asks each shell the agent might spawn what + // its RLIMIT_DATA is. `ulimit -d` reports KiB. 1 GiB is below every hard + // limit a CI runner has, so lowering to it always succeeds. + const CAP_KB = 1048576; + const which = (bin: string): boolean => spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).status === 0; + // Darwin's setrlimit(RLIMIT_DATA) returns EINVAL for any lowering, so the + // "cap applied" assertions can only be made where the kernel honours the + // knob (Linux — the adapter's only deployment target, and CI). The probe + // asks the host, not the platform string, so a Linux box with an odd hard + // limit is skipped honestly rather than failing on an unrelated cause. + const hostCanLowerRlimitData = + spawnSync("/bin/sh", ["-c", `ulimit -d ${CAP_KB} 2>/dev/null && ulimit -d`], { encoding: "utf8" }).stdout.trim() === String(CAP_KB); + const itOnCapableHost = hostCanLowerRlimitData ? it : it.skip; + const install = (limitKb: number): { dir: string; home: string } => { + const base = mkdtempSync(join(tmpdir(), "blo34477-")); + tempDirs.push(base); + const dir = join(base, "runtime-cache", "tool-rlimit"); + const home = join(base, "home"); + mkdirSync(home, { recursive: true }); + mkdirSync(join(base, "runtime-cache"), { recursive: true }); + const run = spawnSync("/bin/sh", ["-c", buildToolRlimitInitShell(dir, limitKb).join("; ")], { encoding: "utf8" }); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + return { dir, home }; + }; + const ulimitD = (argv: string[], env: Record): string => + spawnSync(argv[0], argv.slice(1), { encoding: "utf8", env: { PATH: process.env.PATH ?? "", ...env } }).stdout.trim(); + + // `ulimitD` already reproduces the PRODUCTION spawn shape exactly: default + // stdio, so libuv allocates fd 0 with socketpair(), and a child environment + // rebuilt as { PATH, ...overrides }, so SHLVL is never inherited. That is + // the shape Claude Code spawns a tool shell in, which is why the assertions + // below can run unconditionally on every host instead of behind a probe. + // + // They previously sat behind `bashHonorsBashEnv()`, which was subject to the + // very bug it guarded: it probed through `ulimitD`, so it always hit bash's + // rshd/sshd branch (isnetconn(fd 0) && SHLVL < 2 -> source ~/.bashrc and + // return before $BASH_ENV), returned false on every POSIX host, and silently + // skipped these assertions forever — which is how a dead BASH_ENV arm passed + // review twice. Do not reintroduce a host probe here; if bash is missing the + // test skips honestly on `which`, and a real delivery regression must fail. + const bashEnvArm = (dir: string, home: string): Record => ({ + HOME: home, + BASH_ENV: `${dir}/bashenv.sh`, + // Exactly what job-manifest.ts puts in the claude container env. + SHLVL: "2", + }); + // Reports the two inputs to bash's rshd branch alongside the limit, so a + // failure says why: a bare `ulimit -d` here reads `unlimited` on that branch + // even when the configuration is correct. + const bashDiag = (env: Record): string => + ulimitD( + ["bash", "-c", 'ulimit -d; echo "rc=$? hard=$(ulimit -H -d) version=$BASH_VERSION shlvl=${SHLVL:-unset} fd0=$(readlink /proc/self/fd/0) uid=$(id -ru)/$(id -u)"'], + env, + ).replace(/\n/g, " "); + const itWithBash = which("bash") ? itOnCapableHost : it.skip; + + itWithBash("bash under BASH_ENV, and POSIX sh sourcing the file, report the cap", () => { + const { dir, home } = install(CAP_KB); + expect(ulimitD(["/bin/sh", "-c", `. '${dir}/rlimit.sh'; ulimit -d`], { HOME: home })).toBe(String(CAP_KB)); + const env = bashEnvArm(dir, home); + expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).toBe(String(CAP_KB)); + // A grandchild inherits it — the property that bounds the whole subtree. + expect(ulimitD(["bash", "-c", "sh -c 'ulimit -d'"], env), bashDiag(env)).toBe(String(CAP_KB)); + }); + + // SHLVL=2 does not ADD a startup file, it SWAPS one: off the rshd branch + // bash stops sourcing ~/.bashrc. On a shared-HOME agent that file supplies + // ANTHROPIC_BASE_URL/CCROTATE_SERVE_*, JAVA_HOME and PATH entries, so the + // BASH_ENV stub has to chain it back or the cap silently costs the agent + // its environment. Both properties asserted together: cap AND chain. + itWithBash("the BASH_ENV stub applies the cap AND still sources the user's own $HOME/.bashrc", () => { + const { dir, home } = install(CAP_KB); + writeFileSync(join(home, ".bashrc"), "export BLO34477_BASHRC=reached\n"); + const env = bashEnvArm(dir, home); + expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).toBe(String(CAP_KB)); + expect(ulimitD(["bash", "-c", 'printf %s "$BLO34477_BASHRC"'], env)).toBe("reached"); + }); + + // The reason SHLVL=2 is in the manifest, pinned so nobody deletes it as a + // redundant assignment: drop it and the identical spawn takes bash's rshd + // branch and never reaches $BASH_ENV, so the cap silently does not apply. + // If this ever starts failing, bash changed its startup rules — revisit + // job-manifest.ts's SHLVL arm rather than deleting this test. + // + // Asserted against the measured uncapped baseline rather than + // `.not.toBe(CAP)`, so a bash that fails to start (empty stdout) fails the + // test instead of passing it vacuously. + itWithBash("without SHLVL the BASH_ENV arm is dead on socket stdin — why the manifest sets SHLVL=2", () => { + const { dir, home } = install(CAP_KB); + const baseline = ulimitD(["bash", "-c", "ulimit -d"], { HOME: home }); + expect(baseline, bashDiag({ HOME: home })).not.toBe(String(CAP_KB)); + expect(baseline).not.toBe(""); + const env = { HOME: home, BASH_ENV: `${dir}/bashenv.sh` }; + expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).toBe(baseline); + }); + + it("POSIX sh -c — the shape that launches claude — ignores BASH_ENV and stays uncapped", () => { + const { dir, home } = install(CAP_KB); + const baseline = ulimitD(["/bin/sh", "-c", "ulimit -d"], { HOME: home }); + expect(baseline).not.toBe(String(CAP_KB)); + expect(ulimitD(["/bin/sh", "-c", "ulimit -d"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(baseline); + }); + + (which("zsh") ? itOnCapableHost : it.skip)("zsh under ZDOTDIR applies the cap and still sources the user's own $HOME dotfiles", () => { + const { dir, home } = install(CAP_KB); + writeFileSync(join(home, ".zshenv"), "export BLO34477_CHAIN=reached\n"); + const env = { HOME: home, ZDOTDIR: `${dir}/zdotdir` }; + expect(ulimitD(["zsh", "-c", "ulimit -d"], env)).toBe(String(CAP_KB)); + expect(ulimitD(["zsh", "-c", "printf %s \"$BLO34477_CHAIN\""], env)).toBe("reached"); + // A grandchild inherits it. + expect(ulimitD(["zsh", "-c", "sh -c 'ulimit -d'"], env)).toBe(String(CAP_KB)); + }); + + it("a disabled cap leaves every shell at its baseline", () => { + const { dir, home } = install(0); + const baseline = ulimitD(["/bin/sh", "-c", "ulimit -d"], { HOME: home }); + expect(ulimitD(["/bin/sh", "-c", `. '${dir}/rlimit.sh'; ulimit -d`], { HOME: home })).toBe(baseline); + if (which("zsh")) { + expect(ulimitD(["zsh", "-c", "ulimit -d"], { HOME: home, ZDOTDIR: `${dir}/zdotdir` })).toBe(baseline); + } + }); + }); +}); diff --git a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts index ecf30cbc5fe1..08bf2286934e 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts @@ -675,6 +675,21 @@ export const ENV_NAME_CLASSIFICATION: readonly EnvNameClassification[] = [ classification: "SAFE_LITERAL", reason: "Must be readable off the pod spec — it is the first thing checked when session resume misbehaves.", }, + { + name: "BASH_ENV", + classification: "SAFE_LITERAL", + reason: "Runtime-cache path of the RLIMIT_DATA cap file bash sources for tool-spawned children (BLO-34477); a filename, no credential material.", + }, + { + name: "ZDOTDIR", + classification: "SAFE_LITERAL", + reason: "Runtime-cache directory of the zsh dotfile stubs that apply the tool-child RLIMIT_DATA cap and chain to $HOME (BLO-34477); a path, no credential material.", + }, + { + name: "SHLVL", + classification: "SAFE_LITERAL", + reason: "Set to 2 so bash does not take its rshd/sshd startup branch and therefore honours $BASH_ENV for tool children (BLO-34477); a shell nesting counter, no credential material.", + }, { name: "CLAUDE_CONFIG_DIR", classification: "SAFE_LITERAL", @@ -1067,6 +1082,39 @@ function buildEnvVars( // HOME must live on the mounted data PVC to enable session resume. Isolated // mode scopes Claude config/cache/session state away from shared /paperclip. merged.HOME = isolation.enabled ? isolation.homeRoot : "/paperclip"; + // BLO-34477: zsh sources $ZDOTDIR/.zshenv on every start, and bash sources + // $BASH_ENV on a non-interactive start ONLY when it does not take its + // rshd/sshd branch, so every Bash-tool command picks up the RLIMIT_DATA cap + // the write-prompt init container wrote onto the runtime-cache emptyDir. + // POSIX `sh` (dash on this image) reads neither, so the `sh -c` that launches + // `claude` — and therefore `claude` itself — is not capped. + // + // SHLVL=2 is load-bearing, not cosmetic. bash's run_startup_files() treats a + // shell as "run by rshd/sshd" when isnetconn(fileno(stdin)) && SHLVL < 2; on + // that branch it sources ~/.bashrc and RETURNS BEFORE $BASH_ENV is consulted. + // libuv allocates child stdio with socketpair(), so fd 0 of anything Claude + // Code spawns is a socket and the test always passes — meaning that without + // this variable the BASH_ENV arm is dead for a bash tool shell and the cap is + // advertised but never applied. Measured in the agent image and on a second + // host (bash 5.2.21/5.2.37): socket stdin + SHLVL unset -> `ulimit -d` + // unlimited; the same spawn with SHLVL=2 -> capped. Do not delete this as a + // redundant assignment. Today's image uses zsh as the tool shell, so the + // ZDOTDIR arm already covers it and this closes the bash-tool-shell case. + // + // Not an off-by-one: bash increments SHLVL during initialisation, before + // run_startup_files() evaluates `shell_level < 2`, so a shipped 1 already + // clears the branch (measured: child reports shlvl=2). 2 is for margin — + // do not "correct" it to 1. + // + // Clearing that branch also stops bash sourcing ~/.bashrc, so BASH_ENV points + // at a bash-specific stub that applies the cap AND chains $HOME/.bashrc + // (TOOL_RLIMIT_BASHENV) rather than at rlimit.sh directly. Without that chain + // this fix would swap the pod's environment file for the cap instead of + // adding the cap to it — on a shared-HOME agent that silently drops + // ANTHROPIC_BASE_URL/CCROTATE_SERVE_* and the PATH entries .bashrc sets. + merged.BASH_ENV = TOOL_RLIMIT_BASHENV; + merged.ZDOTDIR = TOOL_RLIMIT_ZDOTDIR; + merged.SHLVL = "2"; if (isolation.enabled) { merged.CLAUDE_CONFIG_DIR = `${isolation.sessionRoot}/.claude`; merged.XDG_CONFIG_HOME = `${isolation.sessionRoot}/.config`; @@ -1199,6 +1247,193 @@ const DIND_WAIT_PREAMBLE = * manifest so misprovisioning surfaces at launch as a named, actionable * error instead of a scattered `Forbidden` days later. */ +// --------------------------------------------------------------------------- +// BLO-34477: per-child memory ceiling for tool-spawned processes. +// +// Agent Job pods run under cgroup v2 with kubelet's default +// `memory.oom.group=1`, so when the `claude` container hits its memory limit +// the kernel SIGKILLs EVERY process in the container as a group — the run dies +// (exit 137 / OOMKilled) even when the culprit was a single runaway child. +// Claude Code's Bash tool backgrounds, rather than kills, a command that +// exceeds its timeout, so one pathological grep/node/python can walk the whole +// cgroup to its limit unobserved (12 pods >5 GiB in 24 h; typical is ~0.5 GiB). +// +// The fix bounds each child, not the container: every shell the agent spawns +// applies RLIMIT_DATA (`ulimit -d`) from a file the init container writes onto +// the per-pod runtime-cache emptyDir, which both containers mount. zsh reads +// `$ZDOTDIR/.zshenv` on every start, and bash reads `$BASH_ENV` on a +// non-interactive start — but only off its rshd/sshd branch, which is why the +// claude container also sets `SHLVL=2` (see the env builder: socket stdin from +// libuv + SHLVL < 2 makes bash source ~/.bashrc and return before BASH_ENV, so +// without SHLVL the bash arm delivers nothing). Together they bind every +// Bash-tool command, and the cap is inherited by its descendants — but NOT by +// the already-running `claude` process, which is exec'd from `sh -c` (dash on +// this image; POSIX sh reads neither variable). A runaway child now fails alone +// with ENOMEM, and the model sees a real error instead of a silent orphan. +// +// Why the emptyDir and not `$HOME/.zshenv`: HOME may be a persistent, shared +// PVC path (legacy shared mode), a per-run runtime-cache path, or — with a +// custom `workspaceMountPath` — a path the init container cannot even see. +// The emptyDir is per-pod, always mounted in both containers, and dies with +// the pod, so the cap is rewritten fresh every run with no marker, no shared +// file to race on, and no stale value to un-append. The ZDOTDIR stubs chain to +// the user's own `$HOME/.z*` files, and the `BASH_ENV` stub chains +// `$HOME/.bashrc`, so nothing an agent relies on is lost — the cap is added to +// the environment HOME would have given it, not substituted for it. +// +// RLIMIT_DATA rather than RLIMIT_AS: `claude` maps ~73 GiB of virtual address +// space (V8 pointer-compression cages) against <1 GiB resident, so no `-v` +// value that leaves node runnable is below an 8 GiB cgroup. RLIMIT_DATA counts +// writable private anonymous mappings — actual heap growth — which is what +// fills the cgroup. +// +// The default cap is half the container memory limit: the largest child plus +// everything else in the container (bounded above by the request-sized +// baseline, which is well under the other half) stays below the cgroup limit, +// so the group kill cannot be reached by one child. `resources.limits.toolMemoryKb` +// overrides it per agent; `0` disables the cap. +// --------------------------------------------------------------------------- + +export const TOOL_MEMORY_LIMIT_CONFIG_KEY = "resources.limits.toolMemoryKb"; +export const TOOL_RLIMIT_DIR = `${RUNTIME_CACHE_MOUNT_PATH}/tool-rlimit`; +/** Sourced by zsh via the `ZDOTDIR` stub, and by bash via the `bashenv.sh` stub. */ +export const TOOL_RLIMIT_FILE = `${TOOL_RLIMIT_DIR}/rlimit.sh`; +/** + * `BASH_ENV` target. A bash-specific stub rather than `rlimit.sh` itself: + * clearing bash's rshd/sshd branch (see `SHLVL` in the env builder) also stops + * bash sourcing `$HOME/.bashrc`, which on a shared-HOME agent is a real file + * supplying `ANTHROPIC_BASE_URL`/`CCROTATE_SERVE_*`, `JAVA_HOME` and `PATH` + * entries. This stub applies the cap and then chains `$HOME/.bashrc`, so the + * agent keeps the environment it would have had from HOME alone, plus the cap — + * the same invariant the ZDOTDIR stubs already hold for zsh. It must be a + * separate file from `rlimit.sh`: the `.zshenv` stub and the POSIX-`sh` path + * source `rlimit.sh` directly, and chaining a bash rc inside it would pull + * `.bashrc` into zsh and sh. + */ +export const TOOL_RLIMIT_BASHENV = `${TOOL_RLIMIT_DIR}/bashenv.sh`; +/** `ZDOTDIR` for the claude container; holds chaining stubs for every zsh dotfile. */ +export const TOOL_RLIMIT_ZDOTDIR = `${TOOL_RLIMIT_DIR}/zdotdir`; +/** Every dotfile zsh looks up under ZDOTDIR; each stub defers to `$HOME/`. */ +export const ZSH_DOTFILES = [".zshenv", ".zprofile", ".zshrc", ".zlogin", ".zlogout"] as const; + +const MEMORY_QUANTITY_RE = /^([0-9]+)(Ki|Mi|Gi|Ti|Pi|k|M|G|T|P)?$/; +const MEMORY_UNIT_BYTES: Record = { + "": 1, + Ki: 1024, + Mi: 1024 ** 2, + Gi: 1024 ** 3, + Ti: 1024 ** 4, + Pi: 1024 ** 5, + k: 1e3, + M: 1e6, + G: 1e9, + T: 1e12, + P: 1e15, +}; + +/** + * Parse an integer Kubernetes memory quantity (`8Gi`, `6144Mi`, `2G`, plain + * bytes) to whole KiB. Fractional quantities (`1.5Gi`) and the `m` + * (milli) suffix are rejected: the value feeds `ulimit -d`, which takes + * integer KiB, and a memory limit expressed that way is an operator error + * worth surfacing at manifest-build time rather than rounding silently. + */ +export function parseMemoryQuantityToKiB(raw: string, field: string): number { + const match = MEMORY_QUANTITY_RE.exec(raw.trim()); + if (!match) { + throw new Error(`${field} must be an integer Kubernetes memory quantity such as 8Gi or 6144Mi: ${raw}`); + } + const kib = Math.floor((Number(match[1]) * MEMORY_UNIT_BYTES[match[2] ?? ""]) / 1024); + if (!Number.isSafeInteger(kib) || kib <= 0) { + throw new Error(`${field} must be at least 1Ki: ${raw}`); + } + return kib; +} + +/** + * Resolve the RLIMIT_DATA cap (KiB) applied to tool-spawned children. Unset + * derives half of the container memory limit; an explicit non-negative integer + * (number or digit string) wins; `0` disables the cap. A malformed explicit + * value throws — it is interpolated into a shell command, so only digits may + * pass. A container limit this derivation cannot read (a legal but fractional + * Kubernetes quantity such as `1.5Gi`, or a unit outside the parser) is NOT a + * reason to refuse the Job: that limit was already valid before this cap + * existed and the cluster still enforces it. It degrades to no cap (`0`) with + * a warning so the operator can pin `toolMemoryKb` explicitly. + */ +export function resolveToolMemoryLimitKb( + config: Record, + containerMemoryLimit: string, + warn: (message: string) => void = (message) => console.warn(message), +): number { + const raw = config[TOOL_MEMORY_LIMIT_CONFIG_KEY]; + if (raw === undefined || raw === null || (typeof raw === "string" && raw.trim() === "")) { + try { + return Math.floor(parseMemoryQuantityToKiB(containerMemoryLimit, "resources.limits.memory") / 2); + } catch (error) { + warn( + `resources.limits.memory=${JSON.stringify(containerMemoryLimit)} cannot be halved into a tool RLIMIT_DATA cap ` + + `(${error instanceof Error ? error.message : String(error)}); running tool children with no RLIMIT_DATA cap. ` + + `Set ${TOOL_MEMORY_LIMIT_CONFIG_KEY} (KiB) explicitly to cap them.`, + ); + return 0; + } + } + const value = + typeof raw === "number" + ? raw + : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) + ? Number(raw.trim()) + : Number.NaN; + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${TOOL_MEMORY_LIMIT_CONFIG_KEY} must be a non-negative integer number of KiB (0 disables the cap): ${String(raw)}`); + } + return value; +} + +/** + * Shell lines for the write-prompt init container (busybox `sh`) that install + * the cap under `dir` (the runtime-cache emptyDir, mounted by both containers): + * + * 1. `/rlimit.sh` — the `ulimit -d` line (or a "disabled" comment when + * the cap is 0, so the stubs always point at a readable file). + * 2. `/bashenv.sh` — the `BASH_ENV` target: sources rlimit.sh, then the + * user's `$HOME/.bashrc`. Separate from rlimit.sh because zsh and POSIX sh + * source that file directly and must not pull in a bash rc. + * 3. `/zdotdir/.zshenv` — sources rlimit.sh, then the user's + * `$HOME/.zshenv`. The other zsh dotfiles get pure chaining stubs so an + * interactive/login zsh under `ZDOTDIR` behaves exactly as it would with + * HOME alone. `$HOME` is deliberately left unexpanded: the shell that + * sources the stub resolves it. + * + * Everything is rewritten unconditionally: the emptyDir is per-pod, so there is + * no earlier state to preserve and a changed cap takes effect on the next run. + */ +export function buildToolRlimitInitShell(dir: string, limitKb: number): string[] { + const q = (value: string) => `'${value.replace(/'/g, "'\\''")}'`; + const rlimitFile = `${dir}/rlimit.sh`; + const bashenvFile = `${dir}/bashenv.sh`; + const zdotdir = `${dir}/zdotdir`; + const rlimitLines = [ + "# paperclip claude_k8s (BLO-34477): RLIMIT_DATA cap for tool-spawned children so a runaway fails alone with ENOMEM instead of the cgroup OOM-killing the whole run. Rewritten by the write-prompt init container on every run.", + limitKb > 0 ? `ulimit -d ${limitKb} 2>/dev/null || true` : `# cap disabled (${TOOL_MEMORY_LIMIT_CONFIG_KEY}=0)`, + ]; + const chain = (name: string) => `if [ -r "$HOME/${name}" ]; then . "$HOME/${name}"; fi`; + const parts = [ + `mkdir -p ${q(zdotdir)}`, + `printf '%s\\n' ${rlimitLines.map(q).join(" ")} > ${q(rlimitFile)}`, + // Cap first, then the user's own .bashrc — SHLVL=2 means bash no longer + // reads it on its own, so without this chain the fix would silently strip + // the pod's environment file from a directly-spawned bash. + `printf '%s\\n' ${[`. ${q(rlimitFile)}`, chain(".bashrc")].map(q).join(" ")} > ${q(bashenvFile)}`, + ]; + for (const name of ZSH_DOTFILES) { + const lines = name === ".zshenv" ? [`. ${q(rlimitFile)}`, chain(name)] : [chain(name)]; + parts.push(`printf '%s\\n' ${lines.map(q).join(" ")} > ${q(`${zdotdir}/${name}`)}`); + } + return parts; +} + export function resolveServiceAccountName(config: Record): string { const perAgent = asString(config.serviceAccountName, "").trim(); if (perAgent) return perAgent; @@ -1970,6 +2205,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { `mkdir -p ${browserChromeDir} ${browserMetricsTargetQ}`, `[ -L ${browserMetricsLink} ] || { rm -rf ${browserMetricsLink}; ln -sfn ${browserMetricsTargetQ} ${browserMetricsLink}; }`, ); + // BLO-34477: install the tool-child RLIMIT_DATA cap onto the runtime-cache + // emptyDir. Both containers mount it at RUNTIME_CACHE_MOUNT_PATH (see + // initVolumeMounts below and the main container's volumeMounts), and the + // claude container's BASH_ENV / ZDOTDIR point at what is written here. + initCommandParts.push( + ...buildToolRlimitInitShell(TOOL_RLIMIT_DIR, resolveToolMemoryLimitKb(config, containerResources.limits?.memory ?? "")), + ); // The `data` volume is declared unconditionally above (PVC-backed, or an // `emptyDir` when no claim is configured), so this mount needs no condition // and cannot drift from the main container's list. `job-manifest.test.ts` pins