Skip to content
Open
8 changes: 5 additions & 3 deletions vendor/paperclip-adapter-claude-k8s/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ control plane.
| Repository vendored from | <https://github.com/kkroo/paperclip-adapter-claude-k8s> |
| 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
Expand Down Expand Up @@ -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
14a197b0ef7727b9b6e4f41088088fb0f97af3b8e70011316305722b0f9925b2
```

Regenerate with:
Expand Down Expand Up @@ -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. **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`, `<dir>/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.

Expand All @@ -193,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.

Expand Down
4 changes: 2 additions & 2 deletions vendor/paperclip-adapter-claude-k8s/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion vendor/paperclip-adapter-claude-k8s/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export function getConfigSchema(): AdapterConfigSchema {
label: "Memory Limit",
hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
},
{
type: "number",
key: "resources.limits.toolMemoryKb",
label: "Tool Child Memory Cap (KiB)",
hint: "RLIMIT_DATA ceiling (ulimit -d, KiB) applied to every bash or zsh the agent spawns — Bash tool commands and their children — and inherited by their descendants, including plain `sh`. Not applied to the claude process itself, nor to a bare `sh -c` launched outside a tool shell (POSIX sh reads neither BASH_ENV nor ZDOTDIR). A runaway child then fails alone with ENOMEM instead of the cgroup OOM-killing the whole run (BLO-34477). Default: half of Memory Limit. 0 disables.",
},
// Scheduling
{
type: "textarea",
Expand Down
Loading