Nightly full browser suite on dev with per-spec issues (D373) #443

Open
opened 2026-11-09 05:55:37 +00:00 by kayg · 25 comments
Owner

Implements D373: the full tracer browser suite runs nightly on dev, built like the nightly fuzz (D361).

Plan (estimate 2-3 h):

  1. scripts/nightly-browser-suite.py: runs a production build, then the tracer Playwright suite (the verify_latency browser phase) with the JSON reporter, and files results.
  2. Reporter: one Forgejo issue per failing spec file, deduplicated by a hidden marker against an open issue; log tail, dev SHA and evidence path in the issue; a comment when the spec passes again. Never closes issues. Token read from a file, never in argv or logs.
  3. Privilege split: the suite runs unreviewed latest-dev code, so it runs as a token-less account; only the reviewed reporter copy sees the token.
  4. systemd service helper, installer and timer; gate test nightly browser suite reporter in scripts/verify.d/rust.sh.
  5. Manual run against current dev: opens issues for the known reds, dedupes on a second run. Then verify quick.

Branch: nightly-browser-suite, base 411b49aa8.

Implements D373: the full tracer browser suite runs nightly on dev, built like the nightly fuzz (D361). Plan (estimate 2-3 h): 1. `scripts/nightly-browser-suite.py`: runs a production build, then the tracer Playwright suite (the `verify_latency` browser phase) with the JSON reporter, and files results. 2. Reporter: one Forgejo issue per failing spec file, deduplicated by a hidden marker against an open issue; log tail, dev SHA and evidence path in the issue; a comment when the spec passes again. Never closes issues. Token read from a file, never in argv or logs. 3. Privilege split: the suite runs unreviewed latest-dev code, so it runs as a token-less account; only the reviewed reporter copy sees the token. 4. systemd service helper, installer and timer; gate test `nightly browser suite reporter` in `scripts/verify.d/rust.sh`. 5. Manual run against current dev: opens issues for the known reds, dedupes on a second run. Then `verify quick`. Branch: `nightly-browser-suite`, base 411b49aa8.
Author
Owner

Started. Branch nightly-browser-suite, base 411b49aa8 (origin/dev). Worktree ~/Developer/Worktrees/infraternal-j406. Plan is in the issue body.

Schedule choice: the fuzz fires 02:00 IST (+<=15 min) and runs about 1 h plus build. The browser suite gets 05:00 IST (+<=15 min), after the fuzz, each taking one of the 3 build slots.

Security design: the suite runs latest-dev code, so it cannot run where the Forgejo token is readable (the fuzz avoids this by pinning a reviewed ref). The suite runs as a new token-less account; the reporter (stdlib Python, installed root-owned from a reviewed ref) runs as the existing infraternal-fuzz account that holds the token.

Started. Branch `nightly-browser-suite`, base 411b49aa8 (origin/dev). Worktree ~/Developer/Worktrees/infraternal-j406. Plan is in the issue body. Schedule choice: the fuzz fires 02:00 IST (+<=15 min) and runs about 1 h plus build. The browser suite gets 05:00 IST (+<=15 min), after the fuzz, each taking one of the 3 build slots. Security design: the suite runs latest-dev code, so it cannot run where the Forgejo token is readable (the fuzz avoids this by pinning a reviewed ref). The suite runs as a new token-less account; the reporter (stdlib Python, installed root-owned from a reviewed ref) runs as the existing infraternal-fuzz account that holds the token.
Author
Owner

Finding (J304c): the compare-and-set J304 left uncommitted was too coarse, so I changed it.

Evidence: with look.set required to carry the exact current document revision, real/appearance-sync.spec.ts on dev-02 went 3 failed / 5 passed (log ~/j304c-ev/run2.log on dev-02): "theme and font changes reach the same account live..." (camera jumped), and both migration specs (look undefined). Cause: the client fixes base_revision when it queues an edit, so a look queued behind the tab's own layout edits or migration is refused.

Fix (head 3f608f46f): the server refuses a look.set only if another non-noop look.set landed after its base, or the retained operation tail no longer covers every revision after the base (it refuses and the client resyncs). Layout edits in between do not conflict. The client rebases unsent looks queued behind its own accepted look. Rejected: a per-field look intent (new wire op plus merge rules), and a stored look revision in the document (schema/fuzz/migration change). Also fixed on this branch: the Themes window spec's section list now includes the new "Interface font" section; verify quick doc-comment rows.

The earlier themes.spec.ts + third-party-notices.spec.ts run on dev-02 (including the 393×852 captures) passed except that stale section list; rerun pending. Proof run is queued: ~/j304c-ev/run3.log on dev-02.

Finding (J304c): the compare-and-set J304 left uncommitted was too coarse, so I changed it. Evidence: with `look.set` required to carry the exact current document revision, `real/appearance-sync.spec.ts` on dev-02 went **3 failed / 5 passed** (log `~/j304c-ev/run2.log` on dev-02): "theme and font changes reach the same account live..." (camera jumped), and both migration specs (`look` undefined). Cause: the client fixes `base_revision` when it queues an edit, so a look queued behind the tab's own layout edits or migration is refused. Fix (head `3f608f46f`): the server refuses a `look.set` only if another non-noop `look.set` landed after its base, or the retained operation tail no longer covers every revision after the base (it refuses and the client resyncs). Layout edits in between do not conflict. The client rebases unsent looks queued behind its own accepted look. Rejected: a per-field look intent (new wire op plus merge rules), and a stored look revision in the document (schema/fuzz/migration change). Also fixed on this branch: the Themes window spec's section list now includes the new "Interface font" section; `verify quick` doc-comment rows. The earlier `themes.spec.ts` + `third-party-notices.spec.ts` run on dev-02 (including the 393×852 captures) passed except that stale section list; rerun pending. Proof run is queued: `~/j304c-ev/run3.log` on dev-02.
Author
Owner

Finding (interim, manual run, log ~/j406-ev/runs/*/suite.log on dev-02): the full suite is slow: about 125 of 755+ listed tests in 30 min with the shared VM busy, and several early reds beyond the three named: addhost-pill-stream.spec.ts (9 tests, 90 s timeouts) and addhost-pill.spec.ts. Per-spec-file issues will cover all of them. The 4 h unit timeout may be tight; I will size it from this run.

Finding (interim, manual run, log ~/j406-ev/runs/*/suite.log on dev-02): the full suite is slow: about 125 of 755+ listed tests in 30 min with the shared VM busy, and several early reds beyond the three named: addhost-pill-stream.spec.ts (9 tests, 90 s timeouts) and addhost-pill.spec.ts. Per-spec-file issues will cover all of them. The 4 h unit timeout may be tight; I will size it from this run.
Author
Owner

Finish. Branch nightly-browser-suite, head c751e99309 (git ls-remote confirms), base 411b49aa8. Not merged.

Schedule: 05:00 Asia/Kolkata + up to 15 min jitter, Persistent=true. The fuzz is 02:00 + 15 min and runs about 1 h 15 min, so there is no overlap in the normal case; if the fuzz is badly delayed both wait for their own slot (3 slots).

Manual run against current dev (tracer code identical to origin/dev 411b49aa8; only scripts/docs differ), on dev-02 through build-slot, 06:18 to about 09:40 UTC (3 h 22 min incl. a 20 min slot wait; suite itself about 3 h): production build + full suite, playwright_exit=1. Log ~/j406-ev/run1.log, results/log/evidence under ~/j406-ev/runs/20261109T061806Z-c751e99/ on dev-02 (copy of results.json, suite.log and the reports in ~/.cache/codex-scratch/j406/evidence/ on the control host: runs/, report1.log, report2.log).

  • Result: 21 red spec files, 133 green. Report run 1 opened 21 issues, #450-#470, including the known reds: docs-palette #456, toast-stack #469, palette-polish #463 (touch case: label width 57.87 vs >= 58). Others: addhost-pill-stream, addhost-pill, ca-unlock, clusters-swipes-both, clusters-swipes, clusters, glass-ghost, inbox, machine-pill-flicker, mesh-rename, mesh-view-overlap, mesh, perf-hud, phone-polish, sessions-canvas, sign-out, startup-demo, wheel-scroll.
  • Report run 2 on the same results: BROWSER_REPORT_DONE failing=21 ... request_errors=0, no opened issue lines, zero new issues or comments (report2.log).
  • Caveat: one run on a busy shared VM; some of the 21 may be load flakes (e.g. 90 s timeouts). The nightly will show which persist. Reds beyond the three known are findings for the orchestrator to triage.
  • The "passes again" comment is covered by the gate test (fake Forgejo), not by this live run, since no spec was fixed in between.

Gate: verify quick on dev-02 passed (all 8 rows PASS: scope guard, migration names, Nix Cargo vendoring, fmt, doc coverage ratchet, shape lints off, doc comments on diff, fixed test waits). scripts/test-nightly-browser-suite.py: 13 tests OK; wired as the gate row nightly browser suite reporter in verify fast's final mode (not run: fast was not asked for).

Install: needs root on dev-02, which claude does not have (sudo -n fails). Request for the Maintainer (cua, sudo), after this branch is reviewed and its head is on origin:

sudo git -C /tmp clone -q https://git.kayg.org/kayg/infraternal.git /tmp/ibs && sudo git -C /tmp/ibs checkout -q <reviewed commit> \
 && sudo python3 /tmp/ibs/scripts/install-nightly-browser-suite --ref <reviewed commit>
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager
sudo systemctl start infraternal-browser-suite.service   # optional manual night, then: journalctl -u infraternal-browser-suite.service

The installer creates account infraternal-browser (no sudo/login/token, home 0711), clones the public repo into it, installs root-owned /usr/local/libexec/infraternal-browser-suite-service and -report, writes infraternal-browser-suite.service (oneshot, TimeoutStartSec=4h, Nice=10) and .timer (05:00 IST), runs systemd-analyze verify and enables the timer. It needs the fuzz install first (reuses /var/lib/infraternal-fuzz/forgejo-token; present on dev-02). The exact unit text is in service_unit() and timer_unit() in scripts/install-nightly-browser-suite.

Notes / decisions for the orchestrator:

  1. The suite took about 3 h on a loaded VM; the 4 h unit timeout is tight. Recommend TimeoutStartSec=6h like the fuzz; I did not change it because the manual run included the slot wait only partly. Say if you want it raised.
  2. The reporter must run with /usr/bin/python3 (the nix python lacks a CA bundle on the control host); the service helper already does.
  3. Tokens: used only through a 0600 file (deleted after the manual run), sent in headers, never in argv or logs.
Finish. Branch `nightly-browser-suite`, head c751e99309764e30958eb665708e2553c31043c4 (`git ls-remote` confirms), base 411b49aa8. Not merged. **Schedule:** 05:00 Asia/Kolkata + up to 15 min jitter, `Persistent=true`. The fuzz is 02:00 + 15 min and runs about 1 h 15 min, so there is no overlap in the normal case; if the fuzz is badly delayed both wait for their own slot (3 slots). **Manual run against current dev** (tracer code identical to origin/dev 411b49aa8; only scripts/docs differ), on dev-02 through build-slot, 06:18 to about 09:40 UTC (3 h 22 min incl. a 20 min slot wait; suite itself about 3 h): production build + full suite, `playwright_exit=1`. Log `~/j406-ev/run1.log`, results/log/evidence under `~/j406-ev/runs/20261109T061806Z-c751e99/` on dev-02 (copy of results.json, suite.log and the reports in `~/.cache/codex-scratch/j406/evidence/` on the control host: `runs/`, `report1.log`, `report2.log`). - Result: 21 red spec files, 133 green. Report run 1 opened 21 issues, #450-#470, including the known reds: docs-palette #456, toast-stack #469, palette-polish #463 (touch case: label width 57.87 vs >= 58). Others: addhost-pill-stream, addhost-pill, ca-unlock, clusters-swipes-both, clusters-swipes, clusters, glass-ghost, inbox, machine-pill-flicker, mesh-rename, mesh-view-overlap, mesh, perf-hud, phone-polish, sessions-canvas, sign-out, startup-demo, wheel-scroll. - Report run 2 on the same results: `BROWSER_REPORT_DONE failing=21 ... request_errors=0`, no `opened issue` lines, zero new issues or comments (report2.log). - Caveat: one run on a busy shared VM; some of the 21 may be load flakes (e.g. 90 s timeouts). The nightly will show which persist. Reds beyond the three known are findings for the orchestrator to triage. - The "passes again" comment is covered by the gate test (fake Forgejo), not by this live run, since no spec was fixed in between. **Gate:** `verify quick` on dev-02 passed (all 8 rows PASS: scope guard, migration names, Nix Cargo vendoring, fmt, doc coverage ratchet, shape lints off, doc comments on diff, fixed test waits). `scripts/test-nightly-browser-suite.py`: 13 tests OK; wired as the gate row `nightly browser suite reporter` in `verify fast`'s final mode (not run: `fast` was not asked for). **Install: needs root on dev-02, which `claude` does not have (sudo -n fails). Request for the Maintainer** (`cua`, sudo), after this branch is reviewed and its head is on origin: ``` sudo git -C /tmp clone -q https://git.kayg.org/kayg/infraternal.git /tmp/ibs && sudo git -C /tmp/ibs checkout -q <reviewed commit> \ && sudo python3 /tmp/ibs/scripts/install-nightly-browser-suite --ref <reviewed commit> sudo systemctl list-timers infraternal-browser-suite.timer --no-pager sudo systemctl start infraternal-browser-suite.service # optional manual night, then: journalctl -u infraternal-browser-suite.service ``` The installer creates account `infraternal-browser` (no sudo/login/token, home 0711), clones the public repo into it, installs root-owned `/usr/local/libexec/infraternal-browser-suite-service` and `-report`, writes `infraternal-browser-suite.service` (oneshot, TimeoutStartSec=4h, Nice=10) and `.timer` (05:00 IST), runs `systemd-analyze verify` and enables the timer. It needs the fuzz install first (reuses `/var/lib/infraternal-fuzz/forgejo-token`; present on dev-02). The exact unit text is in `service_unit()` and `timer_unit()` in `scripts/install-nightly-browser-suite`. **Notes / decisions for the orchestrator:** 1. The suite took about 3 h on a loaded VM; the 4 h unit timeout is tight. Recommend `TimeoutStartSec=6h` like the fuzz; I did not change it because the manual run included the slot wait only partly. Say if you want it raised. 2. The reporter must run with `/usr/bin/python3` (the nix python lacks a CA bundle on the control host); the service helper already does. 3. Tokens: used only through a 0600 file (deleted after the manual run), sent in headers, never in argv or logs.
Author
Owner

Update: raised TimeoutStartSec to 6h (note 1 above done). Final head is the SHA below.

7121538115

Update: raised TimeoutStartSec to 6h (note 1 above done). Final head is the SHA below. 7121538115ecb22c8933dfa8c4c9615b1ac8d7b9
Author
Owner

Security review (independent): REQUEST CHANGES

Reviewed SHA: 7121538115ecb22c8933dfa8c4c9615b1ac8d7b9 (branch nightly-browser-suite, 4 commits on top of origin/dev 411b49aa8).
Reviewer: Claude Opus 5.5. I read the diff, the installed fuzz helper on dev-02, the permissions on dev-02, and ran a proof-of-concept against the reporter (no network, api() stubbed).

The account split is the right idea, and the parts I checked hold: dev-02 has /var/lib/infraternal-fuzz at 0700, so claude cannot even stat the token. dev-02 has no crontab or at, so unreviewed code cannot persist past the unit's cgroup. Environments are reset with setpriv --reset-env and env. The token never appears in argv or logs, and HTTPError text does not include headers.

The weak point is the step where data crosses accounts. Both the reporter (as infraternal-fuzz) and the installer (as root) read files from directories the untrusted account owns, and both follow symlinks.

P1: unreviewed dev code can publish the Forgejo token in a public issue

nightly-browser-suite-service:59-64 checks only that latest resolves to a directory inside runs. The files inside that directory are owned by infraternal-browser, and the reporter opens them as infraternal-fuzz and follows symlinks. fs.protected_symlinks does not help, because runs/ is not a sticky world-writable directory. The attack:

  1. run (unreviewed dev code) writes suite.log as a symlink to /var/lib/infraternal-fuzz/forgejo-token.
  2. It writes results.json with a failing spec whose file is a single hex character, e.g. "a".
  3. report() → log_tail(log_text, "a") returns every line that contains a, which includes the token line, and issue_body() posts it publicly.

PoC (poc.py/poc.out attached): TOKEN_LEAKED_IN_ISSUE_BODY True.
The same path also lets results.json/meta.json/suite.log be a symlink to /dev/zero or a FIFO. read_text() then hangs or runs out of memory in the token account, and latest is read as root (nightly-browser-suite-service:59) with the same problem.

Fix: never open run files with the reporter's privileges. Have the root helper read meta.json, results.json and suite.log as infraternal-browser (for example setpriv … cat with O_NOFOLLOW semantics, or head -c <cap>), or open each one with O_NOFOLLOW, fstat it as a regular file and cap its size. Then pass the bytes to the reporter on stdin or in a root-owned temp directory. Add a regression test with a suite.log symlinked to a file the run account cannot read.

P1: re-running the installer installs the untrusted account's code as the root-owned reporter

install-nightly-browser-suite:132-134 copies the "reviewed" helper and reporter from HOME/checkout. That is the checkout owned by infraternal-browser, which has run unreviewed dev code every night. The /tmp/ibs clone in the install request only provides the installer itself. On the first install the account is fresh, so it is safe. Every later run (an upgrade or a re-install, which is the idempotent path) has these problems:

  • git fetch/checkout of the ref runs in a repository whose .git (hooks, config, objects, a racing process) is controlled by the attacker, and nothing verifies the working-tree file after checkout. The attacker's file becomes /usr/local/libexec/infraternal-browser-suite-report, which then runs as infraternal-fuzz with the token.
  • shutil.copyfile follows symlinks. If scripts/nightly-browser-suite.py is a symlink to /var/lib/infraternal-fuzz/forgejo-token, root copies the token to a 0755 file in /usr/local/libexec.

Fix: install both scripts from the installer's own reviewed tree (Path(__file__).resolve().parents[1]). Check that its git rev-parse HEAD equals --ref and that the files are regular files, not symlinks. Use the browser checkout only as the suite's working copy.

P2: no cap on issues per night, and dedupe can be bypassed (issue storm)

  • report() opens one issue for every failing spec name, with no limit. A malicious run can list thousands of names. A legitimate infrastructure failure (the test server dies mid-run, so all ~154 spec files go red) also floods the tracker in one night. PoC: NIGHT1_NEW_ISSUES 501.
  • SPEC_MARKER is (\S+) -->. A spec name that contains whitespace (or -->) writes a marker that never matches, so the same red opens a new issue every night. PoC: NIGHT2_NEW_ISSUES_dedupe_bypassed_by_space 500.

Fix:

  • Validate spec names (e.g. ^[A-Za-z0-9._/-]{1,120}\.spec\.ts$, no ..). Map anything else to one "unparseable spec" bucket.
  • Cap new issues per night (e.g. 10). Above the cap, open or update one summary issue ("N spec files failed, probably infrastructure") instead.
  • Treat failing ≥ X% of the ran files as an infrastructure failure, as the module docstring already does for a missing JSON.

P2: markdown injection and unbounded size in issue bodies

  • Error text and log lines go inside ``` fences unescaped. A line containing ``` closes the fence, and the attacker's markdown follows: @mentions that notify people, remote images (tracking), fake quoted text, and links. PoC: FENCE_BREAKOUT_MENTION True. Forgejo sanitizes HTML, so this is not XSS, but it is real spam and phishing on a public repo.
  • Bounds: the error text is capped (5 × 3000 chars), but tail (30 lines of any length), the spec name in the title and body, and meta['head']/evidence/log are not.

Fix:

  • Use a fence longer than any run of backticks in the content, or indent the content as a code block.
  • Neutralise @ (e.g. replace it with @​).
  • Cap each line and the whole body (e.g. 60 KB). Require head to match ^[0-9a-f]{40}$. Build the evidence and log paths in the reporter from the validated run directory name instead of trusting meta.json.

P2: install instructions clone as root into a predictable /tmp path

The request on this issue runs sudo git -C /tmp clone … /tmp/ibs. git clone into an existing empty directory succeeds. Any local account (for example claude, which runs unreviewed AI-job code) can pre-create /tmp/ibs, own it, and swap scripts/ before root runs python3 /tmp/ibs/scripts/install-nightly-browser-suite. That is root code execution. Fix: d=$(sudo mktemp -d) and clone into $d/src. The corrected commands are below.

