diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b984bdbb1..3563edb79 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5856,3 +5856,25 @@ sourced() that a GREEN-time case now pins; GREEN 105 passed, 0 failed (20:02:40Z of 30 killed, runner exit 0 (20:02:56Z to 20:06:58Z, base green). No 64-hex test pin names a changed path. Not pushed. Gaps: lexical false BUSY and missed runtime-built sourced paths, and an undetected bind-mounted hooks directory; details in agents/darkwing/work/queue-211/BUILD.md, section "R4". + +## 2026-11-02T23:53:07Z: row 211 (#1709, #426) R5, busy check listing, quoting and spacing + +Before: row 211 R4 (fd1aa8cc8 on `t3/row211-push-guard-r4-fix`, over 09a847eda). The GPT review of R4 +(row211-r4-gpt.md) found that gone() parsed `ls -f`, whose names follow `QUOTING_STYLE`, so under +`shell-always` or `c` a failed read of a listed pid became CLEAR (N1). It also found that seven of 13 live +shell-snapshot wrappers that source a test, with no child, gave CLEAR: a quoted path with spaces, a quoted +`source`, `;` with no blank, a quoted snapshot, a tab and two spaces (N2). And the R4 bind-mount note named +st_dev, which a same-filesystem bind mount keeps (N3). + +After: two R5 commits by the lead's implementation sub-agent, code and tests, then records. gone() lists with +`ls -f -1 --quoting-style=literal`, the script exports `LC_ALL=C`, and a failed listing is still never gone. +A command line holding quotes, backslashes, shell operators or uneven whitespace gets a flat form (awk). The +snapshot rule reads the flat form, the general rules read it and then the raw text, and a wrapper's text goes +through an awk word walk. In the walk, every word of each `source` or `.` span counts, and quoted text is read +again. Text the walk can't classify (over 32 levels, an awk failure) is exit 2. test-git-guard.sh: RED 119 +passed, 34 failed (23:32:37Z to 23:33:12Z); GREEN 153 passed, 0 failed (23:33:23Z to 23:33:35Z). Mutants 57 +of 57 killed, runner exit 0 (23:33:53Z to 23:46:34Z, base green). Live probes of the 13 shapes +(23:46:56Z to 23:47:18Z): R4 CLEAR on 7 to 13, R5 BUSY on all 13. No 64-hex test pin names a changed path. +Not pushed. Gaps: new lexical false BUSY (span arguments, redirects, `find .`, parentheses), still-missed +runtime-built and ANSI-C sourced paths, and an undetected bind-mounted hooks directory; details in +agents/darkwing/work/queue-211/BUILD.md, section "R5". diff --git a/agents/darkwing/work/queue-211/BUILD.md b/agents/darkwing/work/queue-211/BUILD.md index 70edd16b6..09d676ac4 100644 --- a/agents/darkwing/work/queue-211/BUILD.md +++ b/agents/darkwing/work/queue-211/BUILD.md @@ -571,3 +571,384 @@ The runner cuts each first failing case at 100 characters, which is why three of output lines. Chain on the exit code, never on the lines (unchanged advice). - The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and can't make it BUSY. This host's `/proc` has no hidepid option. + +## R5, the GPT review of R4 (2026-11-02) + +The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from +the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r5-fix.md` and the review +`/mnt/storage/scratch/mosaic-lead/reviews/row211-r4-gpt.md` (N1 to N3, the 13 live shapes in Check 2, and +Check 6). Base 09a847eda, branch `t3/row211-push-guard-r5-fix` from fd1aa8cc8, then one R5 code commit and +one record commit. Only `slot-busy.sh`, the suite, the mutant runner and the record files change; the hook +and the installer are untouched. The suite goes from 105 cases to 153 and the mutants from 30 to 57. The +sections above stay as written; where R5 changes them, it says so here. + +### N1: a listing that reads the same in any environment + +Was: `gone()` ran `ls -f -- ROOT` and looked for the pid among the names. GNU ls reads `QUOTING_STYLE`. With +`shell-always` or `c` it prints a present pid as `'4194430'` or `"4194430"`, the name check misses it, and +the pid reads as gone. A failed cmdline read and a failed ancestor read then both ended in CLEAR. + +Fix: `ls -f -1 --quoting-style=literal -- ROOT`. The flag overrides the variable, and `-1` asks for one name +per line. R2's rule is unchanged: a failed or denied listing returns 1, never gone. The script also exports +`LC_ALL=C` before it runs any tool, for the locale finding below. + +I checked every other tool the script runs or parses, on this host (GNU ls 9.11, GNU Awk 5.4.1, bash +5.3.15): + +- ls. `shell-always` and `shell-escape-always` print `'N'`, `c` prints `"N"`, and `locale` prints the + locale's quote marks around N. `escape`, `shell`, `shell-escape` and `literal` print a digits-only name + bare, so the brief's `escape` control passes on R4 too and stays as a control. `LS_COLORS`, `CLICOLOR`, + `CLICOLOR_FORCE`, `COLUMNS`, `TABSIZE` and `POSIXLY_CORRECT` don't change the output to a pipe. To a pipe, + `-1` changes nothing either, so no test can pin it; it states the format. BSD and BusyBox ls reject + `--quoting-style`, so on such a host every listing fails and every failed read is exit 2. +- awk. `AWKPATH`, `GAWK_READ_TIMEOUT=1`, `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` left the status read's output + as it was. `GAWK_PERSIST_FILE` naming a missing file makes gawk exit 2 with a fatal error. Every awk call + in the script checks its exit status, so that ends in exit 2, never CLEAR. +- tr. It maps bytes; `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` gave the same bytes, 0xff included. +- readlink only feeds the cwd field of a BUSY line, and it prints the link as it is. `sed` runs only for + `--help`. +- bash itself. bash doesn't take `IFS` from the environment. `GLOBIGNORE` had no effect on the pid glob. + `SHELLOPTS=noglob` (exported) turns the glob into its literal, which fails the numeric test, so nothing is + seen and the script exits 2, "no processes". Exported functions, `BASH_ENV` and `PATH` let a caller put + its own code in place of `ls`, `awk` or `tr`; the suite's shims rely on exactly that. The check can't + defend against its own caller, and that stays a stated limit. +- Locale. Under a UTF-8 locale, bash's `[^ /]*` in `RE_TEST` stops at a byte that isn't valid UTF-8, so + `bash scripts/test-<0xff>x.sh` didn't match. `export LC_ALL=C` makes every regex and every tool read bytes. + This host has `C.utf8`, so that test isn't vacuous here. + +Tests, each RED on R4 except the `escape` pair: a loop over `QUOTING_STYLE` = `shell-always`, `c`, `escape`, +`shell-escape-always` and `locale`, each with "QUOTING_STYLE=X: a failing cmdline read with the pid still +listed: exit 2, no CLEAR" (the `failtr` shim) and "QUOTING_STYLE=X: a failing ancestor status read with the +pid still listed: exit 2, no CLEAR" (`awkn fail`, which now passes extra environment through `env`). "under +LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" pins the locale line. + +Mutants: `ls-env-quoting` (the `--quoting-style=literal` flag removed) and `locale-kept` (the `LC_ALL=C` line +removed). `denied-listing-gone` keeps its intent at the new line. All killed. + +### N2: quoting, spacing and operators no longer hide a sourced test + +Was: three regular expressions read the text with a fixed idea of spacing. `RE_SNAPSHOT` needed single +spaces and a space after `.sh`, `RE_SOURCED` cut a quoted path at its first blank and didn't allow a quote +around `source`, and the general rules needed a blank after `.sh`. Seven of the review's 13 live wrappers +gave CLEAR. + +Fix, in three parts. + +1. The flat form. When a command line holds a quote, a backslash, one of `; & | ( ) { } < >`, a backtick, + any whitespace other than a single space, or two spaces in a row, `flat()` rewrites it in awk: quotes + and backslashes removed, and each run of whitespace and those operators made one space. A command line + with none of them is its own flat form, so most of the host's command lines skip the awk call (550 of + 661 on this host). `flat()` prints an `F` before the text; a failed awk or output without the `F` is + exit 2. +2. The rules on both forms. `RE_SNAPSHOT` now runs on the flat form only, so a tab, two spaces, a quoted + snapshot path or `;` with no blank all reduce to the shape it expects. For any other process the general + rules run on the flat form first, then on the raw form. The flat form finds `bash scripts/test-x.sh;echo + done`, `docker compose` and `dockercompose`. The raw form keeps every R4 match, such as a script + named `gate;1.sh` passed as one argument, which the flat form splits. +3. The walk over a wrapper's text. `sourced()` is now an awk program that splits the wrapper's raw text + into words the way the shell does. A blank, a newline or one of `; & | ( ) < >` and backtick ends a word. + `'...'` and `"..."` are parts of a word, with the quotes removed. A backslash keeps the next character, + and `$'...'` and `$"..."` read as quotes. A span starts after a word that is `source` or `.`, quoted or + not, and runs to the next `;`, `&`, `|` or newline outside parentheses and backticks. Every word in the + span is checked, not only the first, against the gate rule, the `mutant` rule and the test-script rule, + each anchored at the word's end. So a quoted path with spaces is one word that still ends in + `/scripts/test-x.sh`, and `source 2>/dev/null T`, `source $(cd scripts && pwd)/test-x.sh` and the + backtick form match. Quoted text that holds a blank, an operator, a quote or a backslash is walked again + as shell text, which covers `eval '...'`, nested `bash -c` and the harness's `'\''` form. A span still + open at the end of quoted text stays open after the word. The first `shell-snapshots/snapshot-*.sh` word + in a span is the harness's own snapshot and is skipped whatever its name, and so is a later word equal + to it. + +What the walk can't classify: + +- An unbalanced quote isn't an error. The quote runs to the end of the text, and that text is walked again, + so a `source` span after it still counts and gives BUSY. An error there would turn every wrapper with a + stray apostrophe (`don't` in a heredoc, a commit message) into exit 2 and stall every gate on the host, + while reading on keeps the one property that matters: a named test, gate or mutant file in a source + argument is never CLEAR. The cost is a false BUSY when the quoted text only mentions a test. +- Quoting deeper than 32 walk levels is exit 2, "cannot classify the command line of PID". Each level of + escaped double quotes costs the walk about two levels, so 16 levels still read and 17 don't. Each level + walks its quoted text again, so the limit also bounds the time on a text that is megabytes long. +- A failed awk, or output that is neither a rule nor "none", is exit 2 with the same message. The scan goes + on, so other BUSY lines still print, and the final line says the host isn't known to be idle. + +The first version of the flat form was bash, `${s//x/y}` per character class. A fake proc root holding one +2 MB command line full of quotes then took 36 s to 135 s to scan, since each replacement costs time per +match. A busy check that takes minutes stalls every gate that waits on it. With the awk version the same +root takes 238 ms to 478 ms as a plain process and 320 ms to 1387 ms as a wrapper, over 12 fill patterns +(quotes, operators, blanks, backslashes, an unclosed quote, repeated `source` and `.` words). I checked the +awk output against the bash version's on 27 inputs. On the real `/proc` (975 pids) R5 took 1317 ms to +1349 ms against R4's 1168 ms to 1172 ms, both CLEAR at the time. + +Tests (all RED on R4 except the controls named here): + +- Rule cases on the wrapper shape, "sourced test script", RED on R4: `source 2>/dev/null T`, `source $(cd + scripts && pwd)/test-x.sh`, the backtick form, `source "/seat/a;b (c)/scripts/test-x.sh"`, `eval 'source + "/seat/a;b/..."'`, an `eval "..."` with escaped quotes around a path with spaces, the harness's + `eval 'source '\''scripts/test-x.sh'\'''` with `pwd -P >|`, and a heredoc holding `don't` before + `source "it's dir/scripts/test-x.sh"`. Two more pass on R4 and stay as controls: + `eval $'source scripts/test-x.sh'`, and an unclosed `"` before `source /seat/scripts/test-x.sh`. +- General rule cases: "test script" for `bash -c "bash scripts/test-x.sh;echo done"`, "docker compose" for + two spaces and for a tab between `docker` and `compose`, and "gate" for `bash "/seat/gate;1.sh"` (a + control for the raw form; it passes on R4). +- CLEAR cases: a wrapper whose own snapshot is named `snapshot-bash-3-gate.sh`, bare and quoted (controls + that pass on R4), and the same snapshot sourced twice (RED on R4, which read the second as a gate). +- Depth: "a sourced test under 16 escaped quoting levels (131094 bytes): exit 1, sourced test script" and + "quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR". +- awk failure: a shim that fails or prints nothing for one program only, keyed on the walk's `function + walk` and the flat form's `gsub`: "the wrapper walk's awk exits 1", "the wrapper walk's awk prints + nothing", "the flat form's awk exits 1" and "the flat form's awk prints nothing", each "exit 2, cannot + classify, no CLEAR". +- Live wrappers, one per shape in the review's table: a real `bash -c` in `$SANDBOX/wrap` whose sourced + file writes a ready marker and blocks in `read -t 60 -u 3` on a fifo. Each case checks the marker, an + empty children file, exit 1, no CLEAR and a BUSY line naming the wrapper's pid, the rule and its cwd, + through a proc root that links only that wrapper. Shapes 1 to 6 pass on R4 and must still pass; shapes 7 + to 13 fail on R4. Live shape 14 is a plain wrapper doing the same blocking read inline, which must stay + CLEAR. The suite kills and reaps each wrapper before the next. R4's external `node --test` child control + (F2, F5) is unchanged and still passes: exit 1 on the child's line, no line for its wrapper. + +Mutants, all killed: `quotes-kept`, `operators-kept`, `snapshot-single-space`, `flat-skipped`, +`flat-double-blank-missed`, `special-tab-missed`, `flat-failure-passes`, `flat-sentinel-unchecked`, +`raw-rules-dropped`, `flat-rules-dropped`, `source-arg-first-word`, `no-requote`, `quoted-span-reset`, +`span-ends-in-parens`, `backtick-ignored`, `sq-kept`, `dq-kept`, `dollar-quote-glued`, `backslash-kept`, +`snapshot-path-checked`, `snapshot-repeat-checked`, `depth-unbounded`, `awk-failure-none`, +`lexer-garbage-none` and `classify-error-passes`. Each edits one line once; the table below gives the site +by name. + +Sites R5 moved, same intent: `snapshot-matched` deletes the new snapshot line, `no-node-rule` and +`no-compose-rule` drop the rule from `rules()`, `snapshot-substring` turns the flat-form match into a +substring test, `denied-listing-gone` edits the new `ls` line, `sourced-test-hidden` makes the snapshot +branch return 1, `sourced-first-only` ends the walk at the end of the first span, and `dot-source-missed` +drops `.` from the span start. + +New false BUSY. Each one only delays a gate, and each was a choice in favor of never giving CLEAR while a +named file runs. I checked each example on a fake proc root holding that one process, with R4 and R5 +(the claims table under "R5 runs"). + +New in R5 (CLEAR on R4, BUSY on R5): + +- Every word in a span counts, not only the sourced file. So the file's arguments count + (`source env.sh scripts/test-x.sh`), and so do redirection targets (`source x 2>notes-mutant.log`) + and the words inside `$(...)` and backticks. +- A `.` word starts a span, so `find . -name 'test-*.sh'` is BUSY. +- A span doesn't end at `;`, `&` or `|` inside parentheses, `$(...)` or backticks, so + `( source x; cat scripts/test-x.sh )` is BUSY. That keeps `source $(cd scripts && pwd)/test-x.sh` one + span. An unbalanced `(` keeps the span open to the end. +- A span open at the end of quoted text stays open: `echo 'x; source' scripts/test-x.sh` is BUSY. +- The general rules now see the flat form in every process. So a shell's `-c` text that names a test, + gate or mutant file next to a quote or an operator is BUSY, where R4 needed a blank after `.sh`: + `bash -c 'cat scripts/test-x.sh|wc -l'`. + +Kept from R4 (BUSY on both): `grep -rn x . scripts/test-x.sh`, `git add . gate.sh`, +`grep -n source scripts/test-x.sh`, `echo source scripts/test-x.sh`, a commit message +`git commit -m "fix; source scripts/test-x.sh"` (also with the path in single quotes), a `#` comment line +naming one, the text after an unbalanced quote, and a second, different snapshot path whose name matches the +gate rule. R5 still reads quoted text again as shell text and doesn't know comments, so these stay BUSY. + +Two more, by reasoning, not tested: + +- Waiters can block each other. Take two wrappers that each run the check and whose text also names a + test in a source span. Each sees the other as BUSY until one gives up. R4 had the same risk with fewer + shapes. +- Text quoted more than 32 levels deep is exit 2 (that part is tested), which also holds a gate. + +### N3: a correction to the R4 limit on bind mounts + +R4's known limits say a mount-point test, "such as the hooks directory's st_dev against its parent's", +would catch a bind-mounted hooks directory. That is wrong for the case it names. A different st_dev finds +a mount of a different filesystem. A bind mount of a directory from the same filesystem keeps the device +number, so the comparison passes it. A detector would have to read mount metadata, such as +`/proc/self/mountinfo`, and look for a mount point at the hooks directory or between it and the +repository. None is built; the limit stands. The R4 text stays as written; this entry supersedes it. No +code changed for N3. + +### What didn't change + +`scripts/git-hooks/pre-push` and `scripts/install-git-hooks.sh` are untouched, and their cases pass +unchanged in GREEN. The ancestor walk, the read loop, the gone rule (a successful literal listing that +lacks the pid) and the exit codes are as R4 left them, apart from the new "cannot classify" error. +`docs/TOOLS.md` now gives 153 cases, the flat form, the span rule, the exit 2 on text it can't classify, and +the literal listing. + +### R5 runs + +`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was under `timeout -k 10 1800`, +one at a time, with output to files there. The suite runs were in the foreground. The mutant runner took +12 minutes 41 seconds, longer than the tool's 10-minute foreground limit. So it ran as a background job of +the tool, under the same `timeout`, and nothing else ran until it ended. + +- RED, all new tests in place and R4's busy check, 23:32:37Z to 23:33:12Z: 119 passed, 34 failed. For this + one run the checkout's `scripts/slot-busy.sh` was fd1aa8cc8's copy (`git diff --quiet fd1aa8cc8` on it + passed), then R5's was put back (sha256 `8571e27d...` before and after). The hook and the installer are + the same blobs in R4 and R5. All 105 R4 tests pass there. Of the 48 new tests, 34 fail and 14 pass. The 14 + are the controls named above: `bash "/seat/gate;1.sh"`, `eval $'source scripts/test-x.sh'`, the unclosed + `"` before a source span, the two `snapshot-bash-3-gate.sh` wrappers, the `QUOTING_STYLE=escape` pair, + live shapes 1 to 6 and live shape 14. Two earlier RED runs at 148 cases gave 117/31 and 119/29. In the + first, a fake pid that one of my new tests left in a shared fake root made two other tests fail. I fixed + that in the test before the second run. The last five cases added after that all fail on R4. +- GREEN, 23:33:23Z to 23:33:35Z: 153 passed, 0 failed. The three code files haven't changed since. +- R4's own suite, in a scratch tree of fd1aa8cc8's four scripts, 23:47:46Z to 23:47:53Z: 105 passed, + 0 failed. Its 105 labels all appear in GREEN, and GREEN's other 48 are the new tests above. +- Mutants, 23:33:53Z to 23:46:34Z: base green (153/0), runner exit 0, 57 of 57 killed, none survived, no + NO MATCH, no COPY FAILED, no timeout. Every kill exited 1. A DRY run before it matched all 57 sites + once. + +| Mutant | FAIL | First failing case | +|---|---|---| +| ancestor-dropped | 8 | push --force of a rewritten branch is refused | +| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first | +| delete-passes | 13 | the install into a clean clone exits 0 | +| install-over-hookspath | 2 | it says core.hooksPath is set | +| busy-exits-0 | 52 | a node --test process: exit 1 | +| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) | +| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes | +| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install | +| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused | +| snapshot-matched | 44 | no matching process: exit 0 and CLEAR | +| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line | +| no-node-rule | 10 | a node --test process: exit 1 | +| no-compose-rule | 4 | busy on: docker compose -p x up -d (docker compose) | +| unreadable-read-clear | 13 | a pid directory with no cmdline: exit 2 | +| vanished-is-error | 3 | a process that vanishes during its read isn't an error: exit 0 and CLEAR | +| read-errors-ignored | 18 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR | +| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR | +| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) | +| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused | +| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) | +| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref | +| ancestor-read-unchecked | 7 | an ancestor's status read that fails after its own read passed: exit 2, no CLEAR | +| exited-ancestor-error | 1 | an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR | +| denied-lookup-vanished | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR | +| denied-listing-gone | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR | +| dir-check-skips | 1 | a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR | +| sourced-test-hidden | 36 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat | +| sourced-first-only | 34 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat | +| dot-source-missed | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && . /seat/scri | +| installer-fixed-tmp | 2 | the install writes nothing through a link planted at a pid-named temporary path | +| ls-env-quoting | 8 | QUOTING_STYLE=shell-always: a failing cmdline read with the pid still listed: exit 2, no CLEAR | +| locale-kept | 1 | under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1 | +| quotes-kept | 1 | live shape 11, source "S" && source "T", no child: exit 1, sourced test script | +| operators-kept | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) | +| snapshot-single-space | 2 | busy on: /bin/sh -c docker compose -p x up (docker compose) | +| flat-skipped | 10 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) | +| flat-double-blank-missed | 1 | busy on: /bin/sh -c docker compose -p x up (docker compose) | +| special-tab-missed | 1 | busy on: `/bin/sh -c dockercompose -p x up` (docker compose) | +| flat-failure-passes | 2 | the flat form's awk exits 1: exit 2, cannot classify, no CLEAR | +| flat-sentinel-unchecked | 1 | the flat form's awk prints nothing: exit 2, cannot classify, no CLEAR | +| raw-rules-dropped | 1 | busy on: bash /seat/gate;1.sh (gate) | +| flat-rules-dropped | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) | +| source-arg-first-word | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source 2>/de | +| no-requote | 10 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n | +| quoted-span-reset | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "/sea | +| span-ends-in-parens | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source $(cd | +| backtick-ignored | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source `cd s | +| sq-kept | 6 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n | +| dq-kept | 13 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "scri | +| dollar-quote-glued | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval $'sourc | +| backslash-kept | 3 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval "source | +| snapshot-path-checked | 3 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo h | +| snapshot-repeat-checked | 1 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && . /hom | +| depth-unbounded | 1 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR | +| awk-failure-none | 1 | the wrapper walk's awk exits 1: exit 2, cannot classify, no CLEAR | +| lexer-garbage-none | 2 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR | +| classify-error-passes | 5 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR | + +The runner cuts each line at 100 characters, which is why some first failing cases end mid-word. + +Live probes, 23:46:56Z to 23:47:18Z, outside the suite (`probe.sh` in TMPDIR, exit 0). Each shape is one +real `bash -c` started in `$P/wrap`, whose sourced file writes a ready marker and blocks in +`read -t 60 -u 3` on a fifo. With the marker present and the children file read, R4's and R5's +`slot-busy.sh` each scanned a proc root that links only that wrapper. Then R5 scanned the real `/proc`, +and the table counts its BUSY lines for that pid. Each wrapper was killed and reaped before the next, and no +"still alive" warning printed. `S` is an empty snapshot under `$P/shell-snapshots`, `SF` one that runs +`false`, and `T` is `$P/scripts/test-x.sh`. Shape 2's path is relative to `$P/wrap`. + +| Shape | Wrapper text | Pid | Marker, children | R4 | R5 | BUSY lines on the real /proc (R5) | +|---|---|---|---|---|---|---| +| 1 | `source S && source T` | 732550 | ready, none | exit 1, BUSY 732550 sourced test script | exit 1, BUSY 732550 sourced test script | 1 | +| 2 | `source S && .'scripts/test-x.sh'` | 737522 | ready, none | exit 1, BUSY 737522 sourced test script | exit 1, BUSY 737522 sourced test script | 1 | +| 3 | `source S ; source T ; :` | 742569 | ready, none | exit 1, BUSY 742569 sourced test script | exit 1, BUSY 742569 sourced test script | 1 | +| 4 | `source SF \|\| source T` | 747729 | ready, none | exit 1, BUSY 747729 sourced test script | exit 1, BUSY 747729 sourced test script | 1 | +| 5 | `source S && . "./gate.sh"` | 755860 | ready, none | exit 1, BUSY 755860 sourced gate | exit 1, BUSY 755860 sourced gate | 1 | +| 6 | `source S && source ./mutants.sh` | 760031 | ready, none | exit 1, BUSY 760031 sourced mutant | exit 1, BUSY 760031 sourced mutant | 1 | +| 7 | `source S && source ".../dir with spaces/scripts/test-x.sh"` | 763715 | ready, none | exit 0, CLEAR | exit 1, BUSY 763715 sourced test script | 1 | +| 8 | `source S && "source" T` | 767367 | ready, none | exit 0, CLEAR | exit 1, BUSY 767367 sourced test script | 1 | +| 9 | `source S;source T;:` | 771113 | ready, none | exit 0, CLEAR | exit 1, BUSY 771113 sourced test script | 1 | +| 10 | `source S;. T;:` | 774899 | ready, none | exit 0, CLEAR | exit 1, BUSY 774899 sourced test script | 1 | +| 11 | `source "S" && source "T"` | 778649 | ready, none | exit 0, CLEAR | exit 1, BUSY 778649 sourced test script | 1 | +| 12 | `sourceS && source "T"` | 782425 | ready, none | exit 0, CLEAR | exit 1, BUSY 782425 sourced test script | 1 | +| 13 | `sourceS && source "T"` | 786085 | ready, none | exit 0, CLEAR | exit 1, BUSY 786085 sourced test script | 1 | + +Claims, 23:47:24Z to 23:48:44Z (`claims.sh` and `claims3.sh` in TMPDIR). Each row is one process in a fake +proc root, scanned by R4 and R5. Unless the row says otherwise, the argv is +`/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && TEXT"`. + +| TEXT | R4 | R5 | +|---|---|---| +| `source env.sh scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script | +| `source x 2>notes-mutant.log` | exit 0, CLEAR | exit 1, sourced mutant | +| `find . -name 'test-*.sh'` | exit 0, CLEAR | exit 1, sourced test script | +| `( source x; cat scripts/test-x.sh )` | exit 0, CLEAR | exit 1, sourced test script | +| `echo 'x; source' scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script | +| argv `/bin/bash -c 'cat scripts/test-x.sh\|wc -l'`, no snapshot | exit 0, CLEAR | exit 1, test script | +| `grep -rn x . scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script | +| `git add . gate.sh` | exit 1, sourced gate | exit 1, sourced gate | +| `grep -n source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script | +| `echo source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script | +| `git commit -m "fix; source scripts/test-x.sh"` | exit 1, sourced test script | exit 1, sourced test script | +| `git commit -m "fix; source 'scripts/test-x.sh'"` | exit 1, sourced test script | exit 1, sourced test script | +| `echo hi`, then a line `# source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script | +| `echo "it's && source /seat/scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script | +| `source /home/u/.claude/shell-snapshots/snapshot-bash-9-gate.sh && echo hi` | exit 1, sourced gate | exit 1, sourced gate | +| `source $'scripts/test\x2dx.sh'` | exit 0, CLEAR | exit 0, CLEAR | +| `f=scripts/test-x.sh; source "$f"` | exit 0, CLEAR | exit 0, CLEAR | +| argv `/bin/bash -c 'source "/home/a b/.claude/shell-snapshots/snapshot-bash-1.sh" && source scripts/test-x.sh'` | exit 1, test script | exit 1, test script | +| the same with `&& node --test x.test.mjs; :` in place of the second source | exit 1, node --test | exit 1, node --test | +| `echo hi && ls -la` | exit 0, CLEAR | exit 0, CLEAR | + +The ANSI-C and variable rows are the misses under "Known limits after R5". The two rows with a blank in the +snapshot path show that such a wrapper isn't skipped and goes through the general rules. Run as +`env SHELLOPTS=noglob bash slot-busy.sh`, both versions exit 2 with "no processes", on a fake root holding +`sleep 1` and on the real `/proc` (23:47:40Z). A first try set `SHELLOPTS=noglob` as a prefix +assignment. bash refused it ("readonly variable") and ran the check without it, so that row is the `env` +run. + +- Real host, 23:50:13Z to 23:50:17Z: `bash slot-busy.sh` on the real `/proc`, R4, R5, R4, R5. Each exited + 0 with CLEAR. R4 took 1157 ms and 1183 ms, R5 1352 ms and 1275 ms, over about 965 pids. The earlier + timing runs above gave R5 145 ms to 181 ms more than R4. +- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the + positive control). None names a path this round changes, and none holds the old or new sha256 or blob id + of the three changed code files. `git grep` for any of those twelve ids over the whole tree finds nothing. + No test reads `docs/TOOLS.md`, `BUILD-LOG.md`, `docs/SESSIONS.md` or this file. `gate-lane-auth.test.mjs` + leaves `docs`, `agents` and `BUILD-LOG.md` out of its copy, and `stack-release.test.mjs` only names + TOOLS.md in an expected message. + +### Known limits after R5 + +- `git push --no-verify` skips the hook. Gitea branch protection is the guard that holds (unchanged). +- The busy rules read command lines as text. The walk is a model of shell word splitting, not bash's + parser: it doesn't know comments, `case` patterns, arithmetic or here-strings. The false BUSY cases above + are the price of reading every quoted and spanned word. +- Still missed: a test sourced by a path built at run time (a variable, a glob, command substitution + output), by an ANSI-C escape (`$'scripts/test\x2dx.sh'`, which the walk reads as plain quotes), by a + nested file, a function or an alias, or by a shell reading its commands from stdin or a terminal. The + command line doesn't show any of those, and the check may not read another seat's files (the review's + Check 6, item 2). +- A snapshot path with a blank in it, from a home directory with a space, doesn't match `RE_SNAPSHOT`, so + that wrapper is never skipped. Its text goes through the general rules, which can only add BUSY lines. +- `SPECIAL` must list every character `flat()` changes, or a command line holding only that character keeps + its raw form. The tab and two spaces are pinned by tests and mutants; CR, VT and FF are listed but not + pinned. +- `--quoting-style` needs GNU ls. BSD or BusyBox ls fails every listing, which fails closed (exit 2 on any + failed read) but turns every exit race into an error. +- `-1` is a no-op for a pipe, so no test pins it. +- Exported functions, `BASH_ENV` and `PATH` are the caller's to set; with them a caller can replace the tools + the check runs. +- A scan costs 145 ms to 181 ms more than R4's on this host (975 pids), and a single 2 MB wrapper text took + up to 1.4 s. +- A bind-mounted hooks directory isn't detected (corrected by N3 above). +- The installer has a check-then-use window after its path checks (unchanged). +- A BUSY line holds the command line as it is, so a wrapper whose `-c` text has newlines prints over several + output lines. Chain on the exit code, never on the lines (unchanged advice). +- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and + can't make it BUSY. This host's `/proc` has no hidepid option (unchanged). diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 729ef4d0a..e6ac8984a 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1882,3 +1882,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R3 from the GPT review, on t3/row211-push-guard-r3-fix over 09a847eda (picks 5734c1a80, c8f2cb70c) | F1-F5 and the object-name length check fixed with tests; suite RED 67/18, GREEN 85/0; 21 of 21 mutants killed; one local commit, not pushed 2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R4 from the GPT review of R3, on t3/row211-push-guard-r4-fix over 09a847eda (picks bcd74ec33, 3a121885c, 7d03f212d) | R1-R3, runner exit status and installer mktemp fixed with tests; suite RED 90/14, GREEN 105/0; 30 of 30 mutants killed, runner exit 0; one local commit, not pushed +2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R5 from the GPT review of R4, on t3/row211-push-guard-r5-fix over 09a847eda (from fd1aa8cc8) | N1-N3 fixed (literal ls listing under LC_ALL=C, flat form and awk word walk for sourced tests, bind-mount note corrected) with tests; suite RED 119/34, GREEN 153/0; 57 of 57 mutants killed, runner exit 0; 13 of 13 live shapes BUSY; two local commits, not pushed diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 6ca3d6c39..78068936b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -166,7 +166,7 @@ resolved through the CLI. | `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases | | `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases | | `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases | -| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 105 cases | +| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 153 cases | | `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath`, a symlinked hooks directory or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage | | `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error, including a process or an ancestor it can't read that isn't confirmed gone; chain on the exit code | | `scripts/gitea-api.sh [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout | @@ -199,11 +199,20 @@ Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a `test-*.sh` and `docker compose` command lines, skips itself, its ancestors and agent shell-snapshot wrappers (a shell whose `-c` text starts by sourcing -`.../shell-snapshots/snapshot-*.sh`). A wrapper whose text also sources -(`source` or `.`) a gate, test or mutant script is BUSY, because that script -runs in the wrapper's own shell. It reads only `/proc//cmdline`, the cwd -link, its own and its ancestors' `status` and, after a failed read, the proc -root's listing. A process or an ancestor it can't read makes it exit 2, never +`.../shell-snapshots/snapshot-*.sh`). Each rule runs on the command line as it +is and on a flat form with the quotes and backslashes removed and every run of +blanks, newlines and the operators `; & | ( ) { } < >` and backtick turned into +one space, so quoting and spacing don't hide a match. A wrapper is BUSY when +any word after a `source` or `.`, up to the next `;`, `&`, `|` or newline +outside parentheses and backticks, names a gate, test or mutant script, +because that script runs in the wrapper's own shell. Quoted text, an `eval` +string for one, is read the same way. That rule can call a wrapper BUSY that +runs no test; row 211's BUILD.md (R5) lists the cases. A command line it can't +classify, quoting more than 32 levels deep for one, makes it exit 2. It reads +only `/proc//cmdline`, the cwd link, its own and its ancestors' `status` +and, after a failed read, the proc root's listing, which it takes from GNU +`ls --quoting-style=literal` under `LC_ALL=C` so the caller's environment +can't change it. A process or an ancestor it can't read makes it exit 2, never CLEAR, unless a listing of the proc root succeeds and no longer has that pid. ## Seat launch with registration (`scripts/mosaic`)