Repository navigation
Bench: bench pull pulls each task's image by pinned digest and puts its base tree, from the image, in the held-out store (#748 slice 4) [PROTECTED: owner merges] - #947
Conversation
…ts base tree, from the image, in the store The rest of slice 4 of #748 (step 4 of the smoke run plan): images.json had no consumer that pulled, and no task had a base tree, a local clone with no upstream history or a local bare remote. bench pull --tasks smoke|baseline (the flag is required, as bench run will take it; no default) takes the tasks and their references from the store only (loadTasks), pulls each through pullImage (docker pull <name>@sha256:<digest>, never a tag) and reads the digest back; then reads images.json again and stops if it changed. pullImage now throws PullRefused with its stage, so bench pull can say what failed without docker's words, which name the image. Each task's base tree comes from its own image's /testbed, through the bench's container runner (docker run --network none, limits read back, TaskRunner exec only), never from upstream. With git metadata, HEAD must be base_commit; base_commit's own commit object and exactly its tree's objects are packed out, and on the host the commit must hash to base_commit and name that tree. With none, /testbed's files are hashed into a throwaway repo in the container and that tree is recorded as the content hash, saying HEAD was not checked. On the host: one root commit (fixed author and date) in a fresh bare remote that holds exactly its objects, a clone with that remote as origin, and a 0600 tree.json, built in a temporary folder and renamed to trees/<key>/ in the held-out store, never over anything. A tree already there is kept only when it is the same task from the same image and still checks out. It prints keys and counts only. The README gets step 10, the owner's steps in order with each block held equal to its setup step by a test, the open gap of collecting the image digests, and a step 5 that can be redone after each pins change. Test (fails without): test/bench-pull.test.ts (17 of 17 fail at 49b01df: no pull command, no pull module, no owner steps in the README). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… from its image (#947) The release note for the rest of #748 slice 4, as a fragment (changelog.d), never in CHANGELOG.md. Test (fails without): none, notes only; the changelog.d backstop in test/changelog.test.ts and test/changelog-fragments-selfcheck.test.ts pass with it. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (final check, head 54894eb)Reviewed head The pull side and the git-mode binding hold up. Three things don't: the copy-out has no size bound, content mode passes with no binding, and the pulls may write instance ids into a journal that the agents can read. 1. Held-out secrecy
2. Binding (probes with a stand-in docker
|
| Attack | Result |
|---|---|
| Wrong commit object, or the right tree without the commit | Refused (measured, tests 11 and 12). In git mode, base_commit's own commit object must hash and name the tree. Forging one needs a SHA-1 collision on a chosen commit (inferred). |
| Extra objects or upstream history in the pack | Dropped (measured, test 12). The fetch takes only what is reachable, and the object count is checked. |
A .git/hooks/post-checkout entry in the tree (built with mktree) |
Refused (measured): Refused: building its bare remote and clone failed. git's verify_path stops the checkout. |
A symlink in the tree (evil -> /etc/passwd, up -> ../../../../../../..) |
Accepted (measured). The clone keeps both as links. In git mode they can only be base_commit's own, which is legitimate. In content mode the image chooses them. Nothing in this PR follows them (inferred). Note for slice 7, which copies the tree. |
A submodule gitlink with .gitmodules url = file:///... |
Accepted (measured): clone/sub is an empty folder and nothing is fetched. The README doesn't say that submodule contents are missing (non-blocking). |
| A pack bomb | Accepted, no bound (measured). A 261,149-byte pack (348,200 base64) became a 268,435,456-byte file in trees/t01/clone, about 1028:1. In git mode the tree is base_commit's, so the image can't inject one. In content mode it can. Unreachable extra objects are still inflated by index-pack (inferred). |
| Copy-out size | Not bounded (measured) through the real async path: TaskRunner.exec with a stand-in docker binary. 256 MiB of stdout was taken whole (stdout chars=268435456, RSS grew by about 770 MiB). 600 MiB crashed the process with an uncaught ERR_STRING_TOO_LONG, thrown in spawnDockerAsync's close handler. That crash skips finally { docker.stop(c) }, so the sleep infinity container is left running (inferred). |
| Copy-out time | Bounded (inferred from code). Probe 5 min, pack and content 60 min, docker run 10 min, each host git call 30 min. |
Content mode (no .git) |
No binding to base_commit (inferred, and the PR body's "Not defended" says so). It is disclosed in the PASS line ("HEAD not checked against base_commit"), the README and tree.json (head_checked: false). But it still prints PASS. Nothing says that an image picks this path just by having no /testbed/.git. See required change 2. |
3. No-tag pulls
- Met (measured). Test 2 asserts that every docker argv containing the image name matches
name@sha256:<64 hex>(pull, image inspect, run). - Read-back of another pinned digest, an unpinned digest, or the digest under another name is refused (test 4, measured).
- An
images.jsonswapped during the pulls is refused (test 8, measured). - Trees are built from the in-memory tasks that the re-read confirmed (inferred).
- Observation, non-blocking:
container.tsIMAGE(line 49) still allows a:tagonrun. So the no-tag property ofrunrests onloadTasksbuildingtask.image, not on the argv check (inferred).
4. README owner steps A to L
- Accurate against the code (inferred from reading):
- step 10 describes pull.ts as it is;
- each block equals its setup step (measured, test 16);
- K's labels are right.
- Shell history (inferred):
- no block types an instance id or an image name;
- H types the dataset file name (a sha256), B the bench user's name, and E a clone URL.
- Step I (inferred):
- "blocked" is stated honestly in I, in step 7 ("Collecting the image digests is not built yet (an open gap)") and in the smoke plan's step 4;
- it says a decision is needed before J.
5. Fail-without (re-measured)
- Base run (measured):
- setup:
test/bench-pull.test.tswritten as a NEW file into a fresh worktree at49b01df8, byte-identical to the head's (sha256763170ef…bddboth); - command:
node --import ./dist/test/isolate.js --test dist/test/bench-pull.test.js; - result:
# tests 17,# pass 0,# fail 17.
- setup:
- Why they fail (measured):
- 14 fail on
pull is not a functionand 1 onCannot find module …/dist/scripts/bench/pull.js: a missing symbol, unavoidable for a new command; - test 1 fails on an assertion: the usage has no pull;
- test 16 fails on
the README has the owner steps.
- 14 fail on
- At the head (measured): 17/17 pass.
6. Other checks
- typecheck: clean at the head (measured).
- check:denylist:
1401 files clean(measured). - Package size: delta 0 (measured with
npm pack --dry-run --jsonat the head and at 49b01df). Both givesize1470176,unpackedSize4926629,entryCount491. - critical.mjs:
#947 self-merge-eligible (6 files)(measured). That disagrees with the PR's own[PROTECTED: owner merges], itsneeds-ownerlabel, and Bench: gated-merge benchmark on SWE-bench Verified (smoke, baseline, per-release rerun) in scripts/bench/ #748's "The owner merges" (L3scripts/**). The PR's stricter marking stands; the classifier gap belongs to the orchestrator. - Protection markings: the title,
**Risk class:** protected,needs-owner,## What this can never do,## Compromised componentand## Adversarial passare all present (measured). - Compromised component:
- its "a huge pack ... denial of service only (inferred)" is right that nothing is accepted;
- it should add that the crash case leaves the container running (inferred).
- Commits: 2, both
Signed-off-by(measured). No attribution trailers (measured). - Scope:
- no change to
package.json,package-lock.json,src/or.claude/(measured); - no new dependencies;
- nothing beyond slice 4 except the README's owner-steps list and step 5's redo wording. Both serve this slice.
- no change to
- Owner's real state: the tests use a temp HOME, a stand-in docker and
PATH=/nonexistentfor the CLI, so they can't touch real state (inferred from reading the test file and the isolate preload it requires). git merge-tree --write-tree origin/main HEAD: clean, tree19acdf82(measured).- CI at the head: run 39545411032 has headSha 54894eb,
completed/success. Every required job passes and the systemd jobs are skipped (measured,gh pr checks 947). Not rerun.
Required changes
- Bound the copy-out (principle 1).
- Refuse, cleanly, a copy-out whose stdout passes a fixed byte cap, and remove the container. The cap goes in the exec path that
copyOutuses, or in aTaskRunneroption. Today 256 MiB is taken whole and 600 MiB crashes the process with the container left running. - Before the clone checkout, refuse a pack or tree whose decoded size, or total object size, passes a cap. For example, sum
cat-file --batch-check='%(objectsize)'over what the remote holds. - Add a test for each: a stand-in docker that prints past the cap is refused and its container removed; a small pack that inflates past the tree cap is refused with nothing kept.
- Refuse, cleanly, a copy-out whose stdout passes a fixed byte cap, and remove the container. The cap goes in the exec path that
- Content mode fails closed (principle 1).
- An image with no
/testbed/.gitcurrently gets PASS with a tree nothing binds to base_commit. Taking that path takes nothing more than deleting.git. - SWE-bench's own images keep
/testbedas a git checkout, since its harness runs git there (inferred). - Refuse content mode by default (FAIL at that key, nothing kept), with a test.
- If a fallback is wanted, it is a new loosening and the owner must decide it; this PR shouldn't ship it as a PASS.
- An image with no
- Deny the journal to the agents, or measure that it holds no image name.
- This PR is the first to hand task image names to the bench user's dockerd.
benchDenied()doesn't cover the journal (measured), and the README points atjournalctl --user -u dockerfor failures. - Fail-closed fix, a tightening: add
/var/log/journaland/run/log/journaltobenchDenied, with a test. - Alternatively, state as an open gap in the README and the PR body that this is unmeasured, for the owner to decide before the first real
bench pull.
- This PR is the first to hand task image names to the bench user's dockerd.
- Correct the "Compromised component" huge-pack line to match the measurement above: no size bound, and a crash that leaves the container. If change 1 lands, describe the cap instead.
Non-blocking:
- the README could say that submodule contents are missing (gitlinks become empty folders);
container.tsIMAGEcould refuse tags onrun.
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…adata, and the lane denies the journal Review round 2 of #947 (principle 1, fail closed). The copy-out had no bound: 256 MiB of stdout was taken whole, and 600 MiB crashed the process in the async docker runner's close handler, skipping the container's removal. TaskRunner.exec now takes a byte cap: the async runner ends the CLI at the first byte past it and keeps nothing (OutputTooLarge), and a result too long for one string is refused instead of thrown in an event handler. bench pull caps the probe (64 KiB) and the pack (384 MiB of base64), and caps what the pack decodes to (1 GiB, every object summed) before anything is fetched or checked out: a 261 KB pack had become a 256 MiB file. Past a cap the task is refused, nothing kept, the container removed. An image with no /testbed/.git is now refused: the content-hash path bound nothing to base_commit, and an image took it just by having no .git. A fallback would be a loosening for the owner. A kept tree's record must be the git, HEAD-checked kind. bench pull is the first command that hands task image names to the bench user's dockerd, which logs to the system journal; the agents' lane now denies /var/log/journal and /run/log/journal, and the README's log hint says to read it on screen only. baseTree also refuses a task image that isn't its own pinned digest reference before docker run. The bench pull tests share their fixtures (test/bench-pull-fixtures.ts). Test (fails without): test/bench-pull-bounds.test.ts (7 of 7 fail at 54894eb, each on behaviour). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2: head e5dfe1e (main d2a549f merged in)All four required changes are made, plus both non-blocking notes. The PR body's "Round 2" section has the detail.
Fail-without (measured): After (measured):
|
Evaluator verdict: CHANGES NEEDED (delta final check, head e5dfe1e)Reviewed head 1. The findings of the last verdict
2. Fail-without (re-measured)
3. Weakening gate: changed or removed existing test lines
No other existing test was changed. The other test files in the two-dot diff come from the merge of main. The merge commit 4. Other callers of
|
…the caps before git indexes it Review round 3 of #947. The decoded-size cap ran after git index-pack, which builds each object in memory: a crafted 1,115-byte delta pack made it peak at 1.58 GB before any check (measured in review). packDeclaredBytes walks the pack itself before index-pack sees it: the signature, version and count, each entry's type and size, a delta's target size from its delta header, each entry's zlib data inflated once (never past its declared size) to find where it ends, and nothing after the last entry but the trailer. A whole object or delta target over 256 MiB, or a total over 1 GiB, is refused, nothing kept, the container removed; so is any pack that isn't well formed. With crafted packs under the caps, index-pack peaked at 255 MiB for side-by-side deltas and 1005 MiB for a chain declaring 1001 MiB (measured), so its peak tracks the total cap. The README and the release note say what is checked before index-pack and the measured peaks, including bench pull's own (about 1.5 GB just under the copy-out cap, measured in review). The probe test again checks that a /testbed with no git metadata is unchanged by the probe. Test (fails without): test/bench-pull-pack.test.ts (5 of 5 fail at e5dfe1e: no pack walk, a delta bomb reaching index-pack, the README). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 3: head 92a8de8
Fail-without (measured): After (measured):
|
Evaluator verdict: CHANGES NEEDED (delta final check, head 92a8de8)I reviewed head The round's fix works for the shape that was reported: the 1,115-byte delta bomb is now refused before git runs, and the walker is solid against malformed packs. But the new claims about memory and time under the caps don't hold:
All probes ran in scratch only: a stand-in docker binary, temp HOME, TMPDIR and store, and host git in temp folders. No real docker, no network. 1. The finding of the last verdict, and the walker
2. Corrected claims vs measurement
3. Restored check and weakening gate
4. Fail-without (re-measured)
5. Other checks
Required changes
Non-blocking:
Probes (scratch only): |
…, and runs host git on one thread Review of round 3 measured three gaps under the caps. A delta's own data counted nothing, so a 300 MB pack of zero delta data took the walk 302.6 s, and 33 valid deltas of 248 MiB data each (12.5 MB pack) passed the walk and made index-pack peak at 0.86 to 1.96 GB with git's default threads on 16 CPUs (3.27 GB with 16, 283 MiB with one). The builder also measured 18 million 4-byte blobs (a 288 MB pack): 49.7 s in the walk, 1.41 GB in index-pack with one thread. The walk (packDeclaredBytes) now charges one budget, TREE_CAPS.treeBytes: 1 KiB an entry (the count is charged before any entry is read), the bytes each entry's data inflates to, a delta's own data included, and each delta's target. Each charge is made before the data is inflated, so the walk never inflates past the budget. Data over 1 MiB is inflated as a stream and dropped, keeping only its first 32 bytes. The walk also checks the trailer's SHA-1 and that an OFS_DELTA's base starts inside the pack. It now runs in baseTree, before anything is made in the store. Host git runs with pack.threads=1, and index-pack with --threads=1. The README's step 10 and the changelog fragment are corrected with the measured figures. Test (fails without): test/bench-pull-budget.test.ts, written as a new file in a worktree at 92a8de8: 6 tests, 5 fail (many valid large deltas not refused; tiny entries not refused; a wrong trailer taken; index-pack run without --threads=1; the README's round-3 claims), 1 passes (the streamed path's size checks, kept as coverage). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (delta final check, head 410ef53)I reviewed head The round fixes all three findings of the round-3 delta. The walk now has one budget, charged before anything is inflated; host git runs on one thread; and the
All probes ran in scratch only: an in-process stand-in docker (the review's 1. The round-3 findings
2. Hunting for an admitted pack past the stated time or memoryMy packs are in
What this shows:
3. Changed lines in this PR's own
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ime claims match what was measured The no-wall-clock-durations guard (#582) flagged two elapsed times read off Date.now() in this PR's new tests (bench-pull-budget and bench-pull-pack), which turned CI shard 3/4 red. They now use performance.now(), which an NTP step can't move. The guard's ALLOWED list is unchanged. Review of round 4 measured a 900,000-link chain of tiny deltas (a 14.4 MB pack) taking the walk 3.5 to 4.6 s, over the README's "at most about 3 s", and 19 chained 50 MiB deltas (51 KB) making index-pack --threads=1 rebuild bases for 18.6 s at 106 MB. README step 10 and packDeclaredBytes' doc now give those figures: the walk reads at most a million entries and inflates at most 1 GiB, a bound in seconds is inferred, and index-pack's memory follows the budget while its time is bounded by the 30-minute git timeout. Test (fails without): test/no-wall-clock-durations.test.ts, red in CI on 410ef53 (shard 3/4, Node 22 and 24). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (delta final check, head 1f30402; PR body only)I reviewed head The code is done. CI is green, the guard passes with its All probes ran in scratch only: temp HOME, TMPDIR, state and store, with stand-ins (exit 1) first on PATH. Host git ran only in temp folders. No real docker, no network pulls, and the real bench store was never touched. 1. The round-4 findings
2. Figures re-measured on the round-4 packs, in place (no copies)I ran the round-4 evaluator's
3. The merge commit
4. Fail-without (re-measured)
5. Weakening gate: changed test lines this roundThis round changes lines in two test files. Both are new against main, so no line of main's tests changes (measured,
None is loosened. A cosmetic nit: 6. Other checks
Required changes
Non-blocking:
Probes (scratch only): |
Evaluator verdict: PASS (delta final check follow-up, head 1f30402)The head is still
Non-blocking, no new head needed:
No required changes remain. |
No conflict. #947 brought scripts/bench/pull.ts's git helper, a raw execFileSync('git', [...GIT_HARDENED, ...args], { env: GIT_ENV }) that the runner scan refuses (measured on this merge before the fix: "every git call in src/ and scripts/ goes through the runner" fails with "scripts/bench/pull.ts: starts git 1 time(s) (not on the list)"). It now runs through gitExecFileSync with both lists kept: GIT_HARDENED as arguments after the runner's, and GIT_ENV as the env, over which the runner sets its own settings last (gitExecFileSync applies gitEnv). Not added to OUTSIDE_THE_RUNNER, which is never-grow. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
main brought #947 and #899. One conflict, in src/review.ts: the HARNESS_CONFIG block from #899 (its JSDoc, HARNESS_CONFIG and the never-shrink HARNESS_CONFIG_GLOBS) and this PR's decision-record block (DECISION_FILES, DECISION_DIRS, DECISION_RECORD_GLOBS and onePartPerGlob) were both added after hidesSubmodules. Both are kept whole: this PR's block first, then the one from #899, then SELF_MODIFICATION as before. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: the rest of slice 4 of #748, now based on
main(it was stacked on #929, which has merged).bench pull --tasks smoke|baselinepulls that task set's images by their pinned digests, never a tag, and reads each digest back. It then puts each task's base tree in the held-out store: a bare remote with one commit (no upstream history) and a clone of it. The tree is copied out of the task's own image's/testbedthrough the bench's container runner, with no upstream fetch. The README gets step 10 and an ordered list of the owner's steps; a test holds each block equal to its setup step.Risk class: protected. It touches
scripts/**(scripts/bench/, L3 per #748: the owner merges). It changes nosrc/file.Part of #748. #929 has merged; the base is
mainnow, andmainis merged in (d2a549f).Round 5 (head 1f30402): what changed for the round-4 delta
no-wall-clock-durationsguard (Clocks: dashboard listing caches use a monotonic injectable clock, so a wall-clock step cannot keep or cut a cache window; lint new Date.now() durations in tests (v0.5.3) #582) flaggedDate.now()durations intest/bench-pull-budget.test.tsandtest/bench-pull-pack.test.ts. Both now useperformance.now(). The guard'sALLOWEDlist is unchanged.packDeclaredBytesdoc comment and README step 10 now say thatindex-pack's memory follows the budget, but its time doesn't. 19 chained 50 MiB deltas, a 51 KB pack, took it 18.6 s at 106,152 KiB, about 109 MB (measured in review). Its time is bounded by the 30-minute git timeout.no-wall-clock-durations:# tests 100,# pass 100;1421 files clean; DCO all signed off.Round 4 (head 410ef53): what changed for the third CHANGES NEEDED verdict
The round-1 to round-3 builder wasn't reachable, so I continued its work. This round is one commit, 410ef53.
packDeclaredByteschargesTREE_CAPS.treeBytes(1 GiB) with:ENTRY_COST). The whole count is charged before any entry is read;index-pack's, are bounded by the budget. Corrected in round 5:index-pack's time is not (18.6 s for 19 chained 50 MiB deltas in review); the 30-minute git timeout bounds it.baseTree, before anything is made in the store. It returns{ decoded, spent }.GIT_HARDENEDadds-c pack.threads=1to every host git, andindex-packruns with--threads=1.fetch's ownindex-packandpack-objectsgetpack.threads=1too (inferred: git passes-csettings to the git processes it starts).index-pack --threads=1peaked at 1,409,888 KB (measured).bench pull's own peak is stated, not lowered.A, which the walk refuses at once (1,511 and 1,894 MiB), as for the review's well-formed 2×143 MiB pack (1,509, 1,892, 1,891 and 1,892 MiB).container.ts). It made no measurable difference (1,891 and 1,509 MiB with it), so I reverted it. Lowering the peak needs a streamed copy-out (see "What remains").--threads=1.Measured (scratch probes, stand-ins only; git 2.43.0, 16 CPUs, Node 22.23.2). First, the review's packs and mine:
dd-1150(300,070,794 B: 1,150 deltas of 256 MiB zero data), the walkbaseTreecopy8-33x31(12,501,787 B: 33 valid deltas of 248 MiB data each), the walkbaseTreepack-dThen shapes the walk admits, against what it charges:
index-pack --threads=1peakindex-pack's highest measured peak is 756 MiB, for 752 MiB charged. On these shapes it stayed near or under the charge, so it is at most about the 1 GiB budget (inferred for other shapes).bench pullandindex-packtogether, on the near-both-caps pack: sampled every 0.1 s, at most 1,746 MiB (node 1,050 + git 696) over 2 runs (measured; a short spike could fall between samples).Round 3 (head 92a8de8): what changed for the second CHANGES NEEDED verdict
index-pack's memory is bounded by design.pull.tspackDeclaredByteswalks the pack beforegit index-packsees it. It reads:TREE_CAPS.objectBytes), a total over 1 GiB, and any pack that isn't well formed. Nothing is kept and the container is removed.pack-d.pack). It used to makeindex-packpeak at 1.58 GB.index-packpeaked at 255 MiB for one or four side-by-side 250 MiB deltas, and at 1005 MiB for a chain of four (1001 MiB declared). Its peak tracks the 1 GiB total cap. Wrong, corrected in round 4: with git's default threads it grew with the host's CPUs.index-pack, stays as a second check.bench pullat about 1.5 GB just under the copy-out cap (measured in review; wrong: 1,891 MiB in review, 1,509 to 1,894 MiB in round 4), and the measuredindex-packpeaks;index-packand gives the measured peaks.Sentand the exec path, and isn't cheap.snapshot(plain)check after the probe is restored;Round 2 (head e5dfe1e): what changed for the CHANGES NEEDED verdict
TaskRunner.exectakes a byte cap (stdout and stderr together). The async docker runner ends the CLI (SIGKILL) at the first byte past it and keeps nothing; the exec throwsOutputTooLarge. This is checked after a stand-incalltoo.closehandler. It is refused, so the caller'sfinally(the container's removal) always runs.bench pullcaps:cat-file --batch-all-objectssizes, summed), checked before anything is fetched or checked out./testbed/.gitis refused (FAIL at that key, nothing copied out, container removed; measured). A kept tree's record must be the git, HEAD-checked kind; an older content record is refused (measured).benchDeniednow has/var/log/journaland/run/log/journal(measured, test). A tightening.baseTreerefuses a task image that isn't its own pinned digest reference (<name>@sha256:<64 hex>frompins.ts) beforedocker run. I didn't changecontainer.ts'sIMAGEcheck itself:test/bench.test.tsfixtures runswebench/example:latest, and the driver's own path takes images only fromloadTasks(inferred).The
bench pulltests now share their fixtures (test/bench-pull-fixtures.ts). The content-mode PASS test is replaced by the refusal test.What remains (proposals, not in this PR)
/testbed/.git: a loosening, for the owner to decide. It would bind nothing tobase_commit.container.tsIMAGErequiring a digest for everyrun: that needs the driver fixtures intest/bench.test.tschanged to digest references. Slice 7 (bench run) is the natural place.caps.tscapsFromArgsdefaulting--tasksto smoke, whilebench pullrequires it: settle whenbench runlands.bench run's commands. Give everyTaskRunner.execthatbench run's task commands make (the test relay,syncTree) a byte cap. Uncapped output is still buffered whole: 600 MiB peaked at 1,273 MiB (measured in review).bench pull's peak of 1,509 to 1,894 MiB just under the cap (measured, round 4).What changed
scripts/bench/pull.ts(new).pullTasks: the tasks come fromloadTasks(store only). Each image goes throughpullImage, which sends onlydocker pull <name>@sha256:<digest>and thenimage inspectto read the digest back. ThenloadTasksruns again, and the command refuses ifimages.jsonchanged during the pulls. Refusals name the key only.baseTree: one throwaway container per task, throughDocker.start(run --network none --cap-drop ALL, no mount, limits read back) andTaskRunner.exec(docker execonly)./testbed: HEAD must bebase_commit. The pack holds base_commit's own commit object (none of its parents) and exactly its tree's objects. On the host, that commit must hash tobase_commitand name the tree, so the tree is bound by hash, not by the container's word.index-pack(which hashes every object), one root commit with a fixed author and date, and a fresh bare remote thatfetches only that commit. That remote must hold exactly the reachable objects (count checked) and passfsck --connectivity-only. Then a clone withoriginset to the bare remote, and a 0600tree.json. Everything is built in a temporary folder and renamed totrees/<key>/, never over anything.GIT_CONFIG_GLOBAL=/dev/null,NOSYSTEM), with hooks off, fsmonitor off, andprotocol.allow=neverexceptfile.scripts/bench/download.ts:pullImagenow throwsPullRefused(stagepullorread back, plus the exit status). Its messages are unchanged. This letsbench pullreport a failure without docker's stderr, which names the image.scripts/bench/cli.ts:bench pulltakes exactly--tasks smokeor--tasks baseline. Anything else, including no flag, exits 2 before any docker call.bench images"next" line are updated.scripts/bench/README.md:.oldbreaks the second time. It is redone after everypins.jsonchange, because the bench reads its pins from that clone.changelog.d/947-bench-pull-base-trees.md: the release note, under "Guardrail changes: what each one does". The changelog.d backstop intest/changelog.test.tspasses 1/1 andtest/changelog-fragments-selfcheck.test.tspasses 2/2 (measured). I didn't runtest/changelog-fragments.test.tslocally: it spawnsrelease.mjswith the host PATH, so CI runs it.Design choices (inferred; no new loosening)
--tasksis required, with no default. The brief said to default to smoke only if the README's smoke plan says so. The plan writesbench run --tasks smokewith the flag, so I failed closed. Note:caps.tscapsFromArgs(forbench run, still to come) defaults--taskstosmoke. That's a mismatch to settle whenbench runlands; this PR does not changecaps.ts.trees/<key>/), not underruns/. The store is denied to the agents' sandbox, and 50 base trees would reveal the task set.bench run(still to come) copies one task's tree into that task's folder./testbed's work tree. An image's setup may edit tracked files; the stand-in/testbedtest covers that case.Open gap found while writing the owner's steps (not built here; needs a decision)
No command collects the image digests.
pins.jsonimage_digestsneeds at least 100 digests, including every selected task's, and nothing in the repo produces them. Typing image names would put instance ids into the bench user's shell history. Sobench imagesandbench pullcannot run on the real machine until this is decided. One option is abench digestscommand that runs thebench imagesregistry lookups over every row of the pinned dataset and prints only the sorted set. The README now says this in step 7 and in the owner's step I.How it was verified
Round 4 (head 410ef53)
test/bench-pull-budget.test.tswith the Write tool as a NEW file in a fresh worktree at 92a8de8. It is byte-identical to the head's (sha256eff52ce0…).node --import ./dist/test/isolate.js --test dist/test/bench-pull-budget.test.js(temp HOME, TMPDIR and state, stand-ins first on PATH) gave# tests 6,# pass 1,# fail 5:Missing expected rejection.The round-3 walk inflated about 8 GiB in 8.6 s and took the pack;Missing expected rejection.;Missing expected rejection: a wrong trailer;index-pack on one thread. The argv ended… index-pack --stdin;pack.threads=1.# tests 98,# pass 98;bench.test.ts, by name: 1/1;changelog.test.tsandchangelog-fragments-selfcheck.test.ts: 29/29;1415 files clean; DCO all signed off.git merge-tree --write-tree origin/main HEADagainst main 9f9533a is clean (treeb0f6d339). I didn't merge main in; the brief didn't ask for it.test/bench.test.tsand the full suite. CI runs both.Round 3 (head 92a8de8)
test/bench-pull-pack.test.tsfirst, as a NEW file in a fresh worktree at e5dfe1e, byte-identical to the head's (sha256921b6559…). There,node --import ./dist/test/isolate.js --test dist/test/bench-pull-pack.test.jsgave# tests 5,# fail 5:typeof packDeclaredByteswas'undefined';bench pull: no "declares … before it is indexed" refusal;index-pack".# tests 92,# pass 92;bench.test.ts: the round-7 scan and six container tests, 7/7;1414 files clean; DCO all signed off./usr/bin/time -f %M git index-pack --stdinon crafted packs: 255 MiB for 1×250 MiB, 255 MiB for 4×250 MiB side by side (not reproduced in review: 1,030,716 KB), 1005 MiB for a chain of 4×250 MiB;pack-d.pack: refused in 2 ms.Round 2 (head e5dfe1e)
test/bench-pull-bounds.test.tsandtest/bench-pull-fixtures.tsfirst, as NEW files in a fresh worktree at 54894eb. Both are byte-identical to this head's (sha256e1b24dfe…and860faae6…). There,node --import ./dist/test/isolate.js --test dist/test/bench-pull-bounds.test.jsgave# tests 7,# pass 0,# fail 7, each on behaviour, not on a missing module:TaskRunner.exec: missing expected rejection;.git: exit 0;false;/var/log/journal.# tests 87,# pass 87;bench.test.tscontainer tests by name (the task runner sends docker exec…, limits, rootless, daemon, Read denials): 6/6;1413 files clean; DCO all signed off.TaskRunner.execagainst a stand-in docker script that prints N zero bytes:Round 1
test/bench-pull.test.tsfirst, then ran it at Bench: bench images writes each task's pinned image reference from the store, never typed (#748 slice 4) [PROTECTED: owner merges] #929's head 49b01df. That ran in a fresh worktree with only that new file added, byte-identical to this PR's (same sha256763170ef…). Result:# tests 17,# pass 0,# fail 17. Of those, 14 fail onpull is not a function, 1 onCannot find module …/dist/scripts/bench/pull.js, 1 on the help text (nopullcommand), and 1 onthe README has the owner steps.node --import ./dist/test/isolate.js --test dist/test/bench-pull.test.js dist/test/bench-images.test.js dist/test/bench-select.test.js dist/test/bench-select-lookalike.test.js dist/test/bench-cli.test.js dist/test/bench-pins.test.jsgave# tests 81,# pass 81,# fail 0.--test-name-pattern="round 7: no raw format"ondist/test/bench.test.js): 1/1 pass;merge-policy.test.ts"weakening backstop": 2/2 pass;npm run typecheck: clean;npm run check:denylist:1400 files clean;node scripts/check-dco.mjs origin/bench-slice4-images HEAD: all signed off.test/bench.test.tsand the full suite. CI runs both.example__proj-NNNN) and a stand-in dockercall. No daemon, image, network, model or real store;gitandshrun only on temp fixture repos, with no config of their own;shon a stand-in/testbed. A snapshot of every path's size and mtime shows they change nothing there.New dependencies
None.
Package size
0 bytes of delta: measured at 49b01df in round 1, inferred since.
npm pack --dry-run --jsongivessize1481905,unpackedSize4963126,entryCount493, the same as at 92a8de8 (measured).dist/scripts,scripts/bench,test/orchangelog.d(count 0, measured).Per file, in lines (measured,
git diff --numstat origin/main...HEADat 410ef53):scripts/bench/pull.tsscripts/bench/README.mdscripts/bench/cli.tsscripts/bench/container.tsscripts/bench/download.tsscripts/bench/agent.tschangelog.d/947-bench-pull-base-trees.mdtest/bench-pull.test.tstest/bench-pull-fixtures.tstest/bench-pull-pack.test.tstest/bench-pull-budget.test.tstest/bench-pull-bounds.test.tsWhat this can never do
pins.tspinnedImagebuilt reachespullImage, and its argv check refuses anything butpull <name>@sha256:<64 hex>. Every pulled argv is asserted in the tests (measured).git fetch, clone or network goes to anything but a local path:protocol.allow=never,fileonly.--network nonecontainer (measured: argv; inferred: no route in a real container).fetch, and must hold exactly its reachable objects (measured: a full-history pack leaves no parent, old blob or upstream commit in the remote or the clone).base_commitand name the tree (measured: a pack with the right tree but no commit is refused).__,_1776_,sweb.evalor[task withheld](measured across 7 scenarios).Compromised component
tree.jsonlive, and it has no docker (no socket, noDOCKER_*);bench pullruns before any agent run (inferred: no driver code calls it yet)./testbedand the container'sgit, and could lie about HEAD;base_commitby hash, so a lie is refused. Faking it would need a SHA-1 collision on a chosen commit (inferred);.gitit is refused (round 2; measured);bench pull's own memory (measured in review):git index-packsees it, against one budget (rounds 3 and 4).index-packpeak at 1.58 GB. That pack is now refused before git runs (measured, on the evaluator's own pack file and in a test).index-packpeak at up to 1.96 GB. Both are now refused in under 0.5 s (measured, round 4).pack.threads=1,index-pack --threads=1);index-pack's highest peak was 773,828 KB, for a delta the walk charged 752 MiB. At most about the 1 GiB budget (inferred for other shapes);index-pack's CPU time is bounded by the 30-minute git timeout;bench pull's copy-out, before git runs (measured).bench pullandindex-packtogether, on a pack near both caps, sampled at most 1,746 MiB (measured)./var/log/journaland/run/log/journal;docker pullby digest, and the digest is read back (as in Bench: pinned dataset and image downloads, verified before use (#748 slice 2) [PROTECTED: owner merges] #863).scripts/**change, and the owner merges.scripts/bench(inferred: nothing undersrc/does).Adversarial pass
Each attack runs as a fixture test, and each result is measured.
bench pull,--tasks,--tasks all,--tasks constructor,--tasks=smoke, an extra argument, an instance id, an image name__in the outputimages.jsonmissing, an unpinned digest, a tag, another task's imagedocker pullfails, with stderr naming the imageimages.jsonrewritten during the pulls (another pinned digest under the same name, whichloadTasksalone would take)base_committrees/<key>is a link;trees/itself is a link/testbedwith a dirty work tree/testbedunchanged/testbed/.gitname:latest, a bare name, orname:latest@digestdocker run/var/log/journaland/run/log/journalbench pulltreeBytes; one object overobjectBytes; a delta whose target is over (its own data is tiny)cat-file --batch-all-objects)dd-1150andcopy8-33x31packsbaseTreein 3.0 s and 0.5 s; container removed, nothing keptindex-pack --threads=1at most 773,828 KB; the walk at most 2.63 sbench pullruns, recorded through a wrapper-c pack.threads=1;index-packhas--threads=1(test)What I changed because of it:
index-pack's peak track the total (1005 MiB for 1001 MiB declared), so the total cap, not just the per-object cap, is what bounds it.container.tsthat measured as no change.Not defended:
bench pullpeaks at up to 1,894 MiB just under the copy-out cap, in the copy-out (buffers, a string and the pack at once).index-pack --threads=1peaked at most at 773,828 KB on the shapes tried, about what the walk charged (at most about the 1 GiB budget, inferred). Streaming the copy-out, or lower caps, would lower the first;git,sh,printfandbase64in the image. A missing tool fails closed;Checks removed or weakened
No check, lint or guard is removed or loosened. These test assertions were removed or replaced, in this PR's own new test file:
test/bench-pull.test.ts:no git metadata in /testbed: its content hash is recorded instead …expected PASS for a content-mode image. It is replaced by a refusal test (test/bench-pull-bounds.test.ts: FAIL, nothing kept, nothing copied out, container removed). That is a tightening, and the round-1 verdict required it;CONTAINER_SCRIPTS.contentassertions in the in-container scripts test were removed: the code they tested is deleted;assert.deepEqual(snapshot(plain), plainBefore)was dropped. Restored in round 3, as a check that the probe leaves a/testbedwith no git metadata unchanged;test/bench-pull-fixtures.tswith no assertion changed. The stand-in now strips a:tagwhen it looks up an image name, so it can answer the tag-image test instead of crashing.test/bench-pull-pack.test.tsis additive.test/bench-pull-pack.test.ts:.decoded;spent) instead of git's decoded total, because the budget now charges more than the decoded total. One byte under is still refused, anddecodedis still checked against git's own sizes;test/bench-pull-bounds.test.ts(/decode.*over N bytes/) still matches it, unchanged.no-wall-clock-durationsand itsALLOWEDlist are unchanged. Test-line changes in this PR's own files:test/bench-pull-budget.test.ts: the timing of the large-deltas refusal readsperformance.now()instead ofDate.now(), and its elapsed ms is rounded withMath.roundfor the message. The threshold is unchanged (under 20 s);test/bench-pull-pack.test.ts: the delta-bomb timing readsperformance.now()instead ofDate.now(). The threshold is unchanged (under 30 s);WHYcomment naming Clocks: dashboard listing caches use a monotonic injectable clock, so a wall-clock step cannot keep or cut a cache window; lint new Date.now() durations in tests (v0.5.3) #582.Owner action
Review. The owner's one-time steps, from creating
wl-benchtobench pull, are in the README section "The owner's steps, in order". Step I (the image digests) is blocked on the decision above.