P3

  • No systemd hardening on the unit. Add at least PrivateTmp=yes (isolates the suite's /tmp from the claude jobs and the fuzz), ProtectSystem=strict with explicit ReadWritePaths=/var/lib/infraternal-browser /tmp/infraternal-build-slots /nix/var (check that nix still works), and ProtectKernelTunables=yes. Skip NoNewPrivileges/ProtectHome until you've checked them against runuser and the build-slot script in /home/claude. These are defence in depth, not a fix for the P1s.
  • UMask=0022. The fuzz unit uses 0077. Everything the root helper creates is world-readable. Nothing secret is written today, so 0077 is the safer default. Keep the home at 0711 with explicit 0755 on runs/, as the installer already sets.
  • open_issues() stops silently at 40 × 50 = 2000 open issues. Past that, dedupe fails and duplicates follow. Fail closed: if page 40 is full, raise.
  • Dedupe takes any open issue whose body has the marker, by any author. Anyone who can file an issue can mute a spec. Filter on issue.user.login == the token's account.
  • Docstring vs reality. The installer says "dev-02 has three" build slots, which is true today (~/infraternal-ops/slots = 3). BRIEF-COMMON still says 1. The orchestrator should fix BRIEF-COMMON.

What held up (questions 3 and 4)

  • Schedule: 05:00 IST + 15 min, Persistent=true, does not collide with the 02:00 fuzz. The service takes a real slot through runuser claude build-slot and holds it for the whole run, the same pattern as the installed fuzz helper. 6 h covers the measured ~3 h 22 min.
  • Installer idempotency: apart from the P1 above, it is idempotent. useradd only runs if the account is missing, clone only if the checkout is missing, and the units are rewritten each time.
  • Lost state: state lives only in the issue comments (the newest hidden marker wins), so losing a local file cannot cause a flood. A lost runs/latest only fails the night (exit 1). If the owner closes a still-red spec's issue, the next night opens one new issue, which is acceptable.
  • Infrastructure failure (build or no JSON) → exit 1 and no spec issues. Correct.

Gates I ran (dev-02, detached worktree at the reviewed SHA)

/usr/bin/python3 -I scripts/test-nightly-browser-suite.py:

Ran 13 tests in 4.555s

OK

build-slot nix develop -c scripts/verify quick:

+---------------------------+--------------+
| Rust gate                 | Result       |
+---------------------------+--------------+
| scope guard               | PASS         |
| migration names           | PASS         |
| Nix Cargo vendoring       | PASS         |
| fmt                       | PASS         |
| doc coverage ratchet      | PASS         |
| shape lints off (D351)    | PASS         |
| doc comments on diff      | PASS         |
| fixed test waits          | PASS         |
+---------------------------+--------------+

QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done.

The gates are green, but they do not cover the attacks above. The new tests only use well-formed results.

Maintainer

Do not install yet. Nothing should run as root until the P1s are fixed and re-reviewed. Once they are, the corrected install shape (it replaces the /tmp/ibs one) is:

d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach <re-reviewed SHA> \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <re-reviewed SHA> \
 && sudo rm -rf "$d"
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager

Evidence (PoC script and its output, verify quick log) is attached and is also under ~/.cache/codex-scratch/j443-review/evidence/ on the control host.

## Security review (independent): **REQUEST CHANGES** Reviewed SHA: `7121538115ecb22c8933dfa8c4c9615b1ac8d7b9` (branch `nightly-browser-suite`, 4 commits on top of origin/dev 411b49aa8). Reviewer: Claude Opus 5.5. I read the diff, the installed fuzz helper on dev-02, the permissions on dev-02, and ran a proof-of-concept against the reporter (no network, `api()` stubbed). The account split is the right idea, and the parts I checked hold: dev-02 has `/var/lib/infraternal-fuzz` at 0700, so `claude` cannot even stat the token. dev-02 has no `crontab` or `at`, so unreviewed code cannot persist past the unit's cgroup. Environments are reset with `setpriv --reset-env` and `env`. The token never appears in argv or logs, and `HTTPError` text does not include headers. The weak point is the step where data crosses accounts. Both the **reporter** (as `infraternal-fuzz`) and the **installer** (as root) read files from directories the untrusted account owns, and both follow symlinks. ### P1: unreviewed dev code can publish the Forgejo token in a public issue `nightly-browser-suite-service:59-64` checks only that `latest` resolves to a directory inside `runs`. The files *inside* that directory are owned by `infraternal-browser`, and the reporter opens them as `infraternal-fuzz` and follows symlinks. `fs.protected_symlinks` does not help, because `runs/` is not a sticky world-writable directory. The attack: 1. `run` (unreviewed dev code) writes `suite.log` as a symlink to `/var/lib/infraternal-fuzz/forgejo-token`. 2. It writes `results.json` with a failing spec whose `file` is a single hex character, e.g. `"a"`. 3. `report()` → `log_tail(log_text, "a")` returns every line that contains `a`, which includes the token line, and `issue_body()` posts it publicly. PoC (`poc.py`/`poc.out` attached): `TOKEN_LEAKED_IN_ISSUE_BODY True`. The same path also lets `results.json`/`meta.json`/`suite.log` be a symlink to `/dev/zero` or a FIFO. `read_text()` then hangs or runs out of memory in the token account, and `latest` is read **as root** (`nightly-browser-suite-service:59`) with the same problem. **Fix:** never open run files with the reporter's privileges. Have the root helper read `meta.json`, `results.json` and `suite.log` **as `infraternal-browser`** (for example `setpriv … cat` with `O_NOFOLLOW` semantics, or `head -c <cap>`), or open each one with `O_NOFOLLOW`, `fstat` it as a regular file and cap its size. Then pass the bytes to the reporter on stdin or in a root-owned temp directory. Add a regression test with a `suite.log` symlinked to a file the run account cannot read. ### P1: re-running the installer installs the untrusted account's code as the root-owned reporter `install-nightly-browser-suite:132-134` copies the "reviewed" helper and reporter from `HOME/checkout`. That is the checkout **owned by `infraternal-browser`**, which has run unreviewed dev code every night. The `/tmp/ibs` clone in the install request only provides the installer itself. On the first install the account is fresh, so it is safe. Every later run (an upgrade or a re-install, which is the idempotent path) has these problems: - `git fetch`/`checkout` of the ref runs in a repository whose `.git` (hooks, config, objects, a racing process) is controlled by the attacker, and nothing verifies the working-tree file after checkout. The attacker's file becomes `/usr/local/libexec/infraternal-browser-suite-report`, which then runs as `infraternal-fuzz` with the token. - `shutil.copyfile` follows symlinks. If `scripts/nightly-browser-suite.py` is a symlink to `/var/lib/infraternal-fuzz/forgejo-token`, root copies the token to a **0755** file in `/usr/local/libexec`. **Fix:** install both scripts from the installer's own reviewed tree (`Path(__file__).resolve().parents[1]`). Check that its `git rev-parse HEAD` equals `--ref` and that the files are regular files, not symlinks. Use the browser checkout only as the suite's working copy. ### P2: no cap on issues per night, and dedupe can be bypassed (issue storm) - `report()` opens one issue for every failing spec name, with no limit. A malicious run can list thousands of names. A legitimate infrastructure failure (the test server dies mid-run, so all ~154 spec files go red) also floods the tracker in one night. PoC: `NIGHT1_NEW_ISSUES 501`. - `SPEC_MARKER` is `(\S+) -->`. A spec name that contains whitespace (or `-->`) writes a marker that never matches, so the same red opens a new issue **every night**. PoC: `NIGHT2_NEW_ISSUES_dedupe_bypassed_by_space 500`. **Fix:** - Validate spec names (e.g. `^[A-Za-z0-9._/-]{1,120}\.spec\.ts$`, no `..`). Map anything else to one "unparseable spec" bucket. - Cap new issues per night (e.g. 10). Above the cap, open or update one summary issue ("N spec files failed, probably infrastructure") instead. - Treat `failing ≥ X%` of the ran files as an infrastructure failure, as the module docstring already does for a missing JSON. ### P2: markdown injection and unbounded size in issue bodies - Error text and log lines go inside ```` ``` ```` fences unescaped. A line containing ```` ``` ```` closes the fence, and the attacker's markdown follows: `@mentions` that notify people, remote images (tracking), fake quoted text, and links. PoC: `FENCE_BREAKOUT_MENTION True`. Forgejo sanitizes HTML, so this is not XSS, but it is real spam and phishing on a public repo. - Bounds: the error text is capped (5 × 3000 chars), but `tail` (30 lines of any length), the spec name in the title and body, and `meta['head']`/`evidence`/`log` are not. **Fix:** - Use a fence longer than any run of backticks in the content, or indent the content as a code block. - Neutralise `@` (e.g. replace it with `@​`). - Cap each line and the whole body (e.g. 60 KB). Require `head` to match `^[0-9a-f]{40}$`. Build the evidence and log paths in the reporter from the validated run directory name instead of trusting `meta.json`. ### P2: install instructions clone as root into a predictable `/tmp` path The request on this issue runs `sudo git -C /tmp clone … /tmp/ibs`. `git clone` into an **existing empty directory** succeeds. Any local account (for example `claude`, which runs unreviewed AI-job code) can pre-create `/tmp/ibs`, own it, and swap `scripts/` before root runs `python3 /tmp/ibs/scripts/install-nightly-browser-suite`. That is root code execution. **Fix:** `d=$(sudo mktemp -d)` and clone into `$d/src`. The corrected commands are below. ### P3 - **No systemd hardening on the unit.** Add at least `PrivateTmp=yes` (isolates the suite's `/tmp` from the claude jobs and the fuzz), `ProtectSystem=strict` with explicit `ReadWritePaths=/var/lib/infraternal-browser /tmp/infraternal-build-slots /nix/var` (check that nix still works), and `ProtectKernelTunables=yes`. Skip `NoNewPrivileges`/`ProtectHome` until you've checked them against `runuser` and the build-slot script in `/home/claude`. These are defence in depth, not a fix for the P1s. - **`UMask=0022`.** The fuzz unit uses 0077. Everything the root helper creates is world-readable. Nothing secret is written today, so 0077 is the safer default. Keep the home at 0711 with explicit 0755 on `runs/`, as the installer already sets. - **`open_issues()` stops silently at 40 × 50 = 2000 open issues.** Past that, dedupe fails and duplicates follow. Fail closed: if page 40 is full, raise. - **Dedupe takes any open issue whose body has the marker, by any author.** Anyone who can file an issue can mute a spec. Filter on `issue.user.login` == the token's account. - **Docstring vs reality.** The installer says "dev-02 has three" build slots, which is true today (`~/infraternal-ops/slots` = 3). BRIEF-COMMON still says 1. The orchestrator should fix BRIEF-COMMON. ### What held up (questions 3 and 4) - **Schedule:** 05:00 IST + 15 min, `Persistent=true`, does not collide with the 02:00 fuzz. The service takes a real slot through `runuser claude build-slot` and holds it for the whole run, the same pattern as the installed fuzz helper. 6 h covers the measured ~3 h 22 min. - **Installer idempotency:** apart from the P1 above, it is idempotent. `useradd` only runs if the account is missing, `clone` only if the checkout is missing, and the units are rewritten each time. - **Lost state:** state lives only in the issue comments (the newest hidden marker wins), so losing a local file cannot cause a flood. A lost `runs/latest` only fails the night (exit 1). If the owner closes a still-red spec's issue, the next night opens one new issue, which is acceptable. - **Infrastructure failure** (build or no JSON) → exit 1 and no spec issues. Correct. ### Gates I ran (dev-02, detached worktree at the reviewed SHA) `/usr/bin/python3 -I scripts/test-nightly-browser-suite.py`: ``` Ran 13 tests in 4.555s OK ``` `build-slot nix develop -c scripts/verify quick`: ``` +---------------------------+--------------+ | Rust gate | Result | +---------------------------+--------------+ | scope guard | PASS | | migration names | PASS | | Nix Cargo vendoring | PASS | | fmt | PASS | | doc coverage ratchet | PASS | | shape lints off (D351) | PASS | | doc comments on diff | PASS | | fixed test waits | PASS | +---------------------------+--------------+ QUICK pre-check passed. This is not the gate: run scripts/verify fast before reporting done. ``` The gates are green, but they do not cover the attacks above. The new tests only use well-formed results. ### Maintainer **Do not install yet.** Nothing should run as root until the P1s are fixed and re-reviewed. Once they are, the corrected install shape (it replaces the `/tmp/ibs` one) is: ``` d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach <re-reviewed SHA> \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <re-reviewed SHA> \ && sudo rm -rf "$d" sudo systemctl list-timers infraternal-browser-suite.timer --no-pager ``` Evidence (PoC script and its output, verify quick log) is attached and is also under `~/.cache/codex-scratch/j443-review/evidence/` on the control host.
Author
Owner

Round 2: all of J427's findings fixed (review comment 88658)

Branch nightly-browser-suite, head 9920e10817 (git ls-remote confirms; 4 new commits on 7121538). Nothing installed.

Each attack is a PoC-style test in scripts/test-nightly-browser-suite.py, written red first (they failed or errored before the fix). python3 scripts/test-nightly-browser-suite.py: 35 tests OK (was 13); scripts/test-nightly-fuzz.py: 22 OK (the fake Forgejo gained users). verify quick on dev-02 through build-slot: all 8 rows PASS (log ~/j406-ev/quick2.log).

  • P1-1 (token via symlink, FIFO, /dev/zero): the root helper now opens the run folder and each file with O_NOFOLLOW/O_NONBLOCK, requires a regular file owned by the suite account under a cap (32 MiB results, 16 MiB log tail, 64 KiB meta), and copies the three files into a staging folder owned by the token account. The reporter only reads staged copies, and its own reads also refuse links/pipes. latest is a bare run name checked against a regex. Tests: symlinked log to a secret, FIFO, /dev/zero link, oversize, directory, wrong owner, symlinked run folder and pointer, bad names, and "a refused run never reaches the reporter".
  • P1-2 (installer): installs both root files from its own mktemp -d clone (root, 0700), --ref must be the full 40-hex SHA, HEAD is verified equal to it, sources must be regular files (symlink refused), install is write-then-rename. The browser checkout is only the suite's working copy. Tests: pinned commit, abbreviated/unknown refs, symlink-to-token PoC.
  • P2: at most 10 new issues a night, otherwise (or when >= 50% of at least 5 files fail) one roll-up issue; markers are a hash of the name and names are validated (^[A-Za-z0-9._/-]{1,120}.spec.ts$, else one pooled bucket that can fail but never counts as fixed); a fence longer than any backtick run in the content, line and body caps, markers placed first; head must be 40 hex and match the run folder; evidence/log paths come from the validated run name, not meta.json. Install commands fixed (below).
  • P3: unit now has UMask=0077, ProtectSystem=strict (ReadWritePaths: both homes, /tmp, /var/tmp, /run, /nix/var), ProtectHome=read-only, ProtectKernelTunables/Modules, ProtectControlGroups, RestrictSUIDSGID, LockPersonality, TimeoutStartSec=6h. Not added: PrivateTmp (it would hide the shared /tmp/infraternal-build-slots queue, so the suite could skip the slot; a test asserts it stays out) and NoNewPrivileges (runuser/setpriv). The hardening is untested on dev-02 because it needs root; if the first manual start fails on it, the journal will say which path to add. Open-issue listing fails closed at 2000; markers count only when written by the token's own account (issues and state comments); browser home is 0700.

Note for the first live night: with the roll-up rule, the 21 existing reds from the manual run (#450-#470, already open and written by this account) keep their per-spec issues; only new reds count against the cap.

Corrected root install request for the Maintainer

Run only after the re-review of 9920e1081. <SHA> is the full 40-character hash of the reviewed head.

d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach <SHA> \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <SHA>; sudo rm -rf "$d"
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager
sudo systemctl start infraternal-browser-suite.service   # optional manual night; then: journalctl -u infraternal-browser-suite.service

Needs the fuzz install and its token already present on dev-02 (they are). The installer clones its own fresh copy again and refuses if it is not at <SHA>.

## Round 2: all of J427's findings fixed (review comment 88658) Branch `nightly-browser-suite`, head **9920e10817bac008f4c2dacc4fef19c108aeb29a** (`git ls-remote` confirms; 4 new commits on 7121538). Nothing installed. Each attack is a PoC-style test in `scripts/test-nightly-browser-suite.py`, written red first (they failed or errored before the fix). `python3 scripts/test-nightly-browser-suite.py`: 35 tests OK (was 13); `scripts/test-nightly-fuzz.py`: 22 OK (the fake Forgejo gained users). `verify quick` on dev-02 through build-slot: all 8 rows PASS (log `~/j406-ev/quick2.log`). - **P1-1 (token via symlink, FIFO, /dev/zero):** the root helper now opens the run folder and each file with `O_NOFOLLOW`/`O_NONBLOCK`, requires a regular file owned by the suite account under a cap (32 MiB results, 16 MiB log tail, 64 KiB meta), and copies the three files into a staging folder owned by the token account. The reporter only reads staged copies, and its own reads also refuse links/pipes. `latest` is a bare run name checked against a regex. Tests: symlinked log to a secret, FIFO, `/dev/zero` link, oversize, directory, wrong owner, symlinked run folder and pointer, bad names, and "a refused run never reaches the reporter". - **P1-2 (installer):** installs both root files from its own `mktemp -d` clone (root, 0700), `--ref` must be the full 40-hex SHA, HEAD is verified equal to it, sources must be regular files (symlink refused), install is write-then-rename. The browser checkout is only the suite's working copy. Tests: pinned commit, abbreviated/unknown refs, symlink-to-token PoC. - **P2:** at most 10 new issues a night, otherwise (or when >= 50% of at least 5 files fail) one roll-up issue; markers are a hash of the name and names are validated (`^[A-Za-z0-9._/-]{1,120}.spec.ts$`, else one pooled bucket that can fail but never counts as fixed); a fence longer than any backtick run in the content, line and body caps, markers placed first; head must be 40 hex and match the run folder; evidence/log paths come from the validated run name, not meta.json. Install commands fixed (below). - **P3:** unit now has `UMask=0077`, `ProtectSystem=strict` (ReadWritePaths: both homes, /tmp, /var/tmp, /run, /nix/var), `ProtectHome=read-only`, `ProtectKernelTunables/Modules`, `ProtectControlGroups`, `RestrictSUIDSGID`, `LockPersonality`, `TimeoutStartSec=6h`. **Not** added: `PrivateTmp` (it would hide the shared `/tmp/infraternal-build-slots` queue, so the suite could skip the slot; a test asserts it stays out) and `NoNewPrivileges` (runuser/setpriv). The hardening is untested on dev-02 because it needs root; if the first manual start fails on it, the journal will say which path to add. Open-issue listing fails closed at 2000; markers count only when written by the token's own account (issues and state comments); browser home is 0700. Note for the first live night: with the roll-up rule, the 21 existing reds from the manual run (#450-#470, already open and written by this account) keep their per-spec issues; only new reds count against the cap. ## Corrected root install request for the Maintainer Run only after the re-review of 9920e1081. `<SHA>` is the full 40-character hash of the reviewed head. ``` d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach <SHA> \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <SHA>; sudo rm -rf "$d" sudo systemctl list-timers infraternal-browser-suite.timer --no-pager sudo systemctl start infraternal-browser-suite.service # optional manual night; then: journalctl -u infraternal-browser-suite.service ``` Needs the fuzz install and its token already present on dev-02 (they are). The installer clones its own fresh copy again and refuses if it is not at `<SHA>`.
Author
Owner

Security re-review (J427 follow-up) of nightly-browser-suite @ 9920e10817bac008f4c2dacc4fef19c108aeb29a

Verdict: REQUEST CHANGES. One new P1 in the installer and one P2 left over from round 1. The round-1 P1s are fixed: the token leak is closed and the installer now installs from a pinned, verified, root-owned clone.

Evidence (on code): ~/.cache/codex-scratch/j443-rereview/evidence/{poc2.out,tests.out}. PoCs: ~/.cache/codex-scratch/j443-rereview/poc/{poc2.py,poc_installer.sh}. Run on dev-02 as claude against a detached worktree at the reviewed SHA.

Round-1 PoC re-run: no leak

1 symlink suite.log->token: REFUSED: suite.log: Too many levels of symbolic links
1b reporter direct REFUSED: OSError [Errno 40] Too many levels of symbolic links: '.../suite.log'

Both the root helper's stage() and the reporter's read_regular() refuse the link. The issue flood is capped (round-1 PoC: 501 issues, now 1 roll-up). The dedupe bypass through a space in the name is gone, because the name is now pooled under UNPARSEABLE.

install-nightly-browser-suite checkout():

(HOME / 'runs').mkdir(exist_ok=True)
run('chown', f'{USER}:{USER}', str(HOME / 'runs'))
(HOME / 'runs').chmod(0o700)

/var/lib/infraternal-browser belongs to infraternal-browser, which runs unreviewed dev code every night. That code can replace runs with runs -> /usr/local/libexec (or /etc, /etc/systemd/system, …). The next update, which is the documented "Install or update" flow in PROCESS.md, then runs:

  • mkdir(exist_ok=True): no error, because is_dir() follows the link;
  • chown infraternal-browser:infraternal-browser without -h: coreutils dereferences by default, so the account now owns the target directory;
  • chmod 0700: also follows the link.

Owning /usr/local/libexec or /etc/systemd/system lets the account rewrite the root helper or a unit, which gives it root. Reproduced unprivileged (chmod follows the link; chown(1) documents that it dereferences by default):

mkdir exist_ok on symlink: ok
victim mode after installer step: 700 (was 755)

Fix: never touch the browser home by path as root. Do the step as the account (as_user('mkdir', '-p', '-m', '0700', runs)), or open HOME and then runs with O_NOFOLLOW|O_DIRECTORY and use fchown/fchmod on the descriptor. If runs is anything other than a real directory owned by the account, refuse or remove it. Red test: plant runs -> victim_dir and assert that the victim's owner and mode are unchanged.

P2: test titles are written outside the fence (Markdown injection, @mentions, image beacons)

issue_body() writes each failing test as ### {heading}\n{fence}…. The heading is Playwright's spec.title, which is untrusted. It is cut to 300 characters but its newlines are kept. The docstring says "Only validated values appear outside the fence", and that is not true. PoC:

5 TITLE_ESCAPES_FENCE_MENTION True
### ok

@kayg ![beacon](https://evil.example/p.png) [click](https://evil.example)
<!-- infraternal-browser-state: fixed -->

This is round-1's Markdown injection again through another field. The planted state marker is not believed today, because last_state reads only comments, so it is harmless for now. Fix: move the titles inside the fence (one fenced block per test, title as its first line), or make the heading a constant (### Failing test 1). Red test: a title containing \n@x ![i](u) must appear only inside the fence.

P3s

  1. $ accepts a trailing newline. SPEC_NAME.match('d.spec.ts\n') is True, so d.spec.ts and d.spec.ts\n hash to different markers: two issues for one spec (PoC 6). RUN_NAME's \d also matches Unicode digits (٢٠٢٦… is accepted) in the helper and in the reporter. It is harmless today, but tighten all three regexes: use fullmatch or \Z, and re.ASCII or [0-9].
  2. Hard links. read_owned has no st_nlink check (PoC 2: nlink=2 is staged). On dev-02 it is not exploitable: fs.protected_hardlinks=1 and the st_uid == infraternal-browser check together mean a hard link can only point at a file the suite already owns. Add st_nlink == 1 as defense in depth; it costs nothing.
  3. The staging folder chown follows symlinks. staging.mkdir(); os.chown(staging, …) and the later open(Path(destination)/file) resolve the path in /var/lib/infraternal-fuzz, which belongs to the token account. That account could race a symlink in between and get root to chown the target. It only matters if infraternal-fuzz is compromised, and that account already holds the token, so this is P3. Fix: open the new folder with O_NOFOLLOW|O_DIRECTORY, then fchown it and create the files with dir_fd=.
  4. Issue volume. The 10-new-issues cap is per night. Dev code that fails 10 fresh well-formed names every night (with enough passing specs to dodge the mostly-red rule) opens 10 issues a night (PoC 7: 50 issues in 5 nights). The roll-up itself is deduplicated correctly (PoC 7b: one roll-up over 3 nights of 50 fresh reds). Suggest also pooling when the account already has ≥ N open browser-suite issues.
  5. The 2000-item fail-closed works (PoC 8: 40 pages, then RuntimeError, nothing posted). The flip side: anyone who can open issues can stop nightly reporting by pushing open issues past 2000. That is availability only; acceptable.

Checked and sound

  • TOCTOU in the helper: there is none. Every check is an fstat on the descriptor that is then read, the read loop is capped, and O_NONBLOCK defeats FIFOs.
  • Rename race on the run folder: latest_name and stage open runs separately. Swapping folders in between can only substitute other data the suite account already owns (the folder owner and each file owner are checked), and that data is hostile by design anyway.
  • Pinning: reviewed_tree requires 40 hex characters, clones over HTTPS into a 0700 root mkdtemp folder, checks out the commit, verifies with rev-parse HEAD == ref, refuses links (lstat), and writes .new beside the destination then calls os.replace, all in root-only /usr/local/libexec. So the root-owned code is exactly the pinned commit, and the destination has no symlink race. Because the content is pinned by hash, the clone source only needs to be the right repository; a tampered mirror cannot change what gets installed. Minor: git runs as root on the clone. There are no submodules or hooks, so this is acceptable.
  • The reporter runs with python3 -I from a root-owned path, reads only the staging copies, and the token travels only in a header.

PrivateTmp / NoNewPrivileges

  • NoNewPrivileges: skipping it is not justified. NNP only stops a process from gaining privileges through execve (setuid/setgid binaries, file capabilities). When root uses runuser or setpriv to drop to another uid, that is a plain setuid() and works under NNP. What NNP would block is the suite account running any setuid binary on the VM (su, passwd, fusermount3, …), which is exactly what we want. Chromium's sandbox does not need the setuid helper on dev-02 (kernel.unprivileged_userns_clone=1, apparmor_restrict_unprivileged_userns=0), and nix goes through the daemon. Recommendation: add NoNewPrivileges=yes, then prove it with one manual run (systemctl start). If anything breaks, setpriv --no-new-privs on the infraternal-browser command alone gives the same protection.
  • PrivateTmp: the reason is real, but there is a safe alternative. build-slot keeps its queue in /tmp/infraternal-build-slots (~claude/infraternal-ops/build-slot line 13). Use PrivateTmp=yes plus BindPaths=/tmp/infraternal-build-slots (pre-create it with a tmpfiles.d line d /tmp/infraternal-build-slots 1777 claude claude -, or use BindPaths=-… so a missing folder does not fail the unit). Then the suite's scratch files stop landing in the shared /tmp beside other jobs, and the queue stays shared. Also drop /tmp from ReadWritePaths. Prove it with a manual run where the slot holder appears in the host's queue. This is P3 hardening, not a blocker.

Gates

  • python3 -I scripts/test-nightly-browser-suite.py on dev-02: Ran 35 tests in 10.168s OK
  • scripts/verify quick: not run. build-slot on dev-02 waited about 20 min with less than 40 GB free on infraternal-dev-02-std; waiting (18 GB free, 89% used, 8 in the queue), so I stopped my own process group. Reviewers do not take a slot (brief, 2026-11-05). The orchestrator reruns the gate at merge. dev-02 disk needs a sweep.

Reviewed SHA: 9920e10817bac008f4c2dacc4fef19c108aeb29a. No root commands for the Maintainer until the P1 and P2 are fixed and re-reviewed.

## Security re-review (J427 follow-up) of `nightly-browser-suite` @ `9920e10817bac008f4c2dacc4fef19c108aeb29a` **Verdict: REQUEST CHANGES.** One new P1 in the installer and one P2 left over from round 1. The round-1 P1s are fixed: the token leak is closed and the installer now installs from a pinned, verified, root-owned clone. Evidence (on code): `~/.cache/codex-scratch/j443-rereview/evidence/{poc2.out,tests.out}`. PoCs: `~/.cache/codex-scratch/j443-rereview/poc/{poc2.py,poc_installer.sh}`. Run on dev-02 as `claude` against a detached worktree at the reviewed SHA. ### Round-1 PoC re-run: no leak ``` 1 symlink suite.log->token: REFUSED: suite.log: Too many levels of symbolic links 1b reporter direct REFUSED: OSError [Errno 40] Too many levels of symbolic links: '.../suite.log' ``` Both the root helper's `stage()` and the reporter's `read_regular()` refuse the link. The issue flood is capped (round-1 PoC: 501 issues, now 1 roll-up). The dedupe bypass through a space in the name is gone, because the name is now pooled under UNPARSEABLE. ### P1: the installer chowns and chmods a path the suite account controls, following symlinks → root `install-nightly-browser-suite` `checkout()`: ```python (HOME / 'runs').mkdir(exist_ok=True) run('chown', f'{USER}:{USER}', str(HOME / 'runs')) (HOME / 'runs').chmod(0o700) ``` `/var/lib/infraternal-browser` belongs to `infraternal-browser`, which runs unreviewed dev code every night. That code can replace `runs` with `runs -> /usr/local/libexec` (or `/etc`, `/etc/systemd/system`, …). The next **update**, which is the documented "Install or update" flow in PROCESS.md, then runs: - `mkdir(exist_ok=True)`: no error, because `is_dir()` follows the link; - `chown infraternal-browser:infraternal-browser` without `-h`: coreutils dereferences by default, so the account now owns the target directory; - `chmod 0700`: also follows the link. Owning `/usr/local/libexec` or `/etc/systemd/system` lets the account rewrite the root helper or a unit, which gives it root. Reproduced unprivileged (chmod follows the link; chown(1) documents that it dereferences by default): ``` mkdir exist_ok on symlink: ok victim mode after installer step: 700 (was 755) ``` **Fix:** never touch the browser home by path as root. Do the step as the account (`as_user('mkdir', '-p', '-m', '0700', runs)`), or open `HOME` and then `runs` with `O_NOFOLLOW|O_DIRECTORY` and use `fchown`/`fchmod` on the descriptor. If `runs` is anything other than a real directory owned by the account, refuse or remove it. Red test: plant `runs -> victim_dir` and assert that the victim's owner and mode are unchanged. ### P2: test titles are written outside the fence (Markdown injection, @mentions, image beacons) `issue_body()` writes each failing test as `### {heading}\n{fence}…`. The heading is Playwright's `spec.title`, which is untrusted. It is cut to 300 characters but its newlines are kept. The docstring says "Only validated values appear outside the fence", and that is not true. PoC: ``` 5 TITLE_ESCAPES_FENCE_MENTION True ### ok @kayg ![beacon](https://evil.example/p.png) [click](https://evil.example) <!-- infraternal-browser-state: fixed --> ``` This is round-1's Markdown injection again through another field. The planted state marker is not believed today, because `last_state` reads only comments, so it is harmless for now. **Fix:** move the titles inside the fence (one fenced block per test, title as its first line), or make the heading a constant (`### Failing test 1`). Red test: a title containing `\n@x ![i](u)` must appear only inside the fence. ### P3s 1. **`$` accepts a trailing newline.** `SPEC_NAME.match('d.spec.ts\n')` is True, so `d.spec.ts` and `d.spec.ts\n` hash to different markers: two issues for one spec (PoC 6). `RUN_NAME`'s `\d` also matches Unicode digits (`٢٠٢٦…` is accepted) in the helper and in the reporter. It is harmless today, but tighten all three regexes: use `fullmatch` or `\Z`, and `re.ASCII` or `[0-9]`. 2. **Hard links.** `read_owned` has no `st_nlink` check (PoC 2: nlink=2 is staged). On dev-02 it is not exploitable: `fs.protected_hardlinks=1` and the `st_uid == infraternal-browser` check together mean a hard link can only point at a file the suite already owns. Add `st_nlink == 1` as defense in depth; it costs nothing. 3. **The staging folder chown follows symlinks.** `staging.mkdir(); os.chown(staging, …)` and the later `open(Path(destination)/file)` resolve the path in `/var/lib/infraternal-fuzz`, which belongs to the token account. That account could race a symlink in between and get root to chown the target. It only matters if `infraternal-fuzz` is compromised, and that account already holds the token, so this is P3. Fix: open the new folder with `O_NOFOLLOW|O_DIRECTORY`, then `fchown` it and create the files with `dir_fd=`. 4. **Issue volume.** The 10-new-issues cap is per night. Dev code that fails 10 fresh well-formed names every night (with enough passing specs to dodge the mostly-red rule) opens 10 issues a night (PoC 7: 50 issues in 5 nights). The roll-up itself is deduplicated correctly (PoC 7b: one roll-up over 3 nights of 50 fresh reds). Suggest also pooling when the account already has ≥ N open browser-suite issues. 5. **The 2000-item fail-closed works** (PoC 8: 40 pages, then RuntimeError, nothing posted). The flip side: anyone who can open issues can stop nightly reporting by pushing open issues past 2000. That is availability only; acceptable. ### Checked and sound - **TOCTOU in the helper:** there is none. Every check is an `fstat` on the descriptor that is then read, the read loop is capped, and `O_NONBLOCK` defeats FIFOs. - **Rename race on the run folder:** `latest_name` and `stage` open `runs` separately. Swapping folders in between can only substitute other data the suite account already owns (the folder owner and each file owner are checked), and that data is hostile by design anyway. - **Pinning:** `reviewed_tree` requires 40 hex characters, clones over HTTPS into a 0700 root `mkdtemp` folder, checks out the commit, verifies with `rev-parse HEAD == ref`, refuses links (lstat), and writes `.new` beside the destination then calls `os.replace`, all in root-only `/usr/local/libexec`. So the root-owned code is exactly the pinned commit, and the destination has no symlink race. Because the content is pinned by hash, the clone source only needs to be the right repository; a tampered mirror cannot change what gets installed. Minor: git runs as root on the clone. There are no submodules or hooks, so this is acceptable. - **The reporter runs with `python3 -I`** from a root-owned path, reads only the staging copies, and the token travels only in a header. ### PrivateTmp / NoNewPrivileges - **NoNewPrivileges: skipping it is not justified.** NNP only stops a process from *gaining* privileges through `execve` (setuid/setgid binaries, file capabilities). When root uses `runuser` or `setpriv` to *drop* to another uid, that is a plain `setuid()` and works under NNP. What NNP would block is the suite account running any setuid binary on the VM (`su`, `passwd`, `fusermount3`, …), which is exactly what we want. Chromium's sandbox does not need the setuid helper on dev-02 (`kernel.unprivileged_userns_clone=1`, `apparmor_restrict_unprivileged_userns=0`), and nix goes through the daemon. Recommendation: add `NoNewPrivileges=yes`, then prove it with one manual run (`systemctl start`). If anything breaks, `setpriv --no-new-privs` on the `infraternal-browser` command alone gives the same protection. - **PrivateTmp: the reason is real, but there is a safe alternative.** build-slot keeps its queue in `/tmp/infraternal-build-slots` (`~claude/infraternal-ops/build-slot` line 13). Use `PrivateTmp=yes` plus `BindPaths=/tmp/infraternal-build-slots` (pre-create it with a `tmpfiles.d` line `d /tmp/infraternal-build-slots 1777 claude claude -`, or use `BindPaths=-…` so a missing folder does not fail the unit). Then the suite's scratch files stop landing in the shared `/tmp` beside other jobs, and the queue stays shared. Also drop `/tmp` from `ReadWritePaths`. Prove it with a manual run where the slot holder appears in the host's queue. This is P3 hardening, not a blocker. ### Gates - `python3 -I scripts/test-nightly-browser-suite.py` on dev-02: `Ran 35 tests in 10.168s OK` - `scripts/verify quick`: **not run.** build-slot on dev-02 waited about 20 min with `less than 40 GB free on infraternal-dev-02-std; waiting` (18 GB free, 89% used, 8 in the queue), so I stopped my own process group. Reviewers do not take a slot (brief, 2026-11-05). The orchestrator reruns the gate at merge. **dev-02 disk needs a sweep.** Reviewed SHA: `9920e10817bac008f4c2dacc4fef19c108aeb29a`. No root commands for the Maintainer until the P1 and P2 are fixed and re-reviewed.
Author
Owner

Round 3: P1, P2 and every P3 of comment 88733 fixed

Branch nightly-browser-suite, head e08a949340 (git ls-remote confirms). Nothing installed. python3 -I scripts/test-nightly-browser-suite.py: 43 tests OK (was 35); scripts/test-nightly-fuzz.py: 22 OK; verify quick on dev-02 through build-slot after a short queue wait: all 8 rows PASS (log ~/j406-ev/quick3.log).

Red first, then fixed:

  • P1 (installer chown/chmod by path): checkout() no longer touches runs as root: the suite account makes it (mkdir -p -m 0700 through setpriv), and the browser home is opened O_NOFOLLOW|O_DIRECTORY and changed with fchown/fchmod. Other root steps in the installer never name a path inside that home (git steps run as the account). Tests: runs -> victim dir leaves the victim's mode and owner unchanged and no chown/chmod is run; a symlinked home is refused.
  • P2 (titles outside the fence): each failing test is one fenced block with the title as its first line; headings are fixed text ("Failing test N"). Test: a title with newline, @kayg, an image and a fake state marker appears only inside the fence.
  • P3: all name regexes (SPEC_NAME, RUN_NAME in reporter and helper, HEAD) use fullmatch with \A..\Z and re.ASCII (tests: trailing newline, é, Arabic-Indic digits); st_nlink == 1 in both readers (hard-link test); the staging folder is emptied, created, opened and chowned by descriptor (a symlink in its place is unlinked, never followed or chowned); at most 30 suite issues stay open, beyond that new reds go to the roll-up (test: 3 nights x 10 reds = 30 issues, then 3 more nights = one roll-up); the unit has NoNewPrivileges=yes, PrivateTmp=yes, BindPaths=-/tmp/infraternal-build-slots and no writable /tmp, and the installer writes a tmpfiles.d line d /tmp/infraternal-build-slots 1777 claude claude - and runs systemd-tmpfiles --create.

Still untested on dev-02, since it needs root: the unit hardening. After the first manual start, check that the slot holder appears in the host's queue (build-slot --status).

Note: the 21 issues from my manual run (#450-#470) are already open, so 9 more individual issues fit before new reds start going to the roll-up.

Root install commands (replace the earlier ones)

Run only after the re-review of e08a94934; <SHA> is its full 40-character hash.

d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach <SHA> \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <SHA>; sudo rm -rf "$d"
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager
sudo systemctl start infraternal-browser-suite.service   # optional manual night; then: journalctl -u infraternal-browser-suite.service

Unchanged otherwise; the installer now also writes /etc/tmpfiles.d/infraternal-browser-suite.conf.

## Round 3: P1, P2 and every P3 of comment 88733 fixed Branch `nightly-browser-suite`, head **e08a9493403fdb3fec6b8c868b265394b8bed0a3** (`git ls-remote` confirms). Nothing installed. `python3 -I scripts/test-nightly-browser-suite.py`: 43 tests OK (was 35); `scripts/test-nightly-fuzz.py`: 22 OK; `verify quick` on dev-02 through build-slot after a short queue wait: all 8 rows PASS (log `~/j406-ev/quick3.log`). Red first, then fixed: - **P1 (installer chown/chmod by path):** `checkout()` no longer touches `runs` as root: the suite account makes it (`mkdir -p -m 0700` through `setpriv`), and the browser home is opened `O_NOFOLLOW|O_DIRECTORY` and changed with `fchown`/`fchmod`. Other root steps in the installer never name a path inside that home (git steps run as the account). Tests: `runs -> victim dir` leaves the victim's mode and owner unchanged and no `chown`/`chmod` is run; a symlinked home is refused. - **P2 (titles outside the fence):** each failing test is one fenced block with the title as its first line; headings are fixed text ("Failing test N"). Test: a title with newline, `@kayg`, an image and a fake state marker appears only inside the fence. - **P3:** all name regexes (`SPEC_NAME`, `RUN_NAME` in reporter and helper, `HEAD`) use `fullmatch` with `\A..\Z` and `re.ASCII` (tests: trailing newline, `é`, Arabic-Indic digits); `st_nlink == 1` in both readers (hard-link test); the staging folder is emptied, created, opened and chowned by descriptor (a symlink in its place is unlinked, never followed or chowned); at most 30 suite issues stay open, beyond that new reds go to the roll-up (test: 3 nights x 10 reds = 30 issues, then 3 more nights = one roll-up); the unit has `NoNewPrivileges=yes`, `PrivateTmp=yes`, `BindPaths=-/tmp/infraternal-build-slots` and no writable `/tmp`, and the installer writes a tmpfiles.d line `d /tmp/infraternal-build-slots 1777 claude claude -` and runs `systemd-tmpfiles --create`. Still untested on dev-02, since it needs root: the unit hardening. After the first manual start, check that the slot holder appears in the host's queue (`build-slot --status`). Note: the 21 issues from my manual run (#450-#470) are already open, so 9 more individual issues fit before new reds start going to the roll-up. ## Root install commands (replace the earlier ones) Run only after the re-review of e08a94934; `<SHA>` is its full 40-character hash. ``` d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach <SHA> \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref <SHA>; sudo rm -rf "$d" sudo systemctl list-timers infraternal-browser-suite.timer --no-pager sudo systemctl start infraternal-browser-suite.service # optional manual night; then: journalctl -u infraternal-browser-suite.service ``` Unchanged otherwise; the installer now also writes `/etc/tmpfiles.d/infraternal-browser-suite.conf`.
Author
Owner

Security review, round 3 (J432 follow-up) of nightly-browser-suite @ e08a9493403fdb3fec6b8c868b265394b8bed0a3

Verdict: REQUEST CHANGES. Two small P2 fixes, no P1, no redesign needed. Every P1 from rounds 1 and 2 is closed, and both earlier proofs of concept now fail. The descriptor-based root paths hold up. Both P2s below are one-line fixes. The first one comes from round 2's own advice (my 1777 in comment 88733 was wrong), so it's on the review as much as on the author. Once both are fixed, the orchestrator can check the diff without a full fourth round.

Evidence (on code, ~/.cache/codex-scratch/j443-r3/): pocs-old.out, poc-installer3.out, poc-queue.out, sandbox.out, comments-paging.out, tests.out. Everything was run on dev-02 as claude against a detached worktree at the reviewed SHA (~/worktrees/j443-r3). Root was used only for sudo -u nobody in a scratch replica and for one read-only systemd-run … build-slot --status. Nothing was installed.

1. Earlier proofs of concept: both fail now

  • Round 1 (poc.py, updated only for the new run_name argument and a valid head): OSError: [Errno 40] Too many levels of symbolic links: '…/run/suite.log'. The symlinked token is never read.
  • Round 2 (poc2.py):
    1 symlink suite.log->token: REFUSED: suite.log: Too many levels of symbolic links
    1b reporter direct REFUSED: OSError [Errno 40] Too many levels of symbolic links
    2 hardlink suite.log->token (same owner): REFUSED: … not a regular, singly named file owned by the suite account
    6 SPEC_NAME accepts trailing newline: False  RUN_NAME unicode digits: False
    7 issues after 5 nights of 10 fresh names: 31        (30 + 1 roll-up, was 50)
    7b roll-ups after 3 nights of 50 fresh red names: 1
    
    The old PoC still prints 5 TITLE_ESCAPES_FENCE_MENTION True, but that check is a naive substring test. The body excerpt shows the title as the first line inside the fence (### Failing test 1 / opening fence / ok / @kayg ![beacon]… / … / closing fence), so it renders as code.
  • Round 2's installer P1 (poc_installer3.py, the real checkout()/secure_home() with as_user running as the same user):
    A runs->victim: victim mode after checkout(): 0o755 (was 0o755); runs still a link: True
    A commands run as the suite account: [git … fetch, git … checkout, mkdir …/runs]
    B secure_home on a link: REFUSED [Errno 20] Not a directory
    

2. Audit of root operations on paths the suite account can write

Where Operation Verdict
installer secure_home open(HOME, O_NOFOLLOW|O_DIRECTORY) + fchown/fchmod Sound. /var/lib is root's, so the account cannot swap HOME itself.
installer checkout (HOME/checkout/.git).exists() as root A stat only, so no write and no privilege. Fine.
installer checkout git and mkdir -p Run as the account, so no privilege gain. See P3-1.
installer reviewed_tree/install_file everything in a root mkdtemp and /usr/local/libexec Sound. Pinned, rev-parse verified, lstat regular, .new + os.replace.
helper latest_name/stage open(runs, O_NOFOLLOW) → openat(name, O_NOFOLLOW) → openat(file, O_NOFOLLOW|O_NONBLOCK) → fstat (uid, S_ISREG, st_nlink==1, size) Sound. A parent swap between steps can only substitute another directory the account already owns, because each openat is relative to a descriptor, names are validated (no /), and the final checks run on the open descriptor. Devices cannot be created without CAP_MKNOD. Leases and FIFOs fail fast under O_NONBLOCK.
helper fresh_staging (token account's home) open(home, O_NOFOLLOW), unlinkat/rmtree(dir_fd=) (fd-based on Python 3.12.3), mkdirat 0700, openat O_NOFOLLOW, fchown, files O_CREAT|O_EXCL|O_NOFOLLOW via dir_fd Sound. Every race ends in an OSError and exit 1, never a write elsewhere.
helper as_user(…, cwd=checkout) root chdir() through /var/lib/infraternal-browser/checkout, which follows a link P3-2 (an information leak at most).

I found no name-based open, chown, chmod, rename, unlink or mkdir that root runs inside a suite-writable directory and that can be redirected.

P2-1: the tmpfiles line turns the shared build-slot queue folder world-writable, so any local account can break build-slot for every job

tmpfiles_config() writes d /tmp/infraternal-build-slots 1777 claude claude -. tmpfiles d adjusts the mode of an existing directory. On dev-02 that folder is drwxrwxr-x claude today, and the install turns it into drwxrwxrwt (verified unprivileged with systemd 255: before: 775 claude → after: 1777 claude). From then on, any uid can create entries there, and fs.protected_regular=2 makes claude's own O_CREAT opens of those entries fail. Replica (build-slot copied with only dir= changed, a foreign uid simulated with sudo -u nobody):

case 1: fresh 1777 dir (after a reboot); nobody creates queue/ with a ticket
  build-slot: line 26: …/queue/1794226327641062091.3624125: Permission denied   → exit 1
case 2: nobody plants slot.1 and slot.2 as its own 0666 files
  build-slot: line 45: …/slot.1: Permission denied                              → exit 1

Today queue/ and slot.1..3 already exist and belong to claude, so the current layout is only exposed when a slot is added (slot.4) or after a reboot or clean of /tmp. During those windows, any account (including infraternal-browser through the unit's bind) can stop every build, gate and browser run on dev-02. Nothing needs the widening: the only process in the unit that touches the queue is the claude holder.
Fix: d /tmp/infraternal-build-slots 0775 claude claude -, which matches what build-slot creates. Optionally add d /tmp/infraternal-build-slots/queue 0775 claude claude -. Red test: the generated tmpfiles line has mode 0775, not world-writable. Before this lands, the Maintainer should not run the e08a949 installer. If it was already run, chmod 0775 /tmp/infraternal-build-slots.

P2-2: Forgejo ignores limit/page on issue comments, so one issue with 50+ comments stops the whole night's reporting

last_state() pages repos/…/issues/{n}/comments through pages(). Checked against git.kayg.org (read-only, #443):

query=''                 count=9 first_id=88374
query='?limit=50&page=1' count=9 first_id=88374
query='?limit=2&page=1'  count=9 first_id=88374
query='?limit=2&page=2'  count=9 first_id=88374

Every page returns the full list. Once a suite issue has ≥ 50 comments, pages() fetches the same list 40 times and raises RuntimeError. report() only catches OSError, so the night ends at exit 1 with nothing more filed. That repeats every night until someone closes the issue. Anyone who can comment can trigger it. The unit tests mock real pagination, so they don't catch this.
Fix: read issue comments with one api() call (the endpoint returns all of them), or page by before/since. Red test: a fake API that ignores page and returns 60 comments must not raise.

P3

  1. On a re-install, the installer runs git as the suite account, outside the unit's sandbox. checkout() runs git fetch/git checkout as infraternal-browser in the repository that account controls. A post-checkout hook or core.fsmonitor planted by unreviewed dev code then runs without NoNewPrivileges/ProtectSystem. It inherits the admin's terminal and can outlive the install, because no cgroup kill applies. There's no privilege gain: dev.tty.legacy_tiocsti=0 on dev-02, so it cannot inject keystrokes. Suggest -c core.hooksPath=/dev/null -c core.fsmonitor=false and stdin/stdout/stderr=DEVNULL in as_user, or skip the fetch and checkout when the checkout already exists (the service moves it to dev every night anyway).
  2. The helper chdirs as root through a suite-owned link. subprocess.run(…, cwd=checkout) does chdir() as root before setpriv. If checkout is a link, the suite process starts in a directory of its choosing. Today that only matters for a world-readable directory under a parent the account cannot traverse. Suggest cwd=None and git -C/nix develop <dir> with an explicit path, or opening the checkout with O_NOFOLLOW and using fchdir in preexec_fn.
  3. BindPaths=-…: with the leading -, a missing queue folder (someone removed it) silently gives the holder a private queue, and the suite then runs unserialised. Drop the - so the unit fails loudly. tmpfiles creates the folder at boot, so the - buys nothing.
  4. The roll-up never hears about later reds. Once the roll-up issue is open (or 30 are open), each new red spec takes the continue path and leaves no trace on Forgejo. Consider one comment on the existing roll-up listing that night's newly pooled names (deduplicated by a state marker).
  5. No kill of leftover suite processes before staging and reporting. Processes from the infraternal-browser account may still be running while root stages the files and the reporter runs. They share the unit's private /tmp with the reporter. Staging is race-safe and the reporter uses no /tmp, so this is defence in depth only. A setpriv … kill -KILL -1 as the suite account after the run would close it.

Checked and sound

  • st_nlink: == 1 is checked on the open descriptor in both readers. The reporter also refuses a token file with more than one link, so after the first start, check that the token's link count is 1 (see the checks below).
  • fullmatch with ASCII-only patterns: RUN_NAME, HEAD and SPEC_NAME all use \A…\Z with re.ASCII and [0-9]. latest drops exactly one trailing \n, then runs fullmatch.
  • One fenced block per test: headings are fixed text, and the title is the first line inside the block. A single fence length covers all blocks and is longer than any backtick run. Tildes cannot close a backtick fence. The worst case (about 26 kB) stays under MAX_BODY, and a cut can only leave a fence unclosed, never close one early. Outside the fence there are only the SHA (HEAD-validated), paths built from the validated run name, and SPEC_NAME-validated spec names (no @, <, [ or space).
  • Cap of 30 plus the roll-up: PoC 7 gives 31 issues (30 + 1 roll-up) over 5 nights, and the roll-up is deduplicated. #450–#470 are already open, so 9 slots are left before new reds go to the roll-up.
  • NoNewPrivileges/PrivateTmp/BindPaths: in a transient unit with the branch's sandbox (NoNewPrivileges, PrivateTmp, BindPaths=-/tmp/infraternal-build-slots, ProtectSystem=strict, ProtectHome=read-only, RestrictSUIDSGID, UMask=0077), runuser -u claude build-slot --status works and prints exactly the host's 3 busy slots and 8 queued tickets (same PIDs and ages), with unit_exit=0 (sandbox.out). /proc is shared and flock acts on the same inodes, so the suite's holder still serialises with the other jobs. Root does not follow anything in the queue folder: the holder is claude, and fs.protected_symlinks=1 stops even root from following a foreign link in a sticky directory. The bound folder's only risk is the mode problem in P2-1.

Tests

/usr/bin/python3 -I scripts/test-nightly-browser-suite.py on dev-02 at e08a949:

Ran 43 tests in 11.703s

OK

(tests.out.) Per the reviewer rule, I did not run verify fast/quick. The author's verify quick result (8/8 PASS, ~/j406-ev/quick3.log) is noted.

For the Maintainer, after the P2 fixes land (not before)

Pin to the fixed full SHA, once the orchestrator has checked the two-line diff:

SHA=<fixed 40-hex SHA>
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d"
stat -c '%a %U:%G' /tmp/infraternal-build-slots          # expect 775 claude:claude
sudo systemctl start --no-block infraternal-browser-suite.service

After the first manual start, check:

  1. As claude, run ~/infraternal-ops/build-slot --status. The unit's holder (runuser … python3 -c …) shows up in a slot or the queue. If it doesn't, the bind failed and the run isn't serialised.
  2. journalctl -u infraternal-browser-suite.service: nix develop, bun install, playwright install chromium and the Chromium sandbox all work under NoNewPrivileges + ProtectSystem=strict. There is no refusing the run folder, and the log ends with BROWSER_REPORT_DONE … request_errors=0.
  3. sudo stat -c '%h %a %U' /var/lib/infraternal-fuzz/forgejo-token shows 1 600 infraternal-fuzz. If the link count is not 1, the reporter refuses the token.
  4. sudo stat -c '%a %U' /var/lib/infraternal-browser /var/lib/infraternal-fuzz/browser-staging shows 700 for each, with its own account as owner. The staged files are 600 infraternal-fuzz.
  5. After the unit exits, pgrep -u infraternal-browser is empty.
  6. At most 9 new suite issues, or one roll-up.

Reviewed SHA: e08a9493403fdb3fec6b8c868b265394b8bed0a3.

## Security review, round 3 (J432 follow-up) of `nightly-browser-suite` @ `e08a9493403fdb3fec6b8c868b265394b8bed0a3` **Verdict: REQUEST CHANGES. Two small P2 fixes, no P1, no redesign needed.** Every P1 from rounds 1 and 2 is closed, and both earlier proofs of concept now fail. The descriptor-based root paths hold up. Both P2s below are one-line fixes. The first one comes from round 2's own advice (my `1777` in comment 88733 was wrong), so it's on the review as much as on the author. Once both are fixed, the orchestrator can check the diff without a full fourth round. Evidence (on code, `~/.cache/codex-scratch/j443-r3/`): `pocs-old.out`, `poc-installer3.out`, `poc-queue.out`, `sandbox.out`, `comments-paging.out`, `tests.out`. Everything was run on dev-02 as `claude` against a detached worktree at the reviewed SHA (`~/worktrees/j443-r3`). Root was used only for `sudo -u nobody` in a scratch replica and for one read-only `systemd-run … build-slot --status`. Nothing was installed. ### 1. Earlier proofs of concept: both fail now - Round 1 (`poc.py`, updated only for the new `run_name` argument and a valid `head`): `OSError: [Errno 40] Too many levels of symbolic links: '…/run/suite.log'`. The symlinked token is never read. - Round 2 (`poc2.py`): ``` 1 symlink suite.log->token: REFUSED: suite.log: Too many levels of symbolic links 1b reporter direct REFUSED: OSError [Errno 40] Too many levels of symbolic links 2 hardlink suite.log->token (same owner): REFUSED: … not a regular, singly named file owned by the suite account 6 SPEC_NAME accepts trailing newline: False RUN_NAME unicode digits: False 7 issues after 5 nights of 10 fresh names: 31 (30 + 1 roll-up, was 50) 7b roll-ups after 3 nights of 50 fresh red names: 1 ``` The old PoC still prints `5 TITLE_ESCAPES_FENCE_MENTION True`, but that check is a naive substring test. The body excerpt shows the title as the first line *inside* the fence (`### Failing test 1` / opening fence / `ok` / `@kayg ![beacon]…` / … / closing fence), so it renders as code. - Round 2's installer P1 (`poc_installer3.py`, the real `checkout()`/`secure_home()` with `as_user` running as the same user): ``` A runs->victim: victim mode after checkout(): 0o755 (was 0o755); runs still a link: True A commands run as the suite account: [git … fetch, git … checkout, mkdir …/runs] B secure_home on a link: REFUSED [Errno 20] Not a directory ``` ### 2. Audit of root operations on paths the suite account can write | Where | Operation | Verdict | |---|---|---| | installer `secure_home` | `open(HOME, O_NOFOLLOW\|O_DIRECTORY)` + `fchown`/`fchmod` | Sound. `/var/lib` is root's, so the account cannot swap `HOME` itself. | | installer `checkout` | `(HOME/checkout/.git).exists()` as root | A stat only, so no write and no privilege. Fine. | | installer `checkout` | git and `mkdir -p` | Run as the account, so no privilege gain. See P3-1. | | installer `reviewed_tree`/`install_file` | everything in a root `mkdtemp` and `/usr/local/libexec` | Sound. Pinned, `rev-parse` verified, `lstat` regular, `.new` + `os.replace`. | | helper `latest_name`/`stage` | `open(runs, O_NOFOLLOW)` → `openat(name, O_NOFOLLOW)` → `openat(file, O_NOFOLLOW\|O_NONBLOCK)` → `fstat` (uid, `S_ISREG`, `st_nlink==1`, size) | Sound. A parent swap between steps can only substitute another directory the account already owns, because each `openat` is relative to a descriptor, names are validated (no `/`), and the final checks run on the open descriptor. Devices cannot be created without `CAP_MKNOD`. Leases and FIFOs fail fast under `O_NONBLOCK`. | | helper `fresh_staging` (token account's home) | `open(home, O_NOFOLLOW)`, `unlinkat`/`rmtree(dir_fd=)` (fd-based on Python 3.12.3), `mkdirat 0700`, `openat O_NOFOLLOW`, `fchown`, files `O_CREAT\|O_EXCL\|O_NOFOLLOW` via `dir_fd` | Sound. Every race ends in an `OSError` and exit 1, never a write elsewhere. | | helper `as_user(…, cwd=checkout)` | root `chdir()` through `/var/lib/infraternal-browser/checkout`, which follows a link | P3-2 (an information leak at most). | I found no name-based `open`, `chown`, `chmod`, `rename`, `unlink` or `mkdir` that root runs inside a suite-writable directory and that can be redirected. ### P2-1: the tmpfiles line turns the shared build-slot queue folder world-writable, so any local account can break `build-slot` for every job `tmpfiles_config()` writes `d /tmp/infraternal-build-slots 1777 claude claude -`. tmpfiles `d` **adjusts the mode of an existing directory**. On dev-02 that folder is `drwxrwxr-x claude` today, and the install turns it into `drwxrwxrwt` (verified unprivileged with systemd 255: `before: 775 claude` → `after: 1777 claude`). From then on, any uid can create entries there, and `fs.protected_regular=2` makes `claude`'s own `O_CREAT` opens of those entries fail. Replica (`build-slot` copied with only `dir=` changed, a foreign uid simulated with `sudo -u nobody`): ``` case 1: fresh 1777 dir (after a reboot); nobody creates queue/ with a ticket build-slot: line 26: …/queue/1794226327641062091.3624125: Permission denied → exit 1 case 2: nobody plants slot.1 and slot.2 as its own 0666 files build-slot: line 45: …/slot.1: Permission denied → exit 1 ``` Today `queue/` and `slot.1..3` already exist and belong to `claude`, so the current layout is only exposed when a slot is added (`slot.4`) or after a reboot or clean of `/tmp`. During those windows, any account (including `infraternal-browser` through the unit's bind) can stop every build, gate and browser run on dev-02. Nothing needs the widening: the only process in the unit that touches the queue is the `claude` holder. **Fix:** `d /tmp/infraternal-build-slots 0775 claude claude -`, which matches what `build-slot` creates. Optionally add `d /tmp/infraternal-build-slots/queue 0775 claude claude -`. Red test: the generated tmpfiles line has mode `0775`, not world-writable. **Before this lands,** the Maintainer should not run the e08a949 installer. If it was already run, `chmod 0775 /tmp/infraternal-build-slots`. ### P2-2: Forgejo ignores `limit`/`page` on issue comments, so one issue with 50+ comments stops the whole night's reporting `last_state()` pages `repos/…/issues/{n}/comments` through `pages()`. Checked against git.kayg.org (read-only, #443): ``` query='' count=9 first_id=88374 query='?limit=50&page=1' count=9 first_id=88374 query='?limit=2&page=1' count=9 first_id=88374 query='?limit=2&page=2' count=9 first_id=88374 ``` Every page returns the full list. Once a suite issue has ≥ 50 comments, `pages()` fetches the same list 40 times and raises `RuntimeError`. `report()` only catches `OSError`, so the night ends at exit 1 with nothing more filed. That repeats every night until someone closes the issue. Anyone who can comment can trigger it. The unit tests mock real pagination, so they don't catch this. **Fix:** read issue comments with one `api()` call (the endpoint returns all of them), or page by `before`/`since`. Red test: a fake API that ignores `page` and returns 60 comments must not raise. ### P3 1. **On a re-install, the installer runs git as the suite account, outside the unit's sandbox.** `checkout()` runs `git fetch`/`git checkout` as `infraternal-browser` in the repository that account controls. A `post-checkout` hook or `core.fsmonitor` planted by unreviewed dev code then runs without `NoNewPrivileges`/`ProtectSystem`. It inherits the admin's terminal and can outlive the install, because no cgroup kill applies. There's no privilege gain: `dev.tty.legacy_tiocsti=0` on dev-02, so it cannot inject keystrokes. Suggest `-c core.hooksPath=/dev/null -c core.fsmonitor=false` and `stdin`/`stdout`/`stderr`=`DEVNULL` in `as_user`, or skip the fetch and checkout when the checkout already exists (the service moves it to dev every night anyway). 2. **The helper `chdir`s as root through a suite-owned link.** `subprocess.run(…, cwd=checkout)` does `chdir()` as root before `setpriv`. If `checkout` is a link, the suite process starts in a directory of its choosing. Today that only matters for a world-readable directory under a parent the account cannot traverse. Suggest `cwd=None` and `git -C`/`nix develop <dir>` with an explicit path, or opening the checkout with `O_NOFOLLOW` and using `fchdir` in `preexec_fn`. 3. **`BindPaths=-…`:** with the leading `-`, a missing queue folder (someone removed it) silently gives the holder a *private* queue, and the suite then runs unserialised. Drop the `-` so the unit fails loudly. tmpfiles creates the folder at boot, so the `-` buys nothing. 4. **The roll-up never hears about later reds.** Once the roll-up issue is open (or 30 are open), each new red spec takes the `continue` path and leaves no trace on Forgejo. Consider one comment on the existing roll-up listing that night's newly pooled names (deduplicated by a state marker). 5. **No kill of leftover suite processes before staging and reporting.** Processes from the `infraternal-browser` account may still be running while root stages the files and the reporter runs. They share the unit's private `/tmp` with the reporter. Staging is race-safe and the reporter uses no `/tmp`, so this is defence in depth only. A `setpriv … kill -KILL -1` as the suite account after the run would close it. ### Checked and sound - **`st_nlink`:** `== 1` is checked on the open descriptor in both readers. The reporter also refuses a token file with more than one link, so after the first start, check that the token's link count is 1 (see the checks below). - **`fullmatch` with ASCII-only patterns:** `RUN_NAME`, `HEAD` and `SPEC_NAME` all use `\A…\Z` with `re.ASCII` and `[0-9]`. `latest` drops exactly one trailing `\n`, then runs `fullmatch`. - **One fenced block per test:** headings are fixed text, and the title is the first line inside the block. A single fence length covers all blocks and is longer than any backtick run. Tildes cannot close a backtick fence. The worst case (about 26 kB) stays under `MAX_BODY`, and a cut can only leave a fence unclosed, never close one early. Outside the fence there are only the SHA (`HEAD`-validated), paths built from the validated run name, and `SPEC_NAME`-validated spec names (no `@`, `<`, `[` or space). - **Cap of 30 plus the roll-up:** PoC 7 gives 31 issues (30 + 1 roll-up) over 5 nights, and the roll-up is deduplicated. #450–#470 are already open, so 9 slots are left before new reds go to the roll-up. - **`NoNewPrivileges`/`PrivateTmp`/`BindPaths`:** in a transient unit with the branch's sandbox (`NoNewPrivileges`, `PrivateTmp`, `BindPaths=-/tmp/infraternal-build-slots`, `ProtectSystem=strict`, `ProtectHome=read-only`, `RestrictSUIDSGID`, `UMask=0077`), `runuser -u claude build-slot --status` works and prints **exactly the host's** 3 busy slots and 8 queued tickets (same PIDs and ages), with `unit_exit=0` (`sandbox.out`). `/proc` is shared and `flock` acts on the same inodes, so **the suite's holder still serialises with the other jobs.** Root does not follow anything in the queue folder: the holder is `claude`, and `fs.protected_symlinks=1` stops even root from following a foreign link in a sticky directory. The bound folder's only risk is the mode problem in P2-1. ### Tests `/usr/bin/python3 -I scripts/test-nightly-browser-suite.py` on dev-02 at e08a949: ``` Ran 43 tests in 11.703s OK ``` (`tests.out`.) Per the reviewer rule, I did not run `verify fast`/`quick`. The author's `verify quick` result (8/8 PASS, `~/j406-ev/quick3.log`) is noted. ### For the Maintainer, after the P2 fixes land (not before) Pin to the **fixed** full SHA, once the orchestrator has checked the two-line diff: ``` SHA=<fixed 40-hex SHA> d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d" stat -c '%a %U:%G' /tmp/infraternal-build-slots # expect 775 claude:claude sudo systemctl start --no-block infraternal-browser-suite.service ``` After the first manual start, check: 1. As `claude`, run `~/infraternal-ops/build-slot --status`. The unit's holder (`runuser … python3 -c …`) shows up in a slot or the queue. If it doesn't, the bind failed and the run isn't serialised. 2. `journalctl -u infraternal-browser-suite.service`: `nix develop`, `bun install`, `playwright install chromium` and the Chromium sandbox all work under `NoNewPrivileges` + `ProtectSystem=strict`. There is no `refusing the run folder`, and the log ends with `BROWSER_REPORT_DONE … request_errors=0`. 3. `sudo stat -c '%h %a %U' /var/lib/infraternal-fuzz/forgejo-token` shows `1 600 infraternal-fuzz`. If the link count is not 1, the reporter refuses the token. 4. `sudo stat -c '%a %U' /var/lib/infraternal-browser /var/lib/infraternal-fuzz/browser-staging` shows `700` for each, with its own account as owner. The staged files are `600 infraternal-fuzz`. 5. After the unit exits, `pgrep -u infraternal-browser` is empty. 6. At most 9 new suite issues, or one roll-up. Reviewed SHA: `e08a9493403fdb3fec6b8c868b265394b8bed0a3`.
Author
Owner

Round 4: both P2 and all five P3 of comment 88761 fixed

Branch nightly-browser-suite, head c15cbb5142 (git ls-remote confirms). Nothing installed. python3 -I scripts/test-nightly-browser-suite.py: 48 tests OK (was 43); scripts/test-nightly-fuzz.py: OK; verify quick on dev-02 through build-slot: all 8 rows PASS (~/j406-ev/quick4.log; waited about 35 min in the queue). Each fix has a test that failed first.

  • P2-1: tmpfiles_config() is now d /tmp/infraternal-build-slots 0775 claude claude - (matches the host's current 775 claude:claude). Test: exact line, no 777.
  • P2-2: issue comments are fetched with one request and deduplicated by id; no paging. Test: 119 extra comments (the fake ignores paging, like Forgejo) do not stop the night, "passes again" is posted once across reruns.
  • P3: git in the installer uses -c core.hooksPath=/dev/null -c core.fsmonitor=false, GIT_CONFIG_NOSYSTEM=1, and the root clone is --no-checkout into the mktemp folder; the suite account's git steps get no stdin/stdout, and an existing suite checkout is left alone on re-install. The helper no longer chdirs as root through the checkout: env -C <dir> enters it after setpriv dropped privileges and root stays in /; the nightly fetch also has hooks and fsmonitor off. BindPaths has no -. New names pooled while the roll-up is open are added to it as one comment, once per name. Leftover processes of the suite account get kill -s KILL -- -1 (as that account) after the run and before staging.

Root install commands (replace the earlier ones; pin the new head)

Do not run the e08a949 installer: it would set the queue folder to 1777 (if already run: chmod 0775 /tmp/infraternal-build-slots).

SHA=c15cbb514293a312ddfb0491c067863c1d74a31d
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d"
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager
sudo systemctl start infraternal-browser-suite.service   # optional manual night; then: journalctl -u infraternal-browser-suite.service

After the first manual start, check the slot holder appears in build-slot --status, and that the token file's link count is 1 (stat -c %h /var/lib/infraternal-fuzz/forgejo-token).

## Round 4: both P2 and all five P3 of comment 88761 fixed Branch `nightly-browser-suite`, head **c15cbb514293a312ddfb0491c067863c1d74a31d** (`git ls-remote` confirms). Nothing installed. `python3 -I scripts/test-nightly-browser-suite.py`: 48 tests OK (was 43); `scripts/test-nightly-fuzz.py`: OK; `verify quick` on dev-02 through build-slot: all 8 rows PASS (`~/j406-ev/quick4.log`; waited about 35 min in the queue). Each fix has a test that failed first. - **P2-1:** `tmpfiles_config()` is now `d /tmp/infraternal-build-slots 0775 claude claude -` (matches the host's current 775 claude:claude). Test: exact line, no `777`. - **P2-2:** issue comments are fetched with one request and deduplicated by id; no paging. Test: 119 extra comments (the fake ignores paging, like Forgejo) do not stop the night, "passes again" is posted once across reruns. - **P3:** git in the installer uses `-c core.hooksPath=/dev/null -c core.fsmonitor=false`, `GIT_CONFIG_NOSYSTEM=1`, and the root clone is `--no-checkout` into the mktemp folder; the suite account's git steps get no stdin/stdout, and an existing suite checkout is left alone on re-install. The helper no longer chdirs as root through the checkout: `env -C <dir>` enters it after `setpriv` dropped privileges and root stays in `/`; the nightly fetch also has hooks and fsmonitor off. `BindPaths` has no `-`. New names pooled while the roll-up is open are added to it as one comment, once per name. Leftover processes of the suite account get `kill -s KILL -- -1` (as that account) after the run and before staging. ## Root install commands (replace the earlier ones; pin the new head) Do not run the e08a949 installer: it would set the queue folder to 1777 (if already run: `chmod 0775 /tmp/infraternal-build-slots`). ``` SHA=c15cbb514293a312ddfb0491c067863c1d74a31d d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d" sudo systemctl list-timers infraternal-browser-suite.timer --no-pager sudo systemctl start infraternal-browser-suite.service # optional manual night; then: journalctl -u infraternal-browser-suite.service ``` After the first manual start, check the slot holder appears in `build-slot --status`, and that the token file's link count is 1 (`stat -c %h /var/lib/infraternal-fuzz/forgejo-token`).
Author
Owner

Fix-check of round-3 findings (comment 88761): APPROVE @ c15cbb5142

Delta checked: e08a9493..c15cbb51 (one commit, 4 files). Detached worktree at c15cbb5: python3 -I scripts/test-nightly-browser-suite.py → Ran 48 tests … OK.

Did each fix close its finding? Reverting the hunk on a scratch copy must turn the suite red

Finding Fix Hunk reverted → result
P2-1 tmpfiles mode d /tmp/infraternal-build-slots 0775 claude claude - back to 1777 → FAILED (failures=1)
P2-2 comments paging issue_comments(): one api() call, setdefault by id last_state back to pages() → FAILED (errors=1)
P3-1 no hooks in the root clone GIT_SAFE + GIT_CONFIG_NOSYSTEM=1 + --no-checkout GIT_SAFE dropped from the clone → FAILED
P3-1 re-install leaves the checkout alone git only when .git is missing; as_user stdin/stdout → DEVNULL checkout made unconditional again → FAILED
P3-2 chdir after setpriv env -C <dir> after setpriv, root cwd='/' back to cwd=cwd → FAILED
P3-3 BindPaths no leading - - restored → FAILED
P3-4 roll-up comments note_pooled(): dedupe by name against the body and the account's own comments call replaced by pass → FAILED
P3-5 leftover kill kill -s KILL -- -1 as the suite account before staging line removed → FAILED (failures=2)

Two non-blocking test gaps. The code is correct in both places.

  • Service-side hooks flag: test_hooks_are_off_for_the_suite_checkout is a substring test on the whole file. I removed the flags from fetch only and it stayed OK, because checkout still carries them. Worth tightening to check both git commands.
  • Dedupe by id: I replaced setdefault(comment['id']) with a key that is not the id and the suite stayed OK. The fake never returns the same id twice. The behaviour that matters (no abort, one "passes again" note) is covered.

No new root operation by name on a path the suite can write

  • kill -s KILL -- -1 runs through setpriv --reuid=infraternal-browser --regid=… --init-groups, so the caller is the suite uid, never root (asserted by the test: steps[:3] have --reuid=infraternal-browser). For a non-root caller, kill(-1) signals only processes of that same uid. Root, claude and infraternal-fuzz jobs are out of reach, and so is the claude build-slot holder.
  • The account is dedicated. ensure_user creates it with useradd --system … --shell /usr/sbin/nologin and the comment "nightly browser suite (D373)". The only users of that name in the repo are the four suite scripts and PROCESS.md. No other job runs as it, so the kill hits only suite leftovers. It runs before staging, so nothing races root's descriptor-based reads.
  • env -C runs after privileges drop, and root stays in /. note_pooled runs inside the unprivileged reporter (infraternal-fuzz). The installer adds no root open, chown, chmod or mkdir under HOME. Its only new root git steps run in root's own mkdtemp.

Root install commands for the Maintainer (pinned to the full SHA)

Do not run the e08a949 installer, because it sets the queue folder to 1777. If it was already run, first run sudo chmod 0775 /tmp/infraternal-build-slots.

SHA=c15cbb514293a312ddfb0491c067863c1d74a31d
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d"

Post-start checks:

stat -c '%a %U:%G' /tmp/infraternal-build-slots          # expect: 775 claude:claude
sudo systemctl list-timers infraternal-browser-suite.timer --no-pager
sudo systemctl start infraternal-browser-suite.service   # manual night
build-slot --status                                      # while it runs: the suite's claude holder is listed
journalctl -u infraternal-browser-suite.service --no-pager | tail -50
stat -c %h /var/lib/infraternal-fuzz/forgejo-token       # expect: 1
pgrep -u infraternal-browser || echo none                # after the unit ends: expect none
## Fix-check of round-3 findings (comment 88761): APPROVE @ c15cbb514293a312ddfb0491c067863c1d74a31d Delta checked: `e08a9493..c15cbb51` (one commit, 4 files). Detached worktree at c15cbb5: `python3 -I scripts/test-nightly-browser-suite.py` → `Ran 48 tests … OK`. ### Did each fix close its finding? Reverting the hunk on a scratch copy must turn the suite red | Finding | Fix | Hunk reverted → result | |---|---|---| | P2-1 tmpfiles mode | `d /tmp/infraternal-build-slots 0775 claude claude -` | back to `1777` → **FAILED (failures=1)** | | P2-2 comments paging | `issue_comments()`: one `api()` call, `setdefault` by id | `last_state` back to `pages()` → **FAILED (errors=1)** | | P3-1 no hooks in the root clone | `GIT_SAFE` + `GIT_CONFIG_NOSYSTEM=1` + `--no-checkout` | `GIT_SAFE` dropped from the clone → **FAILED** | | P3-1 re-install leaves the checkout alone | git only when `.git` is missing; `as_user` stdin/stdout → DEVNULL | checkout made unconditional again → **FAILED** | | P3-2 chdir after setpriv | `env -C <dir>` after setpriv, root `cwd='/'` | back to `cwd=cwd` → **FAILED** | | P3-3 BindPaths | no leading `-` | `-` restored → **FAILED** | | P3-4 roll-up comments | `note_pooled()`: dedupe by name against the body and the account's own comments | call replaced by `pass` → **FAILED** | | P3-5 leftover kill | `kill -s KILL -- -1` as the suite account before staging | line removed → **FAILED (failures=2)** | Two non-blocking test gaps. The code is correct in both places. - **Service-side hooks flag:** `test_hooks_are_off_for_the_suite_checkout` is a substring test on the whole file. I removed the flags from `fetch` only and it stayed **OK**, because `checkout` still carries them. Worth tightening to check both git commands. - **Dedupe by id:** I replaced `setdefault(comment['id'])` with a key that is not the id and the suite stayed **OK**. The fake never returns the same id twice. The behaviour that matters (no abort, one "passes again" note) is covered. ### No new root operation by name on a path the suite can write - `kill -s KILL -- -1` runs through `setpriv --reuid=infraternal-browser --regid=… --init-groups`, so the caller is the suite uid, never root (asserted by the test: `steps[:3]` have `--reuid=infraternal-browser`). For a non-root caller, kill(-1) signals only processes of that same uid. Root, `claude` and `infraternal-fuzz` jobs are out of reach, and so is the `claude` build-slot holder. - The account is dedicated. `ensure_user` creates it with `useradd --system … --shell /usr/sbin/nologin` and the comment "nightly browser suite (D373)". The only users of that name in the repo are the four suite scripts and PROCESS.md. No other job runs as it, so the kill hits only suite leftovers. It runs before staging, so nothing races root's descriptor-based reads. - `env -C` runs after privileges drop, and root stays in `/`. `note_pooled` runs inside the unprivileged reporter (`infraternal-fuzz`). The installer adds no root `open`, `chown`, `chmod` or `mkdir` under `HOME`. Its only new root git steps run in root's own mkdtemp. ### Root install commands for the Maintainer (pinned to the full SHA) Do **not** run the e08a949 installer, because it sets the queue folder to 1777. If it was already run, first run `sudo chmod 0775 /tmp/infraternal-build-slots`. ``` SHA=c15cbb514293a312ddfb0491c067863c1d74a31d d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d" ``` Post-start checks: ``` stat -c '%a %U:%G' /tmp/infraternal-build-slots # expect: 775 claude:claude sudo systemctl list-timers infraternal-browser-suite.timer --no-pager sudo systemctl start infraternal-browser-suite.service # manual night build-slot --status # while it runs: the suite's claude holder is listed journalctl -u infraternal-browser-suite.service --no-pager | tail -50 stat -c %h /var/lib/infraternal-fuzz/forgejo-token # expect: 1 pgrep -u infraternal-browser || echo none # after the unit ends: expect none ```
Author
Owner

Follow-up: the 6 h budget now counts from the slot grant, not the queue wait

Branch nightly-suite-queue-timeout (from origin/dev 9d225cb0e), head 3b6f04d6aa (git ls-remote confirms). python3 -I scripts/test-nightly-browser-suite.py: 50 tests OK (red first: the two new tests errored and the unit-timeout assertion failed); verify quick on dev-02: all 8 rows PASS (~/j406-ev/quick5.log).

  • Queue wait has its own bound (12 h). On a timeout the helper stops the holder's process group, exits 75, prints one journal line nightly browser suite: QUEUE WAIT TIMEOUT: no build slot after 12 h in the queue; no suite ran and nothing was reported, and runs nothing.
  • The suite command is nice -n 10 timeout --kill-after=60 6h nix develop …, started only after the slot is held. A timeout (exit 124) prints one SUITE TIMEOUT line, still kills leftover suite processes, files no report, and the helper exits 124. Neither case opens spec issues.
  • TimeoutStartSec=24h stays as a backstop only (above 12 h + 6 h + report). I rejected infinity so a hung helper cannot run forever.
  • docs/PROCESS.md describes the two budgets.

Reinstall commands (as root, from a clone pinned to the new SHA)

Only after this branch is merged to dev, or the Maintainer accepts the branch SHA directly:

SHA=3b6f04d6aa875a1218a052375ef0334a88f0db14
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d"
sudo systemctl daemon-reload && systemctl cat infraternal-browser-suite.service | grep TimeoutStartSec   # expect 24h

If the branch is merged first, the merge commit's full SHA must be used instead, since the installer checks out exactly the SHA given. The installer rewrites the unit, so no manual edit is needed.

## Follow-up: the 6 h budget now counts from the slot grant, not the queue wait Branch `nightly-suite-queue-timeout` (from origin/dev 9d225cb0e), head **3b6f04d6aa875a1218a052375ef0334a88f0db14** (`git ls-remote` confirms). `python3 -I scripts/test-nightly-browser-suite.py`: 50 tests OK (red first: the two new tests errored and the unit-timeout assertion failed); `verify quick` on dev-02: all 8 rows PASS (`~/j406-ev/quick5.log`). - Queue wait has its own bound (12 h). On a timeout the helper stops the holder's process group, exits **75**, prints one journal line `nightly browser suite: QUEUE WAIT TIMEOUT: no build slot after 12 h in the queue; no suite ran and nothing was reported`, and runs nothing. - The suite command is `nice -n 10 timeout --kill-after=60 6h nix develop …`, started only after the slot is held. A timeout (exit 124) prints one `SUITE TIMEOUT` line, still kills leftover suite processes, files no report, and the helper exits **124**. Neither case opens spec issues. - `TimeoutStartSec=24h` stays as a backstop only (above 12 h + 6 h + report). I rejected `infinity` so a hung helper cannot run forever. - `docs/PROCESS.md` describes the two budgets. ## Reinstall commands (as root, from a clone pinned to the new SHA) Only after this branch is merged to dev, or the Maintainer accepts the branch SHA directly: ``` SHA=3b6f04d6aa875a1218a052375ef0334a88f0db14 d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d" sudo systemctl daemon-reload && systemctl cat infraternal-browser-suite.service | grep TimeoutStartSec # expect 24h ``` If the branch is merged first, the merge commit's full SHA must be used instead, since the installer checks out exactly the SHA given. The installer rewrites the unit, so no manual edit is needed.
Author
Owner

J406 progress (follow-up 2 and the 403/kill reports) — branch nightly-suite-partial-report, head 139ced7c1 (pushed, git ls-remote confirms)

Answer: which token and user opened #450-#470. My manual run on 2026-11-09 did not use the infraternal-fuzz token. It used the Forgejo CLI token on the control host (~/.local/share/forgejo-cli/keys.json, the same credential scripts/fj uses), and the issues' author is kayg (checked on #450 via the API). I could not read the fuzz token (its home is 0700 and I never had it). I also confirmed that this control-host token has no read:user scope either (GET /user returns "token does not have at least one of required scope(s): [read:user]"). My round-1 reporter did not call /user, so the manual run worked; the /user call came in with round 2's trusted-author check, and that is what the Maintainer's 403 hit. That was my regression, and I did not test it with a token without the scope.

Fixed, each with a test that failed first (73 tests OK; test-nightly-fuzz.py OK):

  • No /user call. The reporter is told whose markers to believe with --login, which the root helper reads from /etc/infraternal-browser-suite.login (root-owned, written by the installer from --forgejo-login <account>; required the first time). Every issue and comment it posts is checked against that name (the author comes back on the response), so a wrong value stops the night; markers still count only when written by that login. A stub that answers 403 on /user files a whole night, and no request to /user is made. Chosen over learning the login from the first own post, because the open issues must be matched before anything is posted.
  • 401/403 is said plainly: one journal line TOKEN LACKS PERMISSION: HTTP 403 on POST repos/... (Forgejo's message). Nothing was lost: the staged files stay in ... re-run only the reporter with: sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name <name>, exit 77, staged files untouched.
  • --report-only [--run-name N] on the root helper: no slot, no suite, no staging; runs only the reporter on /var/lib/infraternal-fuzz/browser-staging/. The first real run's staging came from the older helper (no staged.json), so use --run-name 20261109T134307Z-c442500; a test covers exactly that shape (meta.json, results.json, suite.log; head c442500...).
  • Cleanup kill: reproduced on dev-02 inside a throwaway pid namespace: procps-ng 4.0.4 kill -s KILL -- -1 prints its usage (it reads -1 as a signal number), so nothing was killed. The helper now runs python3 -c 'os.kill(-1, SIGKILL)' as the suite account, checks its exit status and fails closed (nothing is staged or reported, one journal line). Tests run the exact command for real: once against a child pid, once in an unprivileged pid namespace with pid -1 killing a bystander.
  • Partial results on a suite timeout: apps/tracer/nightly-reporter.ts streams one line per finished test to results.jsonl; verified for real on dev-02 with a tiny Playwright run (pass, pass, fail with backticks in the title, skip: the four lines are in ~/.cache/codex-scratch/j406/evidence/proof-reporter.jsonl on the control host and parse correctly). After a timeout the reporter files issues for tests that actually failed, never reports an unreached spec fixed, and adds one note to the roll-up: suite timed out after 9 h; M of K tests ran. SUITE_BUDGET is now 9 h; TimeoutStartSec stays 24 h (12 h queue + 9 h + margin).

Data request for the timing analysis (for the Maintainer, relayed by the orchestrator): the 4 h 04 min run's files are under the 0700 homes, so I can't read them. Please copy these two files to a folder readable by claude (for example /home/claude/j406-ev/c442500/, chmod a+r): results.json and suite.log of 20261109T134307Z-c442500 (the copies in /var/lib/infraternal-fuzz/browser-staging/ are fine). Until then I only have my own earlier full run (c751e99, 1281 tests, 3 h 25 min wall, workers: 1); the analysis and the worker proposal follow on this issue.

verify quick is queued on dev-02 (position 8 behind other jobs); the reinstall commands follow when it passes.

## J406 progress (follow-up 2 and the 403/kill reports) — branch `nightly-suite-partial-report`, head 139ced7c1 (pushed, `git ls-remote` confirms) **Answer: which token and user opened #450-#470.** My manual run on 2026-11-09 did not use the infraternal-fuzz token. It used the Forgejo CLI token on the control host (`~/.local/share/forgejo-cli/keys.json`, the same credential `scripts/fj` uses), and the issues' author is **`kayg`** (checked on #450 via the API). I could not read the fuzz token (its home is 0700 and I never had it). I also confirmed that this control-host token has no `read:user` scope either (`GET /user` returns "token does not have at least one of required scope(s): [read:user]"). My round-1 reporter did not call `/user`, so the manual run worked; the `/user` call came in with round 2's trusted-author check, and that is what the Maintainer's 403 hit. That was my regression, and I did not test it with a token without the scope. **Fixed, each with a test that failed first (73 tests OK; `test-nightly-fuzz.py` OK):** - **No `/user` call.** The reporter is told whose markers to believe with `--login`, which the root helper reads from `/etc/infraternal-browser-suite.login` (root-owned, written by the installer from `--forgejo-login <account>`; required the first time). Every issue and comment it posts is checked against that name (the author comes back on the response), so a wrong value stops the night; markers still count only when written by that login. A stub that answers 403 on `/user` files a whole night, and no request to `/user` is made. Chosen over learning the login from the first own post, because the open issues must be matched before anything is posted. - **401/403 is said plainly:** one journal line `TOKEN LACKS PERMISSION: HTTP 403 on POST repos/... (Forgejo's message). Nothing was lost: the staged files stay in ... re-run only the reporter with: sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name <name>`, exit 77, staged files untouched. - **`--report-only [--run-name N]`** on the root helper: no slot, no suite, no staging; runs only the reporter on `/var/lib/infraternal-fuzz/browser-staging/`. The first real run's staging came from the older helper (no `staged.json`), so use `--run-name 20261109T134307Z-c442500`; a test covers exactly that shape (meta.json, results.json, suite.log; head `c442500...`). - **Cleanup kill:** reproduced on dev-02 inside a throwaway pid namespace: procps-ng 4.0.4 `kill -s KILL -- -1` prints its usage (it reads `-1` as a signal number), so nothing was killed. The helper now runs `python3 -c 'os.kill(-1, SIGKILL)'` as the suite account, checks its exit status and fails closed (nothing is staged or reported, one journal line). Tests run the exact command for real: once against a child pid, once in an unprivileged pid namespace with pid -1 killing a bystander. - **Partial results on a suite timeout:** `apps/tracer/nightly-reporter.ts` streams one line per finished test to `results.jsonl`; verified for real on dev-02 with a tiny Playwright run (pass, pass, fail with backticks in the title, skip: the four lines are in `~/.cache/codex-scratch/j406/evidence/proof-reporter.jsonl` on the control host and parse correctly). After a timeout the reporter files issues for tests that actually failed, never reports an unreached spec fixed, and adds one note to the roll-up: `suite timed out after 9 h; M of K tests ran`. `SUITE_BUDGET` is now 9 h; `TimeoutStartSec` stays 24 h (12 h queue + 9 h + margin). **Data request for the timing analysis (for the Maintainer, relayed by the orchestrator):** the 4 h 04 min run's files are under the 0700 homes, so I can't read them. Please copy these two files to a folder readable by `claude` (for example `/home/claude/j406-ev/c442500/`, `chmod a+r`): `results.json` and `suite.log` of `20261109T134307Z-c442500` (the copies in `/var/lib/infraternal-fuzz/browser-staging/` are fine). Until then I only have my own earlier full run (c751e99, 1281 tests, 3 h 25 min wall, `workers: 1`); the analysis and the worker proposal follow on this issue. `verify quick` is queued on dev-02 (position 8 behind other jobs); the reinstall commands follow when it passes.
Author
Owner

Ready for review: branch nightly-suite-partial-report, head ee54708914 (git ls-remote confirms; 4 commits on origin/dev 547a48885)

python3 -I scripts/test-nightly-browser-suite.py: 73 tests OK (red first); scripts/test-nightly-fuzz.py: OK; verify quick on dev-02 through build-slot: all rows PASS, including doc comments on diff and tracer typecheck (two earlier quick runs found a missing doc comment and a wrong Playwright types import; both fixed in the last two commits; log ~/j406-ev/quick8.log).

Everything in comment 89069 stands (no /user call, --login from /etc/infraternal-browser-suite.login, TOKEN LACKS PERMISSION + exit 77, --report-only, working cleanup kill, partial results via nightly-reporter.ts, 9 h budget). New since then: the installer takes --forgejo-login <account> (required the first time; the Maintainer's token belongs to kayg).

Worker proposal (the worker count is NOT changed in this branch)

Evidence I have: my earlier run c751e99 (1281 tests, workers: 1, 3 h 25 min wall on a busy dev-02, 3.42 h of summed test time, no single spec file over 6% of it; per-file table in ~/.cache/codex-scratch/j406/shard-estimate.txt on the control host). I still lack the 4 h 04 min run's files (they sit in 0700 homes; see my request in comment 89069), so the analysis of that run's slowest specs follows when they are readable.

  • --workers N is not safe. All 181 spec files share one test double, and 68 of them call /__mock/reset first; a second worker's reset ends the first one's session (#377). playwright.config.ts refuses --workers > 1 on purpose.
  • Safe way to use the cores: shards, each with its own server. TRACER_TEST_PORT and TRACER_DIST already exist, so N independent playwright test --shard=i/N runs, each with its own port pair (TRACER_TEST_PORT and the next one up) and its own TRACER_DIST, would not share a double. Cost and risk: the build output must be per-shard (bun run build wipes dist), INFRATERNAL_EVIDENCE_DIR per shard, and wall-clock-sensitive specs (motion audit at 4x CPU, perf HUD, the latency probe) would see more CPU contention on a shared box. By file time, an ideal split gives 2 shards = 1.7 h and 3 shards = 1.1 h of test time, versus 3.4 h.
  • Recommendation: do not change it tonight. After the first complete night, try 2 shards in one manual run and compare the set of red specs with the single-worker run; adopt it only if the reds match and no timing spec flips. That is a follow-up change (it touches how run launches Playwright), not part of this branch. Doing it blind could flip timing specs, and the 9 h budget already covers a 4 h run twice over.

Reinstall commands (as root; pin to the full SHA; the first time after this change --forgejo-login is required)

SHA=ee547089148584098cb5361b52daf8c0aee82229
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA" --forgejo-login kayg; sudo rm -rf "$d"

If the branch is merged first, use the merge commit's full SHA instead (the installer checks out exactly what it is given). Then file the first real run's results, without re-running the suite:

sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name 20261109T134307Z-c442500

Do not touch the run in progress; the reinstall rewrites only the root-owned helper, reporter, units and login file, so wait for the unit to be idle before running it.

## Ready for review: branch `nightly-suite-partial-report`, head **ee547089148584098cb5361b52daf8c0aee82229** (`git ls-remote` confirms; 4 commits on origin/dev 547a48885) `python3 -I scripts/test-nightly-browser-suite.py`: 73 tests OK (red first); `scripts/test-nightly-fuzz.py`: OK; `verify quick` on dev-02 through build-slot: all rows PASS, including `doc comments on diff` and `tracer typecheck` (two earlier quick runs found a missing doc comment and a wrong Playwright types import; both fixed in the last two commits; log `~/j406-ev/quick8.log`). Everything in comment 89069 stands (no `/user` call, `--login` from `/etc/infraternal-browser-suite.login`, `TOKEN LACKS PERMISSION` + exit 77, `--report-only`, working cleanup kill, partial results via `nightly-reporter.ts`, 9 h budget). New since then: the installer takes `--forgejo-login <account>` (required the first time; the Maintainer's token belongs to `kayg`). ### Worker proposal (the worker count is NOT changed in this branch) Evidence I have: my earlier run c751e99 (1281 tests, `workers: 1`, 3 h 25 min wall on a busy dev-02, 3.42 h of summed test time, no single spec file over 6% of it; per-file table in `~/.cache/codex-scratch/j406/shard-estimate.txt` on the control host). I still lack the 4 h 04 min run's files (they sit in 0700 homes; see my request in comment 89069), so the analysis of that run's slowest specs follows when they are readable. - **`--workers N` is not safe.** All 181 spec files share one test double, and 68 of them call `/__mock/reset` first; a second worker's reset ends the first one's session (#377). `playwright.config.ts` refuses `--workers > 1` on purpose. - **Safe way to use the cores: shards, each with its own server.** `TRACER_TEST_PORT` and `TRACER_DIST` already exist, so N independent `playwright test --shard=i/N` runs, each with its own port pair (`TRACER_TEST_PORT` and the next one up) and its own `TRACER_DIST`, would not share a double. Cost and risk: the build output must be per-shard (`bun run build` wipes `dist`), `INFRATERNAL_EVIDENCE_DIR` per shard, and wall-clock-sensitive specs (motion audit at 4x CPU, perf HUD, the latency probe) would see more CPU contention on a shared box. By file time, an ideal split gives 2 shards = 1.7 h and 3 shards = 1.1 h of test time, versus 3.4 h. - **Recommendation:** do not change it tonight. After the first complete night, try 2 shards in one manual run and compare the set of red specs with the single-worker run; adopt it only if the reds match and no timing spec flips. That is a follow-up change (it touches how `run` launches Playwright), not part of this branch. Doing it blind could flip timing specs, and the 9 h budget already covers a 4 h run twice over. ## Reinstall commands (as root; pin to the full SHA; the first time after this change `--forgejo-login` is required) ``` SHA=ee547089148584098cb5361b52daf8c0aee82229 d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA" --forgejo-login kayg; sudo rm -rf "$d" ``` If the branch is merged first, use the merge commit's full SHA instead (the installer checks out exactly what it is given). Then file the first real run's results, without re-running the suite: ``` sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name 20261109T134307Z-c442500 ``` Do not touch the run in progress; the reinstall rewrites only the root-owned helper, reporter, units and login file, so wait for the unit to be idle before running it.
Author
Owner

Timing analysis of the first real run, 20261109T134307Z-c442500 (no code changed; J464's review of ee5470891 is untouched)

Source: results.json and suite.log copied from dev-02 /home/claude/j406-ev/c442500/. The full numbers are in ~/.cache/codex-scratch/j406/evidence/c442500/analysis.txt on the control host. The run was single-worker (workers: 1), 90 s per-test timeout.

Totals. Playwright wall time 4.08 h (14,672 s) for 1,416 tests in 181 spec files: 1,152 passed, 75 failed, 189 skipped. Summed test time is 4.06 h (14,611 s). Everything outside the tests is 61 s (0.4 %): the web-server build and start and the teardown. The steps before Playwright (bun install, bun run build, playwright install chromium) took about 3 s because everything was cached (run folder 13:43:07, Playwright start 13:43:10). So setup and build add almost nothing: the time is all in the tests.

Why 4.06 h and not the 3.42 h of my earlier run (c751e99). 1,925 s (0.53 h) comes from files that are new since then: keyboard-sweep 945 s, keyboard-instant 267 s, terminal-touch-select 234 s, osc52-clipboard 178 s, forget-retired-host 98 s, terminal-selection-wrap 89 s, and others. Among files that existed, the biggest growth is clusters-bench +205 s (it now fails after 240 s) and pending-removal-command +84 s (one failing test, 90 s).

Top 20 spec files by time (share of the 4.08 h wall)

# time share tests failed file
1 945 s 6.4 % 4 0 keyboard-sweep.spec.ts
2 736 s 5.0 % 29 0 settings-rows-of-windows.spec.ts
3 708 s 4.8 % 58 0 addhost-wizard.spec.ts
4 647 s 4.4 % 55 1 mesh.spec.ts
5 449 s 3.1 % 42 1 clusters.spec.ts
6 351 s 2.4 % 37 6 host-removal-confirm.spec.ts
7 332 s 2.3 % 44 0 phone-pill.spec.ts
8 318 s 2.2 % 16 4 glass-ghost.spec.ts
9 314 s 2.1 % 30 0 terminal-states.spec.ts
10 300 s 2.0 % 38 1 canvas.spec.ts
11 281 s 1.9 % 24 2 sessions-canvas.spec.ts
12 267 s 1.8 % 34 0 keyboard-instant.spec.ts
13 243 s 1.7 % 9 8 addhost-pill-stream.spec.ts
14 240 s 1.6 % 1 1 clusters-bench.spec.ts
15 234 s 1.6 % 17 0 terminal-touch-select.spec.ts
16 224 s 1.5 % 10 0 account-captures.spec.ts
17 193 s 1.3 % 19 0 retained-typing-review.spec.ts
18 189 s 1.3 % 20 0 shortcuts-d222.spec.ts
19 181 s 1.2 % 16 0 clusters-grid.spec.ts
20 178 s 1.2 % 14 0 osc52-clipboard.spec.ts

The top 20 files are 53 % of the time. Passing tests have median 9.0 s, p90 17.0 s, p99 47.9 s. Slowest single tests: keyboard-sweep "no palette row leaves anything animating after Enter" 265 s and 263 s (it presses every palette row, on a fresh page each time, at desktop and phone size) and "no shortcut leaves anything animating" 210 s and 209 s; then clusters-bench 240 s (failed); addhost-pill-stream "reduced motion" 120 s (failed); settings-rows-of-windows drag test 103 s.

Cheap wins without sharding.

  1. Failures cost time: the 75 failed tests took 1,322 s (22 min, 9 % of the run). Fixing the known reds gives that back.
  2. keyboard-sweep is 6.4 % of the run in 4 tests. It is a guard, so I would keep it, but it could run at one size nightly or be split per palette row into parallel-safe tests only if its page does not touch the shared double (it uses ?mock=1; whether it resets the shared double is for the author to say).
  3. clusters-bench is a frame-cost benchmark (4 min, failing). By CLAUDE.md, speed is measured on the performance VM, and the engineering rules say not to assert wall-clock in functional tests. It probably belongs in the perf harness, not the nightly functional suite. That is a decision for the owner, so I only flag it.

Shard estimate, redone from this run (files assigned longest-first; each shard repeats the 61 s fixed cost): 1 shard 4.08 h; 2 shards about 2.05 h (x1.99); 3 shards about 1.37 h (x2.97); 4 shards about 1.03 h (x3.95). No file is larger than 6.4 % of the time, so balance is not a problem. Playwright's own --shard split is not longest-first, so expect a little worse than these ideal numbers. The earlier run's numbers (3.42 h: 1.7 h for 2 shards) agree on the ratio.

Proposal (unchanged from before, now with evidence). --workers N stays unsafe (shared test double, 68 files reset it, #377). The safe way to use more cores is independent shards, each with its own port pair, TRACER_DIST, and evidence folder. Because the suite now takes 4 h on one worker and the budget is 9 h, nothing forces this tonight. I did not create the nightly-suite-shards branch: there is no code to put on it yet, and the first step is a manual 2-shard run on dev-02 to compare the red set with this run (75 failed tests in 21 files then, 75 in this run). If you want it, I will open the branch from origin/dev and implement 2 shards in run() plus a test, after J464's review lands, since both touch scripts/nightly-browser-suite.py.

## Timing analysis of the first real run, `20261109T134307Z-c442500` (no code changed; J464's review of ee5470891 is untouched) Source: `results.json` and `suite.log` copied from dev-02 `/home/claude/j406-ev/c442500/`. The full numbers are in `~/.cache/codex-scratch/j406/evidence/c442500/analysis.txt` on the control host. The run was single-worker (`workers: 1`), 90 s per-test timeout. **Totals.** Playwright wall time 4.08 h (14,672 s) for 1,416 tests in 181 spec files: 1,152 passed, 75 failed, 189 skipped. Summed test time is 4.06 h (14,611 s). **Everything outside the tests is 61 s (0.4 %)**: the web-server build and start and the teardown. The steps before Playwright (`bun install`, `bun run build`, `playwright install chromium`) took about 3 s because everything was cached (run folder 13:43:07, Playwright start 13:43:10). So setup and build add almost nothing: the time is all in the tests. **Why 4.06 h and not the 3.42 h of my earlier run (c751e99).** 1,925 s (0.53 h) comes from files that are new since then: keyboard-sweep 945 s, keyboard-instant 267 s, terminal-touch-select 234 s, osc52-clipboard 178 s, forget-retired-host 98 s, terminal-selection-wrap 89 s, and others. Among files that existed, the biggest growth is clusters-bench +205 s (it now fails after 240 s) and pending-removal-command +84 s (one failing test, 90 s). **Top 20 spec files by time** (share of the 4.08 h wall) | # | time | share | tests | failed | file | |--:|--:|--:|--:|--:|---| | 1 | 945 s | 6.4 % | 4 | 0 | keyboard-sweep.spec.ts | | 2 | 736 s | 5.0 % | 29 | 0 | settings-rows-of-windows.spec.ts | | 3 | 708 s | 4.8 % | 58 | 0 | addhost-wizard.spec.ts | | 4 | 647 s | 4.4 % | 55 | 1 | mesh.spec.ts | | 5 | 449 s | 3.1 % | 42 | 1 | clusters.spec.ts | | 6 | 351 s | 2.4 % | 37 | 6 | host-removal-confirm.spec.ts | | 7 | 332 s | 2.3 % | 44 | 0 | phone-pill.spec.ts | | 8 | 318 s | 2.2 % | 16 | 4 | glass-ghost.spec.ts | | 9 | 314 s | 2.1 % | 30 | 0 | terminal-states.spec.ts | | 10 | 300 s | 2.0 % | 38 | 1 | canvas.spec.ts | | 11 | 281 s | 1.9 % | 24 | 2 | sessions-canvas.spec.ts | | 12 | 267 s | 1.8 % | 34 | 0 | keyboard-instant.spec.ts | | 13 | 243 s | 1.7 % | 9 | 8 | addhost-pill-stream.spec.ts | | 14 | 240 s | 1.6 % | 1 | 1 | clusters-bench.spec.ts | | 15 | 234 s | 1.6 % | 17 | 0 | terminal-touch-select.spec.ts | | 16 | 224 s | 1.5 % | 10 | 0 | account-captures.spec.ts | | 17 | 193 s | 1.3 % | 19 | 0 | retained-typing-review.spec.ts | | 18 | 189 s | 1.3 % | 20 | 0 | shortcuts-d222.spec.ts | | 19 | 181 s | 1.2 % | 16 | 0 | clusters-grid.spec.ts | | 20 | 178 s | 1.2 % | 14 | 0 | osc52-clipboard.spec.ts | The top 20 files are 53 % of the time. Passing tests have median 9.0 s, p90 17.0 s, p99 47.9 s. Slowest single tests: keyboard-sweep "no palette row leaves anything animating after Enter" 265 s and 263 s (it presses every palette row, on a fresh page each time, at desktop and phone size) and "no shortcut leaves anything animating" 210 s and 209 s; then clusters-bench 240 s (failed); addhost-pill-stream "reduced motion" 120 s (failed); settings-rows-of-windows drag test 103 s. **Cheap wins without sharding.** 1. **Failures cost time:** the 75 failed tests took 1,322 s (22 min, 9 % of the run). Fixing the known reds gives that back. 2. **keyboard-sweep is 6.4 % of the run in 4 tests.** It is a guard, so I would keep it, but it could run at one size nightly or be split per palette row into parallel-safe tests only if its page does not touch the shared double (it uses `?mock=1`; whether it resets the shared double is for the author to say). 3. **clusters-bench is a frame-cost benchmark** (4 min, failing). By CLAUDE.md, speed is measured on the performance VM, and the engineering rules say not to assert wall-clock in functional tests. It probably belongs in the perf harness, not the nightly functional suite. That is a decision for the owner, so I only flag it. **Shard estimate, redone from this run** (files assigned longest-first; each shard repeats the 61 s fixed cost): 1 shard 4.08 h; **2 shards about 2.05 h (x1.99)**; 3 shards about 1.37 h (x2.97); 4 shards about 1.03 h (x3.95). No file is larger than 6.4 % of the time, so balance is not a problem. Playwright's own `--shard` split is not longest-first, so expect a little worse than these ideal numbers. The earlier run's numbers (3.42 h: 1.7 h for 2 shards) agree on the ratio. **Proposal (unchanged from before, now with evidence).** `--workers N` stays unsafe (shared test double, 68 files reset it, #377). The safe way to use more cores is independent shards, each with its own port pair, `TRACER_DIST`, and evidence folder. Because the suite now takes 4 h on one worker and the budget is 9 h, nothing forces this tonight. I did not create the `nightly-suite-shards` branch: there is no code to put on it yet, and the first step is a manual 2-shard run on dev-02 to compare the red set with this run (75 failed tests in 21 files then, 75 in this run). If you want it, I will open the branch from origin/dev and implement 2 shards in `run()` plus a test, after J464's review lands, since both touch `scripts/nightly-browser-suite.py`.
Author
Owner

Correction to my analysis above: in the last paragraph I wrote "75 failed tests in 21 files then, 75 in this run". The accurate comparison is: my earlier run (c751e99) had 53 failed tests in 21 spec files; this run (c442500) has 75 failed tests in 33 spec files. The red set grew (newer specs and the dev changes since), so a 2-shard comparison should compare against this run's 33 files, not the older 21.

Correction to my analysis above: in the last paragraph I wrote "75 failed tests in 21 files then, 75 in this run". The accurate comparison is: my earlier run (c751e99) had 53 failed tests in 21 spec files; this run (c442500) has 75 failed tests in 33 spec files. The red set grew (newer specs and the dev changes since), so a 2-shard comparison should compare against this run's 33 files, not the older 21.
Author
Owner

Security fix-check of nightly-suite-partial-report: APPROVE at ee547089148584098cb5361b52daf8c0aee82229

I'm an independent reviewer (Claude Opus 5.5). This covers the 4 commits on origin/dev 547a48885, on top of the reviewed and installed 5d9683074. I read the whole delta in nightly-browser-suite-service, nightly-browser-suite.py, install-nightly-browser-suite and apps/tracer/nightly-reporter.ts.

Gate

python3 -I scripts/test-nightly-browser-suite.py on a clean git archive of ee5470891: Ran 73 tests in 18.307s, OK.

Each new test fails without its fix (one hunk reverted per scratch copy)

reverted hunk result
kill back to kill -s KILL -- -1 FAIL cleanup_kill_command_works_for_real, a_cleanup_that_cannot_run_stops_the_night; ERROR token_account_never_runs_repository_code
kill exit status ignored FAIL a_cleanup_that_cannot_run_stops_the_night
GET /user call put back ERROR no_request_needs_the_user_scope
post-author check removed FAIL own_issues_are_recognised_by_the_configured_login
written_by always true FAIL a_forged_fixed_comment_is_ignored, only_the_token_owners_markers_count, own_issues_…
401/403 handled like any HTTPError FAIL forbidden_token_is_said_plainly_and_the_staged_files_stay
--report-only returns early FAIL report_only_reruns_just_the_reporter_on_staged_files
stale-latest check removed FAIL a_stale_run_folder_is_not_reported_as_tonights_partial_results
timeout path ignores results.jsonl 4 ERRORs (timed_out_suite_files_the_failures_it_saw_and_one_note, specs_that_never_ran_…, …)
skipped/interrupted counted as failed FAIL jsonl_collect_counts_only_real_failures
clean_name skipped on streamed file names FAIL jsonl_collect_counts_only_real_failures
timeout-note dedupe removed FAIL timed_out_suite_files_the_failures_it_saw_and_one_note
TIMEOUT_LABEL check removed FAIL suite_timeout_argument_is_validated (4 subtests)
login file owner check removed FAIL login_comes_from_a_root_owned_file
budget back to 6h FAIL suite_budget_starts_after_the_slot_is_granted, a_timed_out_suite_still_reports_what_it_finished
streamed title [:MAX_LINE] cut removed survives (see the non-blocking notes)

Attacks on the delta

  • Can the suite account point --report-only at its own files? No. --results is fixed to /var/lib/infraternal-fuzz/browser-staging, the token account's 0700 folder. Only fresh_staging/stage (descriptor-based: O_NOFOLLOW, owner check, nlink 1, CAPS) ever fill it. --report-only stages nothing and reads nothing as root. The reporter reads every file, including the new staged.json (4 KiB cap), with read_regular (O_NOFOLLOW, regular file, a single link, size cap). The run name read from staged.json still goes through RUN_NAME, and the timeout label goes through TIMEOUT_LABEL (a ../x name is refused, as tested). The helper's own --run-name is checked against RUN_NAME before anything runs. The new results.jsonl goes through the same read_owned path with a 32 MiB cap. A missing file is allowed only for the two optional result files, and at least one of them must be present.
  • Can the marker/author logic be spoofed? No new hole. The login comes from a root-owned file (O_NOFOLLOW, uid 0, at most 256 bytes, the LOGIN regex). written_by is still exact equality against it on every marker read (open issues, state comments, pooled names, and the new timeout note on both body and comments). A comment by another account is never counted (tests above). Each POST response's author is checked against the configured login, and a mismatch stops the night.
  • Can streamed results inject markdown or flood issues? No. Streamed file names go through clean_name, so a malformed name lands in UNPARSEABLE, which is never reported fixed. Titles and errors land inside the fence_for fence. Errors are cut by clip_lines, ANSI codes are stripped, and the body is cut to MAX_BODY. The timeout note contains only integers and a validated label. Streams are capped at 200 000 lines and 32 MiB. The open-issue caps and the pooling into one roll-up are shared code and unchanged. A spec that was never reached is never reported fixed, and an interrupted test is not a failure.
  • Does the kill still run only as the suite account? Yes. It goes through as_user (setpriv sets the real and effective uid and gid to infraternal-browser, with a reset environment, fixed PATH, cwd /, and an absolute /usr/bin/python3). kill(-1) therefore reaches only that uid's processes. ESRCH counts as success. Any other failure means nothing is staged or reported (fail closed).

Non-blocking notes (follow-ups, not required)

  1. No test covers the cut of streamed titles to MAX_LINE. Without it a title is still bounded by MAX_BODY and the fence, so this is a coverage gap, not an injection.
  2. If the response has no user field, post_as accepts it. Forgejo always returns one, so this is acceptable. A wrong login on a night that posts nothing is never detected.
  3. On a timed-out night the helper exits 124 even when the reporter exited 77 or 1. The reporter's own journal line still says what happened.
  4. This is the existing trust model, not something the delta changed: the login is kayg, and AI agent jobs also post as kayg through fj. A job comment that quotes one of the reporter's hidden state markers verbatim would count as the reporter's own. Jobs should never paste raw markers into comments.

Root reinstall (dev-02, as root, pinned to the full SHA; --forgejo-login is required the first time)

SHA=ee547089148584098cb5361b52daf8c0aee82229
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA" --forgejo-login kayg; sudo rm -rf "$d"

(If it is merged first, use the merge commit's full SHA instead.) Install only while the unit is idle.

File run 20261109T134307Z-c442500 from its staged files

That staging was left by the older helper, so it has no staged.json and needs --run-name. Run this before the next timer firing, because a new night replaces browser-staging:

sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name 20261109T134307Z-c442500

Expected: one BROWSER_REPORT_DONE … line and exit 0. Exit 77 with TOKEN LACKS PERMISSION means the token still lacks a scope; the staged files stay, so you can fix the token and repeat the command (issues are deduplicated).

Not done here: no merge, push, deploy or install.

## Security fix-check of `nightly-suite-partial-report`: **APPROVE** at `ee547089148584098cb5361b52daf8c0aee82229` I'm an independent reviewer (Claude Opus 5.5). This covers the 4 commits on origin/dev 547a48885, on top of the reviewed and installed 5d9683074. I read the whole delta in `nightly-browser-suite-service`, `nightly-browser-suite.py`, `install-nightly-browser-suite` and `apps/tracer/nightly-reporter.ts`. ### Gate `python3 -I scripts/test-nightly-browser-suite.py` on a clean `git archive` of ee5470891: **Ran 73 tests in 18.307s, OK**. ### Each new test fails without its fix (one hunk reverted per scratch copy) | reverted hunk | result | |---|---| | kill back to `kill -s KILL -- -1` | FAIL cleanup_kill_command_works_for_real, a_cleanup_that_cannot_run_stops_the_night; ERROR token_account_never_runs_repository_code | | kill exit status ignored | FAIL a_cleanup_that_cannot_run_stops_the_night | | `GET /user` call put back | ERROR no_request_needs_the_user_scope | | post-author check removed | FAIL own_issues_are_recognised_by_the_configured_login | | `written_by` always true | FAIL a_forged_fixed_comment_is_ignored, only_the_token_owners_markers_count, own_issues_… | | 401/403 handled like any HTTPError | FAIL forbidden_token_is_said_plainly_and_the_staged_files_stay | | `--report-only` returns early | FAIL report_only_reruns_just_the_reporter_on_staged_files | | stale-`latest` check removed | FAIL a_stale_run_folder_is_not_reported_as_tonights_partial_results | | timeout path ignores results.jsonl | 4 ERRORs (timed_out_suite_files_the_failures_it_saw_and_one_note, specs_that_never_ran_…, …) | | `skipped`/interrupted counted as failed | FAIL jsonl_collect_counts_only_real_failures | | `clean_name` skipped on streamed file names | FAIL jsonl_collect_counts_only_real_failures | | timeout-note dedupe removed | FAIL timed_out_suite_files_the_failures_it_saw_and_one_note | | `TIMEOUT_LABEL` check removed | FAIL suite_timeout_argument_is_validated (4 subtests) | | login file owner check removed | FAIL login_comes_from_a_root_owned_file | | budget back to 6h | FAIL suite_budget_starts_after_the_slot_is_granted, a_timed_out_suite_still_reports_what_it_finished | | streamed title `[:MAX_LINE]` cut removed | **survives** (see the non-blocking notes) | ### Attacks on the delta - **Can the suite account point `--report-only` at its own files? No.** `--results` is fixed to `/var/lib/infraternal-fuzz/browser-staging`, the token account's 0700 folder. Only `fresh_staging`/`stage` (descriptor-based: O_NOFOLLOW, owner check, nlink 1, CAPS) ever fill it. `--report-only` stages nothing and reads nothing as root. The reporter reads every file, including the new `staged.json` (4 KiB cap), with `read_regular` (O_NOFOLLOW, regular file, a single link, size cap). The run name read from `staged.json` still goes through `RUN_NAME`, and the timeout label goes through `TIMEOUT_LABEL` (a `../x` name is refused, as tested). The helper's own `--run-name` is checked against `RUN_NAME` before anything runs. The new `results.jsonl` goes through the same `read_owned` path with a 32 MiB cap. A missing file is allowed only for the two optional result files, and at least one of them must be present. - **Can the marker/author logic be spoofed? No new hole.** The login comes from a root-owned file (O_NOFOLLOW, uid 0, at most 256 bytes, the `LOGIN` regex). `written_by` is still exact equality against it on every marker read (open issues, state comments, pooled names, and the new timeout note on both body and comments). A comment by another account is never counted (tests above). Each POST response's author is checked against the configured login, and a mismatch stops the night. - **Can streamed results inject markdown or flood issues? No.** Streamed file names go through `clean_name`, so a malformed name lands in UNPARSEABLE, which is never reported fixed. Titles and errors land inside the `fence_for` fence. Errors are cut by `clip_lines`, ANSI codes are stripped, and the body is cut to MAX_BODY. The timeout note contains only integers and a validated label. Streams are capped at 200 000 lines and 32 MiB. The open-issue caps and the pooling into one roll-up are shared code and unchanged. A spec that was never reached is never reported fixed, and an interrupted test is not a failure. - **Does the kill still run only as the suite account? Yes.** It goes through `as_user` (setpriv sets the real and effective uid and gid to infraternal-browser, with a reset environment, fixed PATH, cwd `/`, and an absolute `/usr/bin/python3`). `kill(-1)` therefore reaches only that uid's processes. ESRCH counts as success. Any other failure means nothing is staged or reported (fail closed). ### Non-blocking notes (follow-ups, not required) 1. No test covers the cut of streamed titles to MAX_LINE. Without it a title is still bounded by MAX_BODY and the fence, so this is a coverage gap, not an injection. 2. If the response has no `user` field, `post_as` accepts it. Forgejo always returns one, so this is acceptable. A wrong login on a night that posts nothing is never detected. 3. On a timed-out night the helper exits 124 even when the reporter exited 77 or 1. The reporter's own journal line still says what happened. 4. This is the existing trust model, not something the delta changed: the login is `kayg`, and AI agent jobs also post as `kayg` through fj. A job comment that quotes one of the reporter's hidden state markers verbatim would count as the reporter's own. Jobs should never paste raw markers into comments. ### Root reinstall (dev-02, as root, pinned to the full SHA; `--forgejo-login` is required the first time) ``` SHA=ee547089148584098cb5361b52daf8c0aee82229 d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA" --forgejo-login kayg; sudo rm -rf "$d" ``` (If it is merged first, use the merge commit's full SHA instead.) Install only while the unit is idle. ### File run `20261109T134307Z-c442500` from its staged files That staging was left by the older helper, so it has no `staged.json` and needs `--run-name`. Run this **before the next timer firing**, because a new night replaces `browser-staging`: ``` sudo /usr/local/libexec/infraternal-browser-suite-service --report-only --run-name 20261109T134307Z-c442500 ``` Expected: one `BROWSER_REPORT_DONE …` line and exit 0. Exit 77 with `TOKEN LACKS PERMISSION` means the token still lacks a scope; the staged files stay, so you can fix the token and repeat the command (issues are deduplicated). Not done here: no merge, push, deploy or install.
Author
Owner

Started nightly-suite-shards (from origin/dev 06fc52d92). Plan, estimate 3 h plus the manual run (about 2 h of suite):

  1. run --shards 2: one shared results.jsonl that both shards append to, every line tagged with its shard, so a killed run still leaves a merged, partial-safe file; per-shard port pair (4731/4732, 4733/4734), TRACER_DIST and evidence folder; log lines prefixed [s1]/[s2] in one suite.log.
  2. Files go longest-first by the previous night's per-file seconds (runs/file-times.json, written at the end of each run); with no usable times it falls back to Playwright's --shard=i/2.
  3. The root helper takes one build slot per shard (2 of dev-02's 5), and releases all of them at the end or on a queue timeout.
  4. The reporter says which shard ran what: each spec issue names its shard, and when a shard crashes or does not finish, the roll-up gets one note ("shard 2 of 2: ... did not finish"), together with the timeout note.
  5. clusters-bench gets @perf in its title; the nightly passes --grep-invert=@perf. J386's fix-compute01-browser-baseline (#434, not merged) adds grepInvert: /@perf/ to playwright.config.ts unless TRACER_PERF=1; I do not touch that file, so the two do not conflict, and the flag is redundant but harmless once theirs merges.
  6. keyboard-sweep stays at both sizes.
Started `nightly-suite-shards` (from origin/dev 06fc52d92). Plan, estimate 3 h plus the manual run (about 2 h of suite): 1. `run --shards 2`: one shared `results.jsonl` that both shards append to, every line tagged with its shard, so a killed run still leaves a merged, partial-safe file; per-shard port pair (4731/4732, 4733/4734), `TRACER_DIST` and evidence folder; log lines prefixed `[s1]`/`[s2]` in one `suite.log`. 2. Files go longest-first by the previous night's per-file seconds (`runs/file-times.json`, written at the end of each run); with no usable times it falls back to Playwright's `--shard=i/2`. 3. The root helper takes one build slot per shard (2 of dev-02's 5), and releases all of them at the end or on a queue timeout. 4. The reporter says which shard ran what: each spec issue names its shard, and when a shard crashes or does not finish, the roll-up gets one note ("shard 2 of 2: ... did not finish"), together with the timeout note. 5. `clusters-bench` gets `@perf` in its title; the nightly passes `--grep-invert=@perf`. J386's `fix-compute01-browser-baseline` (#434, not merged) adds `grepInvert: /@perf/` to `playwright.config.ts` unless `TRACER_PERF=1`; I do not touch that file, so the two do not conflict, and the flag is redundant but harmless once theirs merges. 6. `keyboard-sweep` stays at both sizes.
Author
Owner

Progress on nightly-suite-shards (head a2b674871 pushed). Code and tests are done (88 tests OK in scripts/test-nightly-browser-suite.py, typecheck OK); the manual 2-shard run on dev-02 is in progress (started 00:02 UTC, 327 of about 1,230 tests after 27 min, both shards running; log ~/j406-ev/shardruns/20261110T000158Z-a2b6748/suite.log). verify quick follows when the run frees its slots.

Finding from the first attempt: the installed nightly was running at the same time (its test server holds 4731/4732), so my shard 1 failed with http://localhost:4731/health is already used and shard 2 (4733/4734) ran. I stopped that attempt by exact PIDs (the live nightly was not touched). That made me add NIGHTLY_BASE_PORT (validated, 1024-65000, default unchanged) so a manual run can sit beside the service; the second attempt uses 5731-5734. It also shows the design works as intended when one shard fails: shard 2 streamed results and shard 1 had none.

One thing for the Maintainer to know when reinstalling: with the new helper, the nightly takes two build slots (one per shard), so the service and a manual run cannot overlap on ports unless NIGHTLY_BASE_PORT differs.

Progress on `nightly-suite-shards` (head a2b674871 pushed). Code and tests are done (88 tests OK in `scripts/test-nightly-browser-suite.py`, typecheck OK); the manual 2-shard run on dev-02 is in progress (started 00:02 UTC, 327 of about 1,230 tests after 27 min, both shards running; log `~/j406-ev/shardruns/20261110T000158Z-a2b6748/suite.log`). `verify quick` follows when the run frees its slots. Finding from the first attempt: the **installed nightly was running at the same time** (its test server holds 4731/4732), so my shard 1 failed with `http://localhost:4731/health is already used` and shard 2 (4733/4734) ran. I stopped that attempt by exact PIDs (the live nightly was not touched). That made me add `NIGHTLY_BASE_PORT` (validated, 1024-65000, default unchanged) so a manual run can sit beside the service; the second attempt uses 5731-5734. It also shows the design works as intended when one shard fails: shard 2 streamed results and shard 1 had none. One thing for the Maintainer to know when reinstalling: with the new helper, the nightly takes **two** build slots (one per shard), so the service and a manual run cannot overlap on ports unless `NIGHTLY_BASE_PORT` differs.
Author
Owner

Maintainer: reporter fix installed on dev-02; tonight's run is filed

  • Tonight's timer run (old helper) started 2026-11-09 23:43:16 UTC and finished 03:57:38 UTC: BROWSER_SUITE_DONE playwright_exit=1 head=8c2b8f5… out=…/runs/20261109T234323Z-8c2b8f5. It hit the expected 403 at reporting, but its results were staged. pgrep -u infraternal-browser shows nothing left.
  • Reinstalled at 06fc52d928dab440ba919e80cdc0d1c862068e2a with --forgejo-login kayg, at 04:41 UTC. Installer exit 0. /etc/infraternal-browser-suite.login is root:root 644, containing kayg. The new helper is in place (9 h suite budget, python os.kill(-1) cleanup), TimeoutStartSec=24h. The queue folder is 775 claude:claude and the token has 1 hard link. Next timer run: 2026-11-10 23:37:06 UTC.
  • Report-only: infraternal-browser-suite-service --report-only --run-name 20261109T234323Z-8c2b8f5 printed:
    opened roll-up issue #482 specs=34 head=8c2b8f5dd166eee270c5aa5484572dd94b3a1c76
    BROWSER_REPORT_DONE failing=34 passing=131 request_errors=0 head=8c2b8f5dd166eee270c5aa5484572dd94b3a1c76
    
    Exit 0. #482 is the roll-up: 34 of 165 failing is over the per-spec threshold. The staged files are unchanged.
  • The first run's (20261109T134307Z-c442500) staging was overwritten by tonight's run, as expected. Copies of it are in /home/claude/j406-ev/c442500/, so it can't be filed through --report-only without re-staging it.
**Maintainer: reporter fix installed on dev-02; tonight's run is filed** - **Tonight's timer run** (old helper) started 2026-11-09 23:43:16 UTC and finished 03:57:38 UTC: `BROWSER_SUITE_DONE playwright_exit=1 head=8c2b8f5… out=…/runs/20261109T234323Z-8c2b8f5`. It hit the expected 403 at reporting, but its results were staged. `pgrep -u infraternal-browser` shows nothing left. - **Reinstalled** at `06fc52d928dab440ba919e80cdc0d1c862068e2a` with `--forgejo-login kayg`, at 04:41 UTC. Installer exit 0. `/etc/infraternal-browser-suite.login` is `root:root 644`, containing `kayg`. The new helper is in place (9 h suite budget, python `os.kill(-1)` cleanup), `TimeoutStartSec=24h`. The queue folder is `775 claude:claude` and the token has 1 hard link. Next timer run: 2026-11-10 23:37:06 UTC. - **Report-only:** `infraternal-browser-suite-service --report-only --run-name 20261109T234323Z-8c2b8f5` printed: ``` opened roll-up issue #482 specs=34 head=8c2b8f5dd166eee270c5aa5484572dd94b3a1c76 BROWSER_REPORT_DONE failing=34 passing=131 request_errors=0 head=8c2b8f5dd166eee270c5aa5484572dd94b3a1c76 ``` Exit 0. #482 is the roll-up: 34 of 165 failing is over the per-spec threshold. The staged files are unchanged. - The first run's (`20261109T134307Z-c442500`) staging was overwritten by tonight's run, as expected. Copies of it are in `/home/claude/j406-ev/c442500/`, so it can't be filed through `--report-only` without re-staging it.
Author
Owner

Request for the Maintainer (via the orchestrator), for the #482 triage: /var/lib/infraternal-browser/runs/20261109T234323Z-8c2b8f5/ is 0700, so claude cannot read suite.log, results.jsonl or the evidence. Please copy suite.log and results.jsonl (and, if small, the evidence/ folder) to /home/claude/j406-ev/r8c2b8f5/ with a+r, originals untouched. Meanwhile I am triaging from my own runs on the same specs and from fresh reruns on current dev (3c0b20252); the comment on #482 will say which parts rest on those and which on the 8c2b8f5 logs.

Request for the Maintainer (via the orchestrator), for the #482 triage: `/var/lib/infraternal-browser/runs/20261109T234323Z-8c2b8f5/` is 0700, so `claude` cannot read `suite.log`, `results.jsonl` or the evidence. Please copy `suite.log` and `results.jsonl` (and, if small, the `evidence/` folder) to `/home/claude/j406-ev/r8c2b8f5/` with `a+r`, originals untouched. Meanwhile I am triaging from my own runs on the same specs and from fresh reruns on current dev (3c0b20252); the comment on #482 will say which parts rest on those and which on the 8c2b8f5 logs.
Author
Owner

Triage of the 34 red spec files (run 20261109T234323Z-8c2b8f5) — J406

Verdict: this is not a dead or slow test server. It is 34 files of mostly spec/product drift on dev, deterministic, and most of it is already in an open branch.

Evidence base.

  • The 8c2b8f5 run itself (copied by the Maintainer to /home/claude/j406-ev/r8c2b8f5/ on dev-02, copy of suite.log and results.json on the control host in ~/.cache/codex-scratch/j406/evidence/tri/): single worker, 4.24 h, 1,240 passed, 80 failed, 195 skipped. In suite.log there are 0 ECONNRESET/ECONNREFUSED/socket hang up lines and 0 90 s timeouts, and every server-dependent spec outside these 34 passed. So no server death, no slow server.
  • Rerun on current dev (3c0b20252) of 33 of the 34 files (all except clusters-bench, a 4-minute frame benchmark), two parallel Playwright runs on dev-02, ports 5741 and 5751, one build slot each, nothing else changed: 344 passed, 77 failed against 342 passed, 79 failed for the same files at 8c2b8f5. Same errors in 31 of 33 files; only keytray-modifiers went green and sessions-canvas went from 3 to 2 red tests (logs ~/j406-ev/tri1/, tri2/ on dev-02; results copied next to the 8c2b8f5 ones).
  • Earlier runs on other commits and a 2-shard run agree: the 10 files that were red only in my 2-shard run (access-level, clusters-keyboard-split, core-screen, font-metrics, font-picker, forget-retired-host, machine-sections, palette-navigation, phone-window-width, settings-rows-of-windows) were rerun without shards on the same head and failed the same way (21 failed tests, ~/j406-ev/rerun10.log), so they are dev drift, not sharding artefacts. (forget-retired-host was not red in the 8c2b8f5 run; it is not one of the 34.)
# Group Specs (tests red) Cause Evidence Fix owner
1 Stale specs after D336 stacked toasts ca-unlock (7), inbox (1), machine-pill-flicker (1), mesh-rename (1), mesh (1), startup-demo (6), sessions-canvas (3, now 2) A second toast is on screen, so locator('.ui-toast') hits "strict mode violation: resolved to 2 elements" error text in results.json of both runs, same on current dev J386, #434, branch fix-compute01-browser-baseline (head a9a68b702, not merged): it edits exactly these specs. Merge it, then re-run
2 Stale specs: confirm tip name / setup dialog / backdrop addhost-pill-stream (8), addhost-pill (1), core-screen (1) The confirm tip is now named by its message (#391); getByRole('dialog', { name: 'Setup of o2' }) not found; the backdrop --mesh-image is a gradient, not blob: errors above J386 branch (same one; core-screen.spec.ts and src/core-screen.ts are in it)
3 Fixed in J386's branch (product or baseline change) access-level (1), canvas (2), wheel-scroll (4), recovery-ui-hold (4) access-level: the consent card leaves no room under its buttons, so the tooltip opens above and covers the field (real placement bug); canvas: palette height assertions; wheel-scroll: Settings/docs ids changed; recovery-ui-hold: 0-pixel sign-in baselines differ by CPU architecture (this host is arm64) J386's comments on #434; branch commit a9a68b702 ("leave room under the consent buttons") and the per-architecture PNGs J386 branch. Their own focused run still had canvas.spec:871 (palette ::before inset) red
4 Perf and frame-budget specs on a shared host clusters-bench (1, 240 s timeout), clusters-keyboard-split (1: 29 to 38 dropped frames, limit 4), motion-audit (1: "0.18s" vs "0.2s" at 4x CPU) Speed measurements, not functional checks; they belong on the perf VM (the owner's rule) clusters-bench took 4 min and failed in both full runs; the other two fail identically at 8c2b8f5 and now J386 tags the last two @perf with grepInvert in playwright.config.ts; my branch nightly-suite-shards (head a2b674871, #443) tags clusters-bench @perf and passes --grep-invert=@perf. No product work
5 Account appearance sync (#400, D352/D366) font-picker (1), machine-sections (2), settings-rows-of-windows (1), terminal-theme (1, likely) Specs still read localStorage (tracer.extended-info, the saved font) that the synced settings no longer write that way, and expect the Themes window's section list without the new "Interface font" section (commit 469165a10 updated one spec, not this one). terminal-theme (selection background after choosing a theme) looks like the same family but I did not trace it font-picker: saved is null; machine-sections: key tracer.extended-info is null after the click; settings-rows: extra row "Interface font" in the expected list The #400 job (commits 7fbd176cc, 22a981116, 469165a10 are on dev). No open branch covers these: propose a new job "update the specs that read localStorage after #400, and check that sync writes it in the mock". latency-targets-120hz touches terminal-theme.spec.ts; its owner should say whether that fixes it
6 Swipe stops and tap-to-step clusters-swipes (2), clusters-swipes-both (2), clusters (1) The settings stop count is 5, not 9 (stops 5 vs >= 8); after swipes canvasLock.index is 0 where 6 is expected; tapping a peeking neighbour does not move errors above, identical now swipe-content-first-d374 (D374) touches the swipe behaviour but not these specs: its owner to say whether these specs are stale or the behaviour regressed
7 Font loading font-metrics (10 tests: Iosevka, Recursive Mono, Hack, Inconsolata, Lilex, each at DPR 1 and 2) No face of those five fonts reports loaded (mapleLoaded is false); the other 20 fonts pass. Fonts added in D329 are "fetched only when chosen" files are present and non-empty in apps/tracer/fonts/; same at 8c2b8f5 and now Needs an owner: the D329 fonts job. I did not find the cause
8 Layout and design changes with no owner yet phone-window-width (2: a lone window is 349 px wide on a 393 px phone), phone-polish (2: the host name pill is cut), host-removal-confirm (6: text behind the card is legible, correlation 1.0, but the spec wants < 0.15), hosts-new-window-review (1: no "Open shell on review-offline-host" option), mesh-view-overlap (1: "containers" overlaps "mesh" at 600x900), palette-navigation (1: "Palette has no selected option"), clusters-close-centre (1: close X off by 0.75 px, limit 0.5) host-removal-confirm is probably D380 ("confirmation cards are glass", merged 2026-11-09): the card is now see-through by design, so the spec is stale. The others look like real layout or behaviour changes or tolerance limits; I did not bisect them errors above host-removal-confirm: the D380 job. The rest: propose one job that bisects each to its commit and decides stale spec vs regression (7 specs, small)
9 Flakes keytray-modifiers (1, green now), perf-hud (1: the injected stall was recorded 0 times at 8c2b8f5, and the next error is about script attribution now) Timing-dependent; perf-hud records an injected 200 ms stall and depends on the host's load keytray-modifiers passed in the rerun; perf-hud fails differently on the two runs perf-hud: J386 judged it not a frame budget and did not tag it; it needs a look by whoever owns the HUD. keytray-modifiers: leave it, watch it

All 34 files are in exactly one row (7+3+4+3+4+3+1+7+2).

Counts. Covered by an open branch (rows 1, 2, 3 and the @perf part of 4): 17 files. Need a new job or an owner's answer: rows 5 to 9, 17 files. I changed no product code and did not close this issue.

What this means for the nightly. About half of this roll-up goes away when #434 merges. The reporter already files per spec once the cap allows; the open-issue budget is 30, so no new issues are filed for these until the roll-up is resolved. When #434 lands, the next nights show what is left.

## Triage of the 34 red spec files (run `20261109T234323Z-8c2b8f5`) — J406 **Verdict: this is not a dead or slow test server. It is 34 files of mostly spec/product drift on dev, deterministic, and most of it is already in an open branch.** **Evidence base.** - **The 8c2b8f5 run itself** (copied by the Maintainer to `/home/claude/j406-ev/r8c2b8f5/` on dev-02, copy of `suite.log` and `results.json` on the control host in `~/.cache/codex-scratch/j406/evidence/tri/`): single worker, 4.24 h, 1,240 passed, 80 failed, 195 skipped. In `suite.log` there are **0** `ECONNRESET`/`ECONNREFUSED`/`socket hang up` lines and **0** 90 s timeouts, and every server-dependent spec outside these 34 passed. So no server death, no slow server. - **Rerun on current dev (3c0b20252)** of 33 of the 34 files (all except `clusters-bench`, a 4-minute frame benchmark), two parallel Playwright runs on dev-02, ports 5741 and 5751, one build slot each, nothing else changed: **344 passed, 77 failed** against 342 passed, 79 failed for the same files at 8c2b8f5. Same errors in 31 of 33 files; only `keytray-modifiers` went green and `sessions-canvas` went from 3 to 2 red tests (logs `~/j406-ev/tri1/`, `tri2/` on dev-02; results copied next to the 8c2b8f5 ones). - Earlier runs on other commits and a 2-shard run agree: the 10 files that were red only in my 2-shard run (`access-level`, `clusters-keyboard-split`, `core-screen`, `font-metrics`, `font-picker`, `forget-retired-host`, `machine-sections`, `palette-navigation`, `phone-window-width`, `settings-rows-of-windows`) were rerun without shards on the same head and failed the same way (21 failed tests, `~/j406-ev/rerun10.log`), so they are dev drift, not sharding artefacts. (`forget-retired-host` was not red in the 8c2b8f5 run; it is not one of the 34.) | # | Group | Specs (tests red) | Cause | Evidence | Fix owner | |--:|---|---|---|---|---| | 1 | **Stale specs after D336 stacked toasts** | ca-unlock (7), inbox (1), machine-pill-flicker (1), mesh-rename (1), mesh (1), startup-demo (6), sessions-canvas (3, now 2) | A second toast is on screen, so `locator('.ui-toast')` hits "strict mode violation: resolved to 2 elements" | error text in `results.json` of both runs, same on current dev | **J386, #434**, branch `fix-compute01-browser-baseline` (head a9a68b702, not merged): it edits exactly these specs. Merge it, then re-run | | 2 | **Stale specs: confirm tip name / setup dialog / backdrop** | addhost-pill-stream (8), addhost-pill (1), core-screen (1) | The confirm tip is now named by its message (#391); `getByRole('dialog', { name: 'Setup of o2' })` not found; the backdrop `--mesh-image` is a gradient, not `blob:` | errors above | J386 branch (same one; `core-screen.spec.ts` and `src/core-screen.ts` are in it) | | 3 | **Fixed in J386's branch (product or baseline change)** | access-level (1), canvas (2), wheel-scroll (4), recovery-ui-hold (4) | access-level: the consent card leaves no room under its buttons, so the tooltip opens above and covers the field (real placement bug); canvas: palette height assertions; wheel-scroll: Settings/docs ids changed; recovery-ui-hold: 0-pixel sign-in baselines differ by CPU architecture (this host is arm64) | J386's comments on #434; branch commit a9a68b702 ("leave room under the consent buttons") and the per-architecture PNGs | J386 branch. Their own focused run still had `canvas.spec:871` (palette `::before` inset) red | | 4 | **Perf and frame-budget specs on a shared host** | clusters-bench (1, 240 s timeout), clusters-keyboard-split (1: 29 to 38 dropped frames, limit 4), motion-audit (1: "0.18s" vs "0.2s" at 4x CPU) | Speed measurements, not functional checks; they belong on the perf VM (the owner's rule) | `clusters-bench` took 4 min and failed in both full runs; the other two fail identically at 8c2b8f5 and now | J386 tags the last two `@perf` with `grepInvert` in `playwright.config.ts`; my branch `nightly-suite-shards` (head a2b674871, #443) tags `clusters-bench` `@perf` and passes `--grep-invert=@perf`. No product work | | 5 | **Account appearance sync (#400, D352/D366)** | font-picker (1), machine-sections (2), settings-rows-of-windows (1), terminal-theme (1, likely) | Specs still read `localStorage` (`tracer.extended-info`, the saved font) that the synced settings no longer write that way, and expect the Themes window's section list without the new "Interface font" section (commit 469165a10 updated one spec, not this one). `terminal-theme` (selection background after choosing a theme) looks like the same family but I did not trace it | font-picker: `saved` is `null`; machine-sections: key `tracer.extended-info` is `null` after the click; settings-rows: extra row "Interface font" in the expected list | The #400 job (commits 7fbd176cc, 22a981116, 469165a10 are on dev). **No open branch covers these: propose a new job "update the specs that read localStorage after #400, and check that sync writes it in the mock"**. `latency-targets-120hz` touches `terminal-theme.spec.ts`; its owner should say whether that fixes it | | 6 | **Swipe stops and tap-to-step** | clusters-swipes (2), clusters-swipes-both (2), clusters (1) | The settings stop count is 5, not 9 (`stops` 5 vs >= 8); after swipes `canvasLock.index` is 0 where 6 is expected; tapping a peeking neighbour does not move | errors above, identical now | `swipe-content-first-d374` (D374) touches the swipe behaviour but not these specs: its owner to say whether these specs are stale or the behaviour regressed | | 7 | **Font loading** | font-metrics (10 tests: Iosevka, Recursive Mono, Hack, Inconsolata, Lilex, each at DPR 1 and 2) | No face of those five fonts reports `loaded` (`mapleLoaded` is false); the other 20 fonts pass. Fonts added in D329 are "fetched only when chosen" | files are present and non-empty in `apps/tracer/fonts/`; same at 8c2b8f5 and now | **Needs an owner**: the D329 fonts job. I did not find the cause | | 8 | **Layout and design changes with no owner yet** | phone-window-width (2: a lone window is 349 px wide on a 393 px phone), phone-polish (2: the host name pill is cut), host-removal-confirm (6: text behind the card is legible, correlation 1.0, but the spec wants < 0.15), hosts-new-window-review (1: no "Open shell on review-offline-host" option), mesh-view-overlap (1: "containers" overlaps "mesh" at 600x900), palette-navigation (1: "Palette has no selected option"), clusters-close-centre (1: close X off by 0.75 px, limit 0.5) | host-removal-confirm is probably D380 ("confirmation cards are glass", merged 2026-11-09): the card is now see-through by design, so the spec is stale. The others look like real layout or behaviour changes or tolerance limits; I did not bisect them | errors above | host-removal-confirm: the D380 job. The rest: **propose one job that bisects each to its commit and decides stale spec vs regression** (7 specs, small) | | 9 | **Flakes** | keytray-modifiers (1, green now), perf-hud (1: the injected stall was recorded 0 times at 8c2b8f5, and the next error is about script attribution now) | Timing-dependent; `perf-hud` records an injected 200 ms stall and depends on the host's load | keytray-modifiers passed in the rerun; perf-hud fails differently on the two runs | `perf-hud`: J386 judged it not a frame budget and did not tag it; it needs a look by whoever owns the HUD. `keytray-modifiers`: leave it, watch it | All 34 files are in exactly one row (7+3+4+3+4+3+1+7+2). **Counts.** Covered by an open branch (rows 1, 2, 3 and the `@perf` part of 4): 17 files. Need a new job or an owner's answer: rows 5 to 9, 17 files. I changed no product code and did not close this issue. **What this means for the nightly.** About half of this roll-up goes away when #434 merges. The reporter already files per spec once the cap allows; the open-issue budget is 30, so no new issues are filed for these until the roll-up is resolved. When #434 lands, the next nights show what is left.
Author
Owner

nightly-suite-shards ready for review — head 3c46d3eee2 (git ls-remote confirms; 5 commits on origin/dev 06fc52d92). Nothing installed.

python3 -I scripts/test-nightly-browser-suite.py: 88 tests OK (red first, including a crashed shard, a shard stopped by the budget, hostile shard numbers, the two-slot helper and a second slot that never comes); test-nightly-fuzz.py OK; verify quick on dev-02 through build-slot: all 12 rows PASS (~/j406-ev/quick10.log; an earlier quick found two missing doc comments, fixed in the last commit).

What it does (details in the commit messages and docs/PROCESS.md): 2 Playwright shards, each with its own port pair, TRACER_DIST, evidence folder and build slot (the helper takes 2 slots under one queue budget); files split longest-first from the previous night's per-file seconds (runs/file-times.json), else --shard=i/2; one shared results.jsonl that both shards append to, tagged by shard; one suite.log with [s1]/[s2] prefixes; spec issues name their shard; a crashed or unfinished shard adds one note to the roll-up. clusters-bench is tagged @perf and the nightly passes --grep-invert=@perf. keyboard-sweep is unchanged. J386's branch (fix-compute01-browser-baseline, #434) puts the same exclusion in playwright.config.ts; I did not touch that file, so the two do not conflict.

Manual 2-shard run on dev-02 (head a2b674871 = dev 06fc52d92 plus this branch; NIGHTLY_BASE_PORT=5731, one slot per shard):

  • Wall time: 2.08 h and 2.10 h (7,489 s and 7,546 s) for the two shards, which run side by side, versus 4.08 h and 4.24 h for the single-worker nights. The shards are balanced to within 1%. This is x1.97 of the single-worker 4.08 h, as estimated. The unit's 9 h budget has room for a 3 shard setup, not needed.
  • 1,523 tests (856 + 667 planned), 1,248 passed, 80 failed, 195 skipped; both shards exit 1 (failures), neither crashed.
  • Red set vs the closest baseline, the 8c2b8f5 night (34 files): 33 of 34 are identical. The only one missing is clusters-bench (now @perf, excluded). Two files are red only in this run: forget-retired-host and pending-host-removal, both on dev after 8c2b8f5 (the first is a strict-mode "Forget" button match). Against c442500's 33 files: 25 in common; the 8 that went green or left are docs-palette, glass-ghost, sign-out, toast-stack, pending-removal-command (fixed on dev since) and the perf-tagged clusters-bench, clusters-frames and mesh-perf (@perf is only on the first in this branch; the other two are not red now). The 10 files red only in this run were rerun without shards on the same head and failed the same way (21 tests, ~/j406-ev/rerun10.log), so sharding did not create reds.
  • Raw numbers: ~/.cache/codex-scratch/j406/evidence/shards/analysis.txt and results.jsonl (merged, 1,523 test lines plus 2 shard lines) on the control host.

Found on the way: the installed nightly was running while I ran my first attempt, so my shard 1 hit "port already used" on 4731 (nothing of the live run was disturbed; I stopped mine by exact PIDs). That is why NIGHTLY_BASE_PORT exists. For the Maintainer when reinstalling: the nightly now holds two build slots, one per shard.

Reinstall commands (as root; after review; --forgejo-login is only needed once and the login file already exists)

SHA=3c46d3eee2788887a15381a0c0351c66a2972a9d
d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \
 && sudo git -C "$d/src" checkout -q --detach "$SHA" \
 && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d"

If the branch is merged first, use the merge commit's full SHA.

Proposed follow-up slice (not built; the Maintainer's proposal, noted here)

Publish each night's run as a claude-readable copy from the root helper, so the next triage does not need a manual copy: after stage(), the helper copies the run into /var/lib/infraternal-browser-pub/<run>/ (root-owned, dirs 0755, files 0644) under the same hostile-content rules as stage() (O_NOFOLLOW, regular files only, owned by the suite account, a single link, per-file and per-run size caps, about 1 GB a run, no devices or sockets), walking the evidence tree by directory descriptors. Keep the last 7 runs (prune on publish, or a tmpfiles.d age rule that the installer writes). The browser home stays 0700 and the reporter stays unchanged. Tests would be PoC-style like the staging ones (a symlink to the token, a FIFO, a hard link, a file over the cap, a directory loop, a name with ..). Estimate 2 h plus review. I will build it only after you say so.

## `nightly-suite-shards` ready for review — head **3c46d3eee2788887a15381a0c0351c66a2972a9d** (`git ls-remote` confirms; 5 commits on origin/dev 06fc52d92). Nothing installed. `python3 -I scripts/test-nightly-browser-suite.py`: 88 tests OK (red first, including a crashed shard, a shard stopped by the budget, hostile shard numbers, the two-slot helper and a second slot that never comes); `test-nightly-fuzz.py` OK; `verify quick` on dev-02 through build-slot: all 12 rows PASS (`~/j406-ev/quick10.log`; an earlier quick found two missing doc comments, fixed in the last commit). **What it does** (details in the commit messages and `docs/PROCESS.md`): 2 Playwright shards, each with its own port pair, `TRACER_DIST`, evidence folder and build slot (the helper takes 2 slots under one queue budget); files split longest-first from the previous night's per-file seconds (`runs/file-times.json`), else `--shard=i/2`; one shared `results.jsonl` that both shards append to, tagged by shard; one `suite.log` with `[s1]`/`[s2]` prefixes; spec issues name their shard; a crashed or unfinished shard adds one note to the roll-up. `clusters-bench` is tagged `@perf` and the nightly passes `--grep-invert=@perf`. `keyboard-sweep` is unchanged. J386's branch (`fix-compute01-browser-baseline`, #434) puts the same exclusion in `playwright.config.ts`; I did not touch that file, so the two do not conflict. **Manual 2-shard run on dev-02** (head a2b674871 = dev 06fc52d92 plus this branch; `NIGHTLY_BASE_PORT=5731`, one slot per shard): - **Wall time: 2.08 h and 2.10 h** (7,489 s and 7,546 s) for the two shards, which run side by side, versus 4.08 h and 4.24 h for the single-worker nights. The shards are balanced to within 1%. This is x1.97 of the single-worker 4.08 h, as estimated. The unit's 9 h budget has room for a 3 shard setup, not needed. - 1,523 tests (856 + 667 planned), 1,248 passed, 80 failed, 195 skipped; both shards exit 1 (failures), neither crashed. - **Red set vs the closest baseline, the 8c2b8f5 night (34 files):** 33 of 34 are identical. The only one missing is `clusters-bench` (now `@perf`, excluded). Two files are red only in this run: `forget-retired-host` and `pending-host-removal`, both on dev after 8c2b8f5 (the first is a strict-mode "Forget" button match). Against c442500's 33 files: 25 in common; the 8 that went green or left are `docs-palette`, `glass-ghost`, `sign-out`, `toast-stack`, `pending-removal-command` (fixed on dev since) and the perf-tagged `clusters-bench`, `clusters-frames` and `mesh-perf` (`@perf` is only on the first in this branch; the other two are not red now). The 10 files red only in this run were rerun **without** shards on the same head and failed the same way (21 tests, `~/j406-ev/rerun10.log`), so sharding did not create reds. - Raw numbers: `~/.cache/codex-scratch/j406/evidence/shards/analysis.txt` and `results.jsonl` (merged, 1,523 test lines plus 2 shard lines) on the control host. **Found on the way:** the installed nightly was running while I ran my first attempt, so my shard 1 hit "port already used" on 4731 (nothing of the live run was disturbed; I stopped mine by exact PIDs). That is why `NIGHTLY_BASE_PORT` exists. **For the Maintainer when reinstalling:** the nightly now holds two build slots, one per shard. ## Reinstall commands (as root; after review; `--forgejo-login` is only needed once and the login file already exists) ``` SHA=3c46d3eee2788887a15381a0c0351c66a2972a9d d=$(sudo mktemp -d) && sudo git clone -q https://git.kayg.org/kayg/infraternal.git "$d/src" \ && sudo git -C "$d/src" checkout -q --detach "$SHA" \ && sudo python3 -I "$d/src/scripts/install-nightly-browser-suite" --ref "$SHA"; sudo rm -rf "$d" ``` If the branch is merged first, use the merge commit's full SHA. ## Proposed follow-up slice (not built; the Maintainer's proposal, noted here) **Publish each night's run as a claude-readable copy from the root helper**, so the next triage does not need a manual copy: after `stage()`, the helper copies the run into `/var/lib/infraternal-browser-pub/<run>/` (root-owned, dirs 0755, files 0644) under the same hostile-content rules as `stage()` (`O_NOFOLLOW`, regular files only, owned by the suite account, a single link, per-file and per-run size caps, about 1 GB a run, no devices or sockets), walking the evidence tree by directory descriptors. Keep the last 7 runs (prune on publish, or a tmpfiles.d age rule that the installer writes). The browser home stays 0700 and the reporter stays unchanged. Tests would be PoC-style like the staging ones (a symlink to the token, a FIFO, a hard link, a file over the cap, a directory loop, a name with `..`). Estimate 2 h plus review. I will build it only after you say so.
Author
Owner

Correction: the speed-up in the shards report is x1.94 against the 4.08 h single-worker night (4.08 / 2.10) and x2.0 against the 4.24 h 8c2b8f5 night, not x1.97.

Correction: the speed-up in the shards report is x1.94 against the 4.08 h single-worker night (4.08 / 2.10) and x2.0 against the 4.24 h 8c2b8f5 night, not x1.97.
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#443
No description provided.