From b104447a8d18adbff4b080b0c88369a0184e555e Mon Sep 17 00:00:00 2001 From: CTO Date: Sat, 19 Sep 2026 12:47:55 +0300 Subject: [PATCH 1/9] fix(claude-k8s): cap tool-spawned children with RLIMIT_DATA so a runaway child cannot OOM-kill the run (BLO-34477) WIP checkpoint: full implementation + tests, pending local test run. Root cause: agent pods run under cgroup v2 with memory.oom.group=1, so when one Bash-tool child (Claude Code backgrounds a command that outlives its 120 s timeout instead of killing it) grows to the container limit, the kernel SIGKILLs every process in the cgroup and the whole run exits 137. Measured 2026-09-17: 12 OOMKilled heartbeat pods in 24 h, one orphaned grep at 7.6 GiB. Fix: the write-prompt init container writes a `ulimit -d` file onto the per-pod runtime-cache emptyDir; the claude container gets BASH_ENV and ZDOTDIR pointing at it so every bash/zsh the agent spawns (and its descendants) is bounded by RLIMIT_DATA, while `claude` itself (exec'd via POSIX sh) is not. Default cap = floor(limits.memory / 2); overridable via resources.limits.toolMemoryKb (0 disables). RLIMIT_AS is unusable because claude maps ~73 GiB of VA. Co-Authored-By: Claude Fable 5.1 --- .../src/server/config-schema.test.ts | 10 + .../src/server/config-schema.ts | 6 + .../src/server/job-manifest.test.ts | 228 ++++++++++++++++++ .../src/server/job-manifest.ts | 168 +++++++++++++ 4 files changed, 412 insertions(+) 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..ee7660b3e75f 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 shell the agent spawns — Bash tool commands and their children — but not to the claude process itself. 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..40125a6cb688 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,14 @@ import { validateAgentCommand, validatePonytailPluginPath, validatePonytailDefaultMode, + buildToolRlimitInitShell, + parseMemoryQuantityToKiB, + resolveToolMemoryLimitKb, + TOOL_MEMORY_LIMIT_CONFIG_KEY, + TOOL_RLIMIT_DIR, + TOOL_RLIMIT_FILE, + TOOL_RLIMIT_ZDOTDIR, + ZSH_DOTFILES, } from "./job-manifest.js"; import type { SelfPodInfo } from "./k8s-client.js"; @@ -2872,3 +2880,223 @@ 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("surfaces an unparseable container memory limit instead of guessing a cap", () => { + expect(() => resolveToolMemoryLimitKb({}, "1.5Gi")).toThrow(/resources.limits.memory must be an integer Kubernetes memory quantity/); + }); + }); + + 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. + expect(cmd).toContain( + `printf '%s\\n' '. '"'"'${TOOL_RLIMIT_FILE}'"'"'' 'if [ -r "$HOME/.zshenv" ]; then . "$HOME/.zshenv"; fi' > '${TOOL_RLIMIT_ZDOTDIR}/.zshenv'`, + ); + // 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_FILE); + expect(env.get("ZDOTDIR")).toBe(TOOL_RLIMIT_ZDOTDIR); + expect(classifyEnvName("BASH_ENV")).toBe("SAFE_LITERAL"); + expect(classifyEnvName("ZDOTDIR")).toBe("SAFE_LITERAL"); + }); + + it("keeps BASH_ENV/ZDOTDIR on the emptyDir even when HOME is a per-run isolated root", () => { + ctx.runtime = { + ...ctx.runtime, + isolation: { + mode: "run", + key: "run-abc12345", + root: "/runtime-cache/paperclip-runs/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", + promptCacheRoot: "/runtime-cache/paperclip-runs/run-abc12345/prompt-cache", + storage: { workspace: "ephemeral", home: "ephemeral", session: "ephemeral", cache: "ephemeral" }, + }, + } as AdapterExecutionContext["runtime"]; + const env = claudeEnv(); + expect(env.get("HOME")).toBe("/runtime-cache/paperclip-runs/run-abc12345/home"); + expect(env.get("BASH_ENV")).toBe(TOOL_RLIMIT_FILE); + 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; + 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(); + + it("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)); + if (which("bash")) { + expect(ulimitD(["bash", "-c", "ulimit -d"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(String(CAP_KB)); + // A grandchild inherits it — the property that bounds the whole subtree. + expect(ulimitD(["bash", "-c", "sh -c 'ulimit -d'"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(String(CAP_KB)); + } + }); + + 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); + }); + + it("zsh under ZDOTDIR applies the cap and still sources the user's own $HOME dotfiles", () => { + if (!which("zsh")) return; + 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..6bc0abfeae45 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,16 @@ 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: "CLAUDE_CONFIG_DIR", classification: "SAFE_LITERAL", @@ -1067,6 +1077,14 @@ 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: bash sources $BASH_ENV on every non-interactive start and zsh + // sources $ZDOTDIR/.zshenv on every start, 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. + merged.BASH_ENV = TOOL_RLIMIT_FILE; + merged.ZDOTDIR = TOOL_RLIMIT_ZDOTDIR; if (isolation.enabled) { merged.CLAUDE_CONFIG_DIR = `${isolation.sessionRoot}/.claude`; merged.XDG_CONFIG_HOME = `${isolation.sessionRoot}/.config`; @@ -1199,6 +1217,149 @@ 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. bash reads +// `$BASH_ENV` on every non-interactive start and zsh reads `$ZDOTDIR/.zshenv` +// on every start, so the cap binds every Bash-tool command and 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 so nothing an agent relies on is lost. +// +// 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 bash via `BASH_ENV` and by zsh via the `ZDOTDIR` stub. */ +export const TOOL_RLIMIT_FILE = `${TOOL_RLIMIT_DIR}/rlimit.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. Anything else throws — + * the value is interpolated into a shell command, so only digits may pass. + */ +export function resolveToolMemoryLimitKb(config: Record, containerMemoryLimit: string): number { + const raw = config[TOOL_MEMORY_LIMIT_CONFIG_KEY]; + if (raw === undefined || raw === null || (typeof raw === "string" && raw.trim() === "")) { + return Math.floor(parseMemoryQuantityToKiB(containerMemoryLimit, "resources.limits.memory") / 2); + } + 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 `BASH_ENV` always points at a readable file). + * 2. `/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 zsh 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 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)}`, + ]; + 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 +2131,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 From d93a475d20965941359fa05329504a7499ba8246 Mon Sep 17 00:00:00 2001 From: CTO Date: Sat, 19 Sep 2026 12:57:01 +0300 Subject: [PATCH 2/9] test(claude-k8s): make the BLO-34477 cap tests pass on the host that runs them - Match the POSIX '\'' quoting the init-command quoter actually emits. - Build the per-run isolation fixture through setRuntimeIsolation() with the typed descriptor shape resolveJobIsolation() reads. - Gate the two "cap is applied" executed-shell tests on a host probe: Darwin's setrlimit(RLIMIT_DATA) returns EINVAL for any lowering, so they can only assert on Linux (the adapter's deployment target and CI). The probe asks the host rather than the platform string. Co-Authored-By: Claude Fable 5.1 --- .../src/server/job-manifest.test.ts | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) 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 40125a6cb688..db07b2dbf03b 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 @@ -2964,8 +2964,10 @@ describe("tool-child memory cap (BLO-34477)", () => { 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'`, + `printf '%s\\n' '. '\\''${TOOL_RLIMIT_FILE}'\\''' 'if [ -r "$HOME/.zshenv" ]; then . "$HOME/.zshenv"; fi' > '${TOOL_RLIMIT_ZDOTDIR}/.zshenv'`, ); // Every other zsh dotfile is a pure chaining stub, so ZDOTDIR loses nothing. for (const name of ZSH_DOTFILES.filter((n) => n !== ".zshenv")) { @@ -3005,21 +3007,16 @@ describe("tool-child memory cap (BLO-34477)", () => { }); it("keeps BASH_ENV/ZDOTDIR on the emptyDir even when HOME is a per-run isolated root", () => { - ctx.runtime = { - ...ctx.runtime, - isolation: { - mode: "run", - key: "run-abc12345", - root: "/runtime-cache/paperclip-runs/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", - promptCacheRoot: "/runtime-cache/paperclip-runs/run-abc12345/prompt-cache", - storage: { workspace: "ephemeral", home: "ephemeral", session: "ephemeral", cache: "ephemeral" }, - }, - } as AdapterExecutionContext["runtime"]; + 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_FILE); @@ -3047,6 +3044,14 @@ describe("tool-child memory cap (BLO-34477)", () => { // 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); @@ -3062,7 +3067,7 @@ describe("tool-child memory cap (BLO-34477)", () => { const ulimitD = (argv: string[], env: Record): string => spawnSync(argv[0], argv.slice(1), { encoding: "utf8", env: { PATH: process.env.PATH ?? "", ...env } }).stdout.trim(); - it("bash under BASH_ENV, and POSIX sh sourcing the file, report the cap", () => { + itOnCapableHost("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)); if (which("bash")) { @@ -3079,8 +3084,7 @@ describe("tool-child memory cap (BLO-34477)", () => { expect(ulimitD(["/bin/sh", "-c", "ulimit -d"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(baseline); }); - it("zsh under ZDOTDIR applies the cap and still sources the user's own $HOME dotfiles", () => { - if (!which("zsh")) return; + (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` }; From dfd703826b202b46a6dc80a0ce4c340e66f4671d Mon Sep 17 00:00:00 2001 From: Players Engineer Date: Sun, 20 Sep 2026 01:46:42 +0300 Subject: [PATCH 3/9] fix(k8s-adapter): degrade an unparseable container memory limit to no cap instead of aborting the Job resolveToolMemoryLimitKb threw on a legal Kubernetes quantity such as 1.5Gi, which aborted buildJobManifest for every heartbeat on a deployment whose resources.limits.memory the integer-only parser cannot read. That limit was valid before the cap existed and the cluster still enforces it, so refusing the Job is the wrong failure mode. Unset toolMemoryKb now falls back to no RLIMIT_DATA cap with a warning naming the knob to pin; a malformed explicit toolMemoryKb still throws because it is interpolated into a shell command. PROVENANCE hash recomputed. (BLO-34477, Ally round 1) Co-Authored-By: Claude Fable 5.1 --- .../src/server/job-manifest.test.ts | 17 ++++++++++-- .../src/server/job-manifest.ts | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) 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 db07b2dbf03b..37113b68523e 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 @@ -2952,8 +2952,21 @@ describe("tool-child memory cap (BLO-34477)", () => { } }); - it("surfaces an unparseable container memory limit instead of guessing a cap", () => { - expect(() => resolveToolMemoryLimitKb({}, "1.5Gi")).toThrow(/resources.limits.memory must be an integer Kubernetes memory quantity/); + 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([]); }); }); 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 6bc0abfeae45..1594c2b53ce6 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts @@ -1305,13 +1305,31 @@ export function parseMemoryQuantityToKiB(raw: string, field: string): number { /** * 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. Anything else throws — - * the value is interpolated into a shell command, so only digits may pass. + * (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): number { +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() === "")) { - return Math.floor(parseMemoryQuantityToKiB(containerMemoryLimit, "resources.limits.memory") / 2); + 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" From 7da6785895af7b79e6803a1c144f3f71a15944ba Mon Sep 17 00:00:00 2001 From: Players Engineer Date: Sun, 20 Sep 2026 03:12:15 +0300 Subject: [PATCH 4/9] test(k8s-adapter): probe whether the host's bash sources BASH_ENV before asserting the cap through it The arc-light lane reported 'unlimited' from bash -c 'ulimit -d' under BASH_ENV while /bin/sh sourcing the same file reported the cap, and the same commands succeed as runner and root inside the runner image. bash skips BASH_ENV in host-specific situations (euid != uid, POSIX mode, a non-GNU bash on PATH), none of which describe the agent image whose bash delivers the cap in production. Probe the host once, skip the bash assertions with a console.warn carrying version/uid/euid/path when it does not source BASH_ENV, and attach ulimit/hard-limit/uid diagnostics to the assertions so a real regression is legible. PROVENANCE hash recomputed. (BLO-34477) Co-Authored-By: Claude Fable 5.1 --- .../src/server/job-manifest.test.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 37113b68523e..9a02661de8b0 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 @@ -3080,13 +3080,33 @@ describe("tool-child memory cap (BLO-34477)", () => { const ulimitD = (argv: string[], env: Record): string => spawnSync(argv[0], argv.slice(1), { encoding: "utf8", env: { PATH: process.env.PATH ?? "", ...env } }).stdout.trim(); + // bash skips $BASH_ENV in a few host-specific situations (euid != uid, + // POSIX mode, a `bash` on PATH that is not GNU bash). The cap's delivery + // mechanism assumes the agent image's bash, not the test host's, so probe + // the host and skip -- loudly, with the reason -- rather than fail on a + // property of the CI runner (arc-light reported `unlimited` here). + const bashHonorsBashEnv = ((): boolean => { + if (!which("bash")) return false; + const probe = mkdtempSync(join(tmpdir(), "blo34477-bashenv-")); + tempDirs.push(probe); + writeFileSync(join(probe, "env.sh"), "BLO34477_BASH_ENV=applied\n"); + const applied = ulimitD(["bash", "-c", 'printf %s "$BLO34477_BASH_ENV"'], { HOME: probe, BASH_ENV: join(probe, "env.sh") }); + if (applied === "applied") return true; + const diag = spawnSync("bash", ["-c", 'printf "bash=%s uid=%s euid=%s gid=%s egid=%s path=%s" "$BASH_VERSION" "$(id -ru)" "$(id -u)" "$(id -rg)" "$(id -g)" "$(command -v bash)"'], { encoding: "utf8" }).stdout; + console.warn(`[BLO-34477 test] this host's bash does not source BASH_ENV (${diag}); skipping the bash-under-BASH_ENV assertions`); + return false; + })(); + const bashDiag = (env: Record): string => + ulimitD(["bash", "-c", 'ulimit -d; echo "rc=$? hard=$(ulimit -H -d) version=$BASH_VERSION uid=$(id -ru)/$(id -u)"'], env).replace(/\n/g, " "); + itOnCapableHost("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)); - if (which("bash")) { - expect(ulimitD(["bash", "-c", "ulimit -d"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(String(CAP_KB)); + if (bashHonorsBashEnv) { + const env = { HOME: home, BASH_ENV: `${dir}/rlimit.sh` }; + 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'"], { HOME: home, BASH_ENV: `${dir}/rlimit.sh` })).toBe(String(CAP_KB)); + expect(ulimitD(["bash", "-c", "sh -c 'ulimit -d'"], env), bashDiag(env)).toBe(String(CAP_KB)); } }); From 71e81f2d53af5894a08ae1d58da4323b62850081 Mon Sep 17 00:00:00 2001 From: Players Engineer Date: Sun, 20 Sep 2026 03:13:27 +0300 Subject: [PATCH 5/9] test(k8s-adapter): evaluate the BASH_ENV host probe inside the test body The probe ran at collection time, before beforeEach initialised tempDirs, and took the whole file down. Make it a function called from the test. PROVENANCE hash recomputed. (BLO-34477) Co-Authored-By: Claude Fable 5.1 --- .../src/server/job-manifest.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 9a02661de8b0..45c312a368cd 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 @@ -3085,7 +3085,8 @@ describe("tool-child memory cap (BLO-34477)", () => { // mechanism assumes the agent image's bash, not the test host's, so probe // the host and skip -- loudly, with the reason -- rather than fail on a // property of the CI runner (arc-light reported `unlimited` here). - const bashHonorsBashEnv = ((): boolean => { + // Evaluated inside a test body: tempDirs is set up in beforeEach. + const bashHonorsBashEnv = (): boolean => { if (!which("bash")) return false; const probe = mkdtempSync(join(tmpdir(), "blo34477-bashenv-")); tempDirs.push(probe); @@ -3095,14 +3096,14 @@ describe("tool-child memory cap (BLO-34477)", () => { const diag = spawnSync("bash", ["-c", 'printf "bash=%s uid=%s euid=%s gid=%s egid=%s path=%s" "$BASH_VERSION" "$(id -ru)" "$(id -u)" "$(id -rg)" "$(id -g)" "$(command -v bash)"'], { encoding: "utf8" }).stdout; console.warn(`[BLO-34477 test] this host's bash does not source BASH_ENV (${diag}); skipping the bash-under-BASH_ENV assertions`); return false; - })(); + }; const bashDiag = (env: Record): string => ulimitD(["bash", "-c", 'ulimit -d; echo "rc=$? hard=$(ulimit -H -d) version=$BASH_VERSION uid=$(id -ru)/$(id -u)"'], env).replace(/\n/g, " "); itOnCapableHost("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)); - if (bashHonorsBashEnv) { + if (bashHonorsBashEnv()) { const env = { HOME: home, BASH_ENV: `${dir}/rlimit.sh` }; expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).toBe(String(CAP_KB)); // A grandchild inherits it — the property that bounds the whole subtree. From f7d82ac2c144bbf28d95b8f05f76e208bfe22d8a Mon Sep 17 00:00:00 2001 From: Omar Ramadan Date: Sun, 20 Sep 2026 01:45:27 +0000 Subject: [PATCH 6/9] fix(claude-k8s): ship SHLVL=2 so the BASH_ENV RLIMIT_DATA arm is not dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ally's re-review dismissed her own earlier approval of this head and found the bash half of the tool-child cap inert. 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. Set SHLVL=2 in the claude container env (declared SAFE_LITERAL under the BLO-29804 gate). Smallest of the three candidate deliveries and the only one with no $HOME dependency, $HOME being what the emptyDir design exists to avoid. Live exposure was config-dependent, not 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. 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 env as { PATH, ...overrides } so SHLVL is never inherited — both branch conditions hold on every POSIX host, the probe returned false universally, and the bash assertions were unreachable. A detector for this exact defect wired to always report clean, and the reason two prior passes came back green; its comment blamed three causes 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 be deleted as redundant-looking. Both halves mutation-tested: removing SHLVL from the test env arm fails the behavioural case, removing merged.SHLVL fails the manifest case, baseline restores green. Reproduced independently of the agent image (bash 5.2.21 here, 5.2.37 in the image). Also narrows the config-schema hint, which claimed the cap reached "every shell the agent spawns" — false for bash, and silent about plain sh. PROVENANCE: integrity hash recomputed (c98d9025 -> 4e6dbf5c) and the missing Local-modifications row added for this PR. Refs: BLO-34477 Co-Authored-By: Claude Opus 5 (1M context) --- .../PROVENANCE.md | 2 + .../src/server/config-schema.ts | 2 +- .../src/server/job-manifest.test.ts | 78 +++++++++++++------ .../src/server/job-manifest.ts | 47 +++++++---- 4 files changed, 90 insertions(+), 39 deletions(-) diff --git a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md index 68a7fcd15681..6e1ed68bd65d 100644 --- a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md +++ b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md @@ -176,6 +176,8 @@ upstream**, so they are enumerated here rather than left implicit. | [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. | + The two cherry-picked commits in the composition above remain upstream commits authored against the fork, not Blockcast-local patches. 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 ee7660b3e75f..4fe20ca953a0 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/config-schema.ts @@ -142,7 +142,7 @@ export function getConfigSchema(): AdapterConfigSchema { type: "number", key: "resources.limits.toolMemoryKb", label: "Tool Child Memory Cap (KiB)", - hint: "RLIMIT_DATA ceiling (ulimit -d, KiB) applied to every shell the agent spawns — Bash tool commands and their children — but not to the claude process itself. 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.", + 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 { 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 45c312a368cd..11580ad8b0aa 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 @@ -3019,6 +3019,16 @@ describe("tool-child memory cap (BLO-34477)", () => { 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", @@ -3080,35 +3090,53 @@ describe("tool-child memory cap (BLO-34477)", () => { const ulimitD = (argv: string[], env: Record): string => spawnSync(argv[0], argv.slice(1), { encoding: "utf8", env: { PATH: process.env.PATH ?? "", ...env } }).stdout.trim(); - // bash skips $BASH_ENV in a few host-specific situations (euid != uid, - // POSIX mode, a `bash` on PATH that is not GNU bash). The cap's delivery - // mechanism assumes the agent image's bash, not the test host's, so probe - // the host and skip -- loudly, with the reason -- rather than fail on a - // property of the CI runner (arc-light reported `unlimited` here). - // Evaluated inside a test body: tempDirs is set up in beforeEach. - const bashHonorsBashEnv = (): boolean => { - if (!which("bash")) return false; - const probe = mkdtempSync(join(tmpdir(), "blo34477-bashenv-")); - tempDirs.push(probe); - writeFileSync(join(probe, "env.sh"), "BLO34477_BASH_ENV=applied\n"); - const applied = ulimitD(["bash", "-c", 'printf %s "$BLO34477_BASH_ENV"'], { HOME: probe, BASH_ENV: join(probe, "env.sh") }); - if (applied === "applied") return true; - const diag = spawnSync("bash", ["-c", 'printf "bash=%s uid=%s euid=%s gid=%s egid=%s path=%s" "$BASH_VERSION" "$(id -ru)" "$(id -u)" "$(id -rg)" "$(id -g)" "$(command -v bash)"'], { encoding: "utf8" }).stdout; - console.warn(`[BLO-34477 test] this host's bash does not source BASH_ENV (${diag}); skipping the bash-under-BASH_ENV assertions`); - return false; - }; + // `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}/rlimit.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 uid=$(id -ru)/$(id -u)"'], env).replace(/\n/g, " "); + 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; - itOnCapableHost("bash under BASH_ENV, and POSIX sh sourcing the file, report the cap", () => { + 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)); - if (bashHonorsBashEnv()) { - const env = { HOME: home, BASH_ENV: `${dir}/rlimit.sh` }; - 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)); - } + 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)); + }); + + // 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. + 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 env = { HOME: home, BASH_ENV: `${dir}/rlimit.sh` }; + expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).not.toBe(String(CAP_KB)); }); it("POSIX sh -c — the shape that launches claude — ignores BASH_ENV and stays uncapped", () => { 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 1594c2b53ce6..c020d4641db0 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts @@ -685,6 +685,11 @@ export const ENV_NAME_CLASSIFICATION: readonly EnvNameClassification[] = [ 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", @@ -1077,14 +1082,27 @@ 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: bash sources $BASH_ENV on every non-interactive start and zsh - // sources $ZDOTDIR/.zshenv on every start, 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. + // 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. merged.BASH_ENV = TOOL_RLIMIT_FILE; 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`; @@ -1230,13 +1248,16 @@ const DIND_WAIT_PREAMBLE = // // 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. bash reads -// `$BASH_ENV` on every non-interactive start and zsh reads `$ZDOTDIR/.zshenv` -// on every start, so the cap binds every Bash-tool command and 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. +// 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 From 628ca85d79ef9a98edda112415544064d8378590 Mon Sep 17 00:00:00 2001 From: Omar Ramadan Date: Sun, 20 Sep 2026 10:35:51 +0000 Subject: [PATCH 7/9] fix(claude-k8s): chain $HOME/.bashrc from the BASH_ENV stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Important from Ally's review of 6eba574a4. SHLVL=2 does not add a startup file, it swaps one: off bash's rshd/sshd branch, bash stops sourcing $HOME/.bashrc. rlimit.sh carried no chain back to it, so the cap replaced the pod's environment file instead of adding to it. Measured on the agent 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; 2 of 9 then-running claude pods had HOME=/paperclip, so a bash-spawned claude/codex would have quietly stopped pointing at the rotation proxy. BASH_ENV now targets TOOL_RLIMIT_BASHENV (/bashenv.sh), which sources rlimit.sh then chains $HOME/.bashrc — the same shape already used for zsh. It must 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: removing chain(".bashrc") fails both the init-shell assertion and the new behavioural case (cap AND chain asserted together); repointing BASH_ENV at rlimit.sh fails two manifest cases; baseline green at 915/915. Two suggestions from the same review land with it: - the negative SHLVL case asserts equality against a measured uncapped baseline rather than .not.toBe(CAP), so a bash that fails to start no longer passes it vacuously; - the source records that bash increments SHLVL before evaluating shell_level < 2 (1 would already clear it; 2 is margin), so the next reader does not read it as an off-by-one. PROVENANCE: hash recomputed (4e6dbf5c -> a7fae8f6), row extended with this round. Refs: BLO-34477 Co-Authored-By: Claude Opus 5 (1M context) --- .../PROVENANCE.md | 2 +- .../src/server/job-manifest.test.ts | 41 ++++++++++++++-- .../src/server/job-manifest.ts | 49 ++++++++++++++++--- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md index 6e1ed68bd65d..16d0ee4e493b 100644 --- a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md +++ b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md @@ -176,7 +176,7 @@ upstream**, so they are enumerated here rather than left implicit. | [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. | +| [#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. | The two cherry-picked commits in the composition above remain upstream commits authored against the fork, not Blockcast-local patches. 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 11580ad8b0aa..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 @@ -24,6 +24,7 @@ import { TOOL_MEMORY_LIMIT_CONFIG_KEY, TOOL_RLIMIT_DIR, TOOL_RLIMIT_FILE, + TOOL_RLIMIT_BASHENV, TOOL_RLIMIT_ZDOTDIR, ZSH_DOTFILES, } from "./job-manifest.js"; @@ -2982,6 +2983,16 @@ describe("tool-child memory cap (BLO-34477)", () => { 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}'`); @@ -3013,7 +3024,7 @@ describe("tool-child memory cap (BLO-34477)", () => { 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_FILE); + 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"); @@ -3042,7 +3053,7 @@ describe("tool-child memory cap (BLO-34477)", () => { }); const env = claudeEnv(); expect(env.get("HOME")).toBe("/runtime-cache/paperclip-runs/run-abc12345/home"); - expect(env.get("BASH_ENV")).toBe(TOOL_RLIMIT_FILE); + expect(env.get("BASH_ENV")).toBe(TOOL_RLIMIT_BASHENV); expect(env.get("ZDOTDIR")).toBe(TOOL_RLIMIT_ZDOTDIR); }); @@ -3105,7 +3116,7 @@ describe("tool-child memory cap (BLO-34477)", () => { // test skips honestly on `which`, and a real delivery regression must fail. const bashEnvArm = (dir: string, home: string): Record => ({ HOME: home, - BASH_ENV: `${dir}/rlimit.sh`, + BASH_ENV: `${dir}/bashenv.sh`, // Exactly what job-manifest.ts puts in the claude container env. SHLVL: "2", }); @@ -3128,15 +3139,35 @@ describe("tool-child memory cap (BLO-34477)", () => { 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 env = { HOME: home, BASH_ENV: `${dir}/rlimit.sh` }; - expect(ulimitD(["bash", "-c", "ulimit -d"], env), bashDiag(env)).not.toBe(String(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", () => { 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 c020d4641db0..08bf2286934e 100644 --- a/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts +++ b/vendor/paperclip-adapter-claude-k8s/src/server/job-manifest.ts @@ -1100,7 +1100,19 @@ function buildEnvVars( // 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. - merged.BASH_ENV = TOOL_RLIMIT_FILE; + // + // 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) { @@ -1265,7 +1277,9 @@ const DIND_WAIT_PREAMBLE = // 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 so nothing an agent relies on is lost. +// 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` @@ -1282,8 +1296,21 @@ const DIND_WAIT_PREAMBLE = export const TOOL_MEMORY_LIMIT_CONFIG_KEY = "resources.limits.toolMemoryKb"; export const TOOL_RLIMIT_DIR = `${RUNTIME_CACHE_MOUNT_PATH}/tool-rlimit`; -/** Sourced by bash via `BASH_ENV` and by zsh via the `ZDOTDIR` stub. */ +/** 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/`. */ @@ -1369,12 +1396,15 @@ export function resolveToolMemoryLimitKb( * 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 `BASH_ENV` always points at a readable file). - * 2. `/zdotdir/.zshenv` — sources rlimit.sh, then the user's + * 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 zsh that sources - * the stub resolves it. + * 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. @@ -1382,6 +1412,7 @@ export function resolveToolMemoryLimitKb( 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.", @@ -1391,6 +1422,10 @@ export function buildToolRlimitInitShell(dir: string, limitKb: number): string[] 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)]; From 6c009e3c480429733d4ad0f13471e242cf70eee5 Mon Sep 17 00:00:00 2001 From: CTO Date: Mon, 21 Sep 2026 02:19:58 +0000 Subject: [PATCH 8/9] chore(claude-k8s): refresh the PROVENANCE integrity hash and bump to .12 Rebase onto master dropped the merge commit that carried the 0.2.6-blockcast.12 bump and emptied the hash-refresh commit. Re-applies both: the version in package.json, package-lock.json and PROVENANCE.md, and the integrity hash recomputed from the rebased tree (package.json is inside the hashed set, so the hash is taken after the bump). Co-Authored-By: Claude Opus 5 (1M context) --- vendor/paperclip-adapter-claude-k8s/PROVENANCE.md | 6 +++--- vendor/paperclip-adapter-claude-k8s/package-lock.json | 4 ++-- vendor/paperclip-adapter-claude-k8s/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md index 16d0ee4e493b..cdac10278a98 100644 --- a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md +++ b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md @@ -14,7 +14,7 @@ control plane. | Repository vendored from | | | Package | `paperclip-adapter-claude-k8s` | | Version at vendor time | `0.2.5-kkroo.6` | -| Current version | `0.2.6-blockcast.11` — see [Versioning](#versioning) | +| Current version | `0.2.6-blockcast.12` — see [Versioning](#versioning) | | Declared license | MIT, in `package.json` only — see the caveat below | Before this change the image built this package by cloning that repository at a @@ -96,7 +96,7 @@ A manifest of `sha256(path)` over all 41 in-tree files, sorted by path under `LC_ALL=C`, itself hashes to: ``` -8c3a5f3d741ff0567bbe9f4a33ff7e9b520396dbc3cebe635d12d23b5f81fdf4 +a7fae8f6f55750170a05808d17526189fd7bca3c7aac5bcea5ebe66c8dc990e5 ``` Regenerate with: @@ -195,7 +195,7 @@ after the first Blockcast change that ships, the version alone could no longer tell you which code was running — provenance had to be established by grepping `dist/` for a token. -This directory therefore versions itself: **`0.2.6-blockcast.11`**, set in +This directory therefore versions itself: **`0.2.6-blockcast.12`**, set in `package.json` and `package-lock.json`. The `-blockcast.` prerelease channel says plainly that this is our tree, not an upstream release. diff --git a/vendor/paperclip-adapter-claude-k8s/package-lock.json b/vendor/paperclip-adapter-claude-k8s/package-lock.json index aca813770d94..7a7fe5ae9908 100644 --- a/vendor/paperclip-adapter-claude-k8s/package-lock.json +++ b/vendor/paperclip-adapter-claude-k8s/package-lock.json @@ -1,12 +1,12 @@ { "name": "paperclip-adapter-claude-k8s", - "version": "0.2.6-blockcast.11", + "version": "0.2.6-blockcast.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paperclip-adapter-claude-k8s", - "version": "0.2.6-blockcast.11", + "version": "0.2.6-blockcast.12", "license": "MIT", "dependencies": { "@kubernetes/client-node": "^1.0.0", diff --git a/vendor/paperclip-adapter-claude-k8s/package.json b/vendor/paperclip-adapter-claude-k8s/package.json index 63b0c1385268..12850c0cecf9 100644 --- a/vendor/paperclip-adapter-claude-k8s/package.json +++ b/vendor/paperclip-adapter-claude-k8s/package.json @@ -1,6 +1,6 @@ { "name": "paperclip-adapter-claude-k8s", - "version": "0.2.6-blockcast.11", + "version": "0.2.6-blockcast.12", "description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs", "license": "MIT", "repository": { From 19d7e26466066cf7816fae735b6798d2ac535b98 Mon Sep 17 00:00:00 2001 From: Omar Ramadan Date: Tue, 22 Sep 2026 21:10:34 +0000 Subject: [PATCH 9/9] fix(claude-k8s): refresh vendored provenance hash --- vendor/paperclip-adapter-claude-k8s/PROVENANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md index cdac10278a98..1ded5727ce94 100644 --- a/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md +++ b/vendor/paperclip-adapter-claude-k8s/PROVENANCE.md @@ -96,7 +96,7 @@ A manifest of `sha256(path)` over all 41 in-tree files, sorted by path under `LC_ALL=C`, itself hashes to: ``` -a7fae8f6f55750170a05808d17526189fd7bca3c7aac5bcea5ebe66c8dc990e5 +14a197b0ef7727b9b6e4f41088088fb0f97af3b8e70011316305722b0f9925b2 ``` Regenerate with: