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
No Branch/Tag specified
main
j470b-server-mesh-rejoin
mesh-compat-382
j444b-d381-key-wins-flood
j461b-macos-utun-reader-thread
j475b-remove-host-motion
fix-reconnect-new-link
j481-d380
dev
j479-mesh-soak
fix/j476-host-facts
j475-motion-pill-split
j470-mac-node-join
root-upgrade-d376
j474-review-merge
latency-targets-120hz
nightly-suite-shards
gotatun-pin-j469
macos-utun-reader-thread
fix-flood-echo-stall
wip/j416
motion-fast-ease-out
mesh-addhost-j259
takeover-close-terminals-d368
kbd-instant-followups
fix-trust-key-double-click
nightly-suite-partial-report
appwin-icons-j207
rusqlite-slice1
swipe-content-first-d374
mesh-mtu-381
addhost-instant-open
glass-contrast-d365-mock
glass-contrast-d365
signout-followups-442
fix-startup-toast-red
research-gotatun-matrix-j317
themes-sync-j304
fix-load-flakes
fix-nix-zig-patch
fix-compute01-browser-baseline
ratchet-size-baseline-419
predict-auto-d130
revoke-coverage-d367
nightly-suite-queue-timeout
nightly-browser-suite
fix-rustsec-2026-0459
fonts-ioskeley-obel
log-retention-d375
fix-kbd-no-anim-427
fix-dev-red-specs-2
takeover-proof-305-j205
wip/j410-server-pump
ui-consistency-d372
fix-osc52-clipboard
libghostty-vt-1.0
forget-retired-host-d369
j404-merge-426
fix-setup-lock-race
fix-touch-select-426
revoke-access-report-ui
fix-predictive-echo-specs
fix-selinux-agent-exec
wip/j395-checkpoint2
wip/j395
j391-review-scratch
ui-consistency-j195-deferred-rebased
fix-rustc-paths-421
decisions-wording-341
fix-install-suite-red
fix-dev-rtt-specs
fix-pending-removal-align
fix-privileged-skip
fix-install-suite-404
review-j368
mesh-dns-j254
revoke-access-report-d356b
revoke-privilege-round5
fix-removal-confirm
review-j360
gotatun-rotation-367
deps-refresh-340
review-j354
review-J351
review-j350
addhost-takeover-d359
revoke-report-items-347
revoke-end-sessions-d362
review-j348
size-mesh-ssh-346
size-trim-337
revoke-access-diff-d356
j259-pre-rebase-b06c5a6
j305-ended-at-start-spec
addhost-sudo-consent
fix-layout-real-specs-411
fix-shell-open-411
fix-console-hosts-373
fix-shell-exit
fuzz-host-name-353
fix-host-details-410
macos-filter-j197
research-gotatun-tricks
revoke-p3-j216l
stale-state-pill
revoke-followups-j216k
revoke-progress-j216e
backup/macos01-20261106/lume/j197
backup/macos01-20261106/lume/appwin-icons-j207-stash0
backup/macos01-20261106/claude/stash-0
backup/macos01-20261106/claude/stash-1
backup/macos01-20261106/claude/J80-macos-s1
backup/macos01-20261106/claude/J80
backup/macos01-20261106/claude/J80-slice1-20261102
backup/macos01-20261106/claude/J16b-appwin-on-dev
backup/macos01-20261106/claude/appwin-launch-idle-146
backup/macos01-20261106/claude/J80-s1-run
wip/j259
trust-285-p3c
runtime-lock-j216f
mesh-ssh-p3-j175
tun-proof-p3b
trust-285-p3
terminal-polish-j271
tun-proof-p3
ssh-install-flakes-j216i
fuzz-fixes-j264
trust-285-a
trust-285-b
restart-oversized-fixture-j198
backup/macos01-20261106/lume/appwin-icons-j207
restart-bound-j216h
up-preflight-p3-j198
backup/macos01-20261106/lume/j251
up-preflight-j198
wip/up-preflight-j198-j245-p2
wip/up-preflight-j198-j245-p1
agent-fsync-offloop-j196
wip/up-preflight-j198-j245
settings-offscreen-j231
wip/up-preflight-j198-j226-inspection
wip/j103f
themes-j206
wip/up-preflight-j198-j226
flakes-j216c
backup/macos01-20261106/claude/j197
flakes-j216b
backup/macos01-20261106/claude/j197-red
wip/up-preflight-j198-review
flakes-j216
backup/macos01-20261106/claude/appwin-icons-j207
engineering-j208b
appwin-mock-j209
toast-stack-j165
engineering-j208
decisions-d351
decisions-d350
decisions-d349
appearance-presets-mock-j129
decisions-d348
docs-audit-spec
doc-coverage-baseline-raise
decisions-d347
decisions-d346b
decisions-d346
j88h-profile
ui-consistency-j195-deferred
ui-consistency-j195
engineering-practices-j194
preferred-login-ui-j166
signin-pools-263-j174
macos-node-265-s1
decisions-d345
decisions-d344
proxy-cgroup-j164
verify-quick-fuzz-329
proxy-start-io-j168
timing-flakes-j163
up-263-s1
mcp-events-j147
verify-quick-fuzz-j158
proxy-p3-j159
agent-resource-j157-detect
wip/j103d-final
remote-cancel-hang-j155
proxy-traefik-281-j62d
j149-verify-quick
proxy-traefik-281-j62b
j150-drop-unnamed
wip/j79d-red-r4
wip/j103d-d332
rootless-toast-j142
wip/j103d-head
wip/j103d
j125c-mcp
macos-build-j136
backup/macos01-20261106/claude/j136
wip/j79d-red-cli
wip/j79d-red-server
j121-link-liveness
j118c-tracer-failures
wip/j79c-head
release-desktop-j131
j124c-signed-macos
wip/j79c-red-p3
wip/j79c-red-p23
wip/j79c-red-p22
merge-j128-check
perf-deferred-j128
sidebar-toggle-260
j127-shortcut-changed-dot
j125-mcp
j118-tracer-failures
j126-account-summary
settings-sections-mock-j92
j120-pinch-lock
sidebar-292
backup/macos01-20261106/claude/j120
taste-2026-11-03
row-actions-242-215
backup/macos01-20261106/claude/stash-0-untracked
backup/macos01-20261106/claude/J80-265-s1-run
backup/macos01-20261106/claude/J80-macos-s1-20261102
j112-canvas-commands
ui-no-overshoot-scroll
fix-recovery-restart
backup/macos01-20261106/claude/stash-1-untracked
taste-2026-11-02
size-baseline-ratchet
machine-cards-structure-j108
j109-boot-copy-mockups
fix-dev-red
wip/up-263-s1
j95b-settings-rows
proxy-traefik-281
j95-option-rows
docs-human-j91
docs-keys-palette
fix-flood-1013-270-j38d
mesh-ssh-s1
mesh-ssh-s0
addhost-d291-tunnel
test-stability-load
mesh-relay-220
repro-296
shim-thp-off-24
mesh-pins-258-v2
mesh-dns
signin-d311-merged
docs-pages
ui-size-283
fix-ui-batch-specs
wip/j12b-edge-update
gate-on-arm
fix-rename-sync-293
trust-285-j60b
fix-focus-ring-clip
integ-ui-1101
edge-update-v2
signin-d311
taste-2026-11-01
layout-per-tab-250
ui-approved-splitopt-224
ui-approved-splitopt-224-on-dev
icon-text-centre-282
ui-approved-189-212-216
research-reverse-proxy
transfer-budget-320
review-mesh-pins-258-r2
sidebar-pill-259
review-mesh-relay-220
wip/j12-edge-update-v2
nix-package
status-crash-loop-237
fix-dev-test-failures
fix-fixture-socket-path
wip/mesh-relay-220-pre-dev-advance-rebase
fix-canvas-side-clip
research-head-to-head
frosted-real-256
review-mesh-pins-258
wip/mesh-relay-220-pre-final-rebase
review-layout-per-tab-250-r2
broad-suite-triage
nix-devshell
review-status-crash-loop-237-r3
wip/nix-devshell-pre-p3
taste-frosted-real
wip/code-serve-demo-2026-10-30
wip/code-serve-glass-2026-10-30
wip/code-serve-clusters-2026-10-30
wip/code-merge-release94-dev-2026-10-30
wip/rproxy-delta-scratch-2026-10-30
wip/stale-note-257-staged-2026-10-30
wip/d241-size-staged-2026-10-30
review-broad-suite-207
wip/mesh-pins-258-pre-split
wip/mesh-relay-220-pre-rebase
mesh-measure-220
plan-211-update
review-edge-autoupdate-r3
perf24-reconnect
review-stale-note-r2
release-prod-live
d241-shell-marker
review-mesh-pins-server-258
review-revocation-r3
review-setup-token-r2
review-sync-journal-r3
wip/review-mesh-pins-258-prune-review-1995fee46
frosted-scroll-256
sync-journal-r3
revocation-per-account-wip
review-tokio-asyncfd-0386
review-sync-p3s
wip/stash-8by64-stash0-2026-10-30
mesh-drag-r2
setup-token-data-wip
release-9-4
auth-busy-1013
mesh-view-overlap
sync-p3s
review-auth-busy-wake
review-readd-r8-wip
mesh-drag-bounds
audit-auth-93
app-windows-stream-start
review-authority-cap-r3
app-windows-r7-measure
readd-minimal-r6
readd-minimal-r5
app-windows-r7-soak
readd-minimal-r4
app-windows-r7
app-windows-r6
readd-minimal-r3
readd-minimal-r2
readd-minimal
merge-release8-dev
review-busy-page-liveness-r2
review-busy-page-liveness
mesh-view-trim
review-authority-read-retry-r2
review-remove-uninstall-r2
release-retry-reattach
release-retry
review-remove-uninstall
review-authority-read-retry
app-windows-ship
mesh-autoswitch-p2
mesh-ssh
mesh-relay
mesh-macos-node
screen-and-control
session-labels
review-real-logins-r5
review-real-logins-r4
review-real-logins-r3
shim-memory-r2
review-real-logins-r2
shim-memory
shadow-addhost-instant
shadow-real-logins
shadow-same-session-flood
shadow-prod-perf-fix
hosts-from-server
app-windows-real
app-windows-transport
release-prod-6w
mesh-policy-verify
quiet-close-toast
dev-merge-r4
review-mesh-agent-transport
review-app-windows-transport
settings-roundtrip
review-release-pipeline-r4
mesh-agent-transport
ended-strip-fix
review-audit-ca-update-d244
settings-rows
phone-width
signout-clear
fix-restart-tests
dev-merge-rp
signer-hardening
review-audit-ca-update
terminal-fidelity
review-audit-ca-update-wip
mesh-integrate
review-mesh-agent-transport-515d3712
review-mesh-dataplane-feec1988
mesh-dataplane
signout
research-canvas-node
wip/review-renderer-cost
wip/review-terminal-fidelity
padlock-glyph
review-release-pipeline-2e95b689
phone-polish
sidebar-polish
inbox-generic
review-mesh-dataplane-378d4055
prod-batch
ui-inbox
ui-ca-unlock
ui-machine-sections
review-mesh-dataplane-a5f744fb
tls-session-prod
session-bounds-prod
fix-dead-session
session-bounds
tls-session
palette-polish
shortcuts-d222
research-appmeta
phone-gutter
ui-centre-button
wip/ui-centre-button
wip/tls-session
review-addhost-breakit
ui-platform
renderer-cost
remote-agents
mesh-api
ssh-bootstrap
prod-hotfix2
prod-fixes
review-remote-agents
review-mesh-dataplane
session-life
research-browser
review-mesh-api
research-capture
review-mesh-api-round3
review-mesh-api-round1
gate-stability
review-gate-stability
review-relay-direct
review-mesh-api-findings
proto-sessions
font-picker
proto-addhost
proto-windows
mesh-core
latency-gate
research-bsd
research-discovery
keytray-mods
mock-core
ui-tweaks
fix-motion-sel
fix-motion
integrate2
fix-robust
t-web
t-server
vt-ghostty
fix-snap-echo
t-web-auth
review-vt2
t-agent
lane-protocol
lane-shim
lane-auth
s1-base
s1-contracts
s1-workspace
No results found.
No labels
launch-qa
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
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
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements D373: the full tracer browser suite runs nightly on dev, built like the nightly fuzz (D361).
Plan (estimate 2-3 h):
scripts/nightly-browser-suite.py: runs a production build, then the tracer Playwright suite (theverify_latencybrowser phase) with the JSON reporter, and files results.nightly browser suite reporterinscripts/verify.d/rust.sh.verify quick.Branch:
nightly-browser-suite, base411b49aa8.Started. Branch
nightly-browser-suite, base411b49aa8(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.
Finding (J304c): the compare-and-set J304 left uncommitted was too coarse, so I changed it.
Evidence: with
look.setrequired to carry the exact current document revision,real/appearance-sync.spec.tson dev-02 went 3 failed / 5 passed (log~/j304c-ev/run2.logon dev-02): "theme and font changes reach the same account live..." (camera jumped), and both migration specs (lookundefined). Cause: the client fixesbase_revisionwhen 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 alook.setonly if another non-nooplook.setlanded 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 quickdoc-comment rows.The earlier
themes.spec.ts+third-party-notices.spec.tsrun on dev-02 (including the 393×852 captures) passed except that stale section list; rerun pending. Proof run is queued:~/j304c-ev/run3.logon dev-02.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.
Finish. Branch
nightly-browser-suite, headc751e99309(git ls-remoteconfirms), base411b49aa8. 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).BROWSER_REPORT_DONE failing=21 ... request_errors=0, noopened issuelines, zero new issues or comments (report2.log).Gate:
verify quickon 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 rownightly browser suite reporterinverify fast's final mode (not run:fastwas not asked for).Install: needs root on dev-02, which
claudedoes not have (sudo -n fails). Request for the Maintainer (cua, sudo), after this branch is reviewed and its head is on origin: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-serviceand-report, writesinfraternal-browser-suite.service(oneshot, TimeoutStartSec=4h, Nice=10) and.timer(05:00 IST), runssystemd-analyze verifyand 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 inservice_unit()andtimer_unit()inscripts/install-nightly-browser-suite.Notes / decisions for the orchestrator:
TimeoutStartSec=6hlike the fuzz; I did not change it because the manual run included the slot wait only partly. Say if you want it raised./usr/bin/python3(the nix python lacks a CA bundle on the control host); the service helper already does.Update: raised TimeoutStartSec to 6h (note 1 above done). Final head is the SHA below.
7121538115Security review (independent): REQUEST CHANGES
Reviewed SHA:
7121538115ecb22c8933dfa8c4c9615b1ac8d7b9(branchnightly-browser-suite, 4 commits on top of origin/dev411b49aa8).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-fuzzat 0700, soclaudecannot even stat the token. dev-02 has nocrontaborat, so unreviewed code cannot persist past the unit's cgroup. Environments are reset withsetpriv --reset-envandenv. The token never appears in argv or logs, andHTTPErrortext 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-64checks only thatlatestresolves to a directory insideruns. The files inside that directory are owned byinfraternal-browser, and the reporter opens them asinfraternal-fuzzand follows symlinks.fs.protected_symlinksdoes not help, becauseruns/is not a sticky world-writable directory. The attack:run(unreviewed dev code) writessuite.logas a symlink to/var/lib/infraternal-fuzz/forgejo-token.results.jsonwith a failing spec whosefileis a single hex character, e.g."a".report()→log_tail(log_text, "a")returns every line that containsa, which includes the token line, andissue_body()posts it publicly.PoC (
poc.py/poc.outattached):TOKEN_LEAKED_IN_ISSUE_BODY True.The same path also lets
results.json/meta.json/suite.logbe a symlink to/dev/zeroor a FIFO.read_text()then hangs or runs out of memory in the token account, andlatestis 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.jsonandsuite.logasinfraternal-browser(for examplesetpriv … catwithO_NOFOLLOWsemantics, orhead -c <cap>), or open each one withO_NOFOLLOW,fstatit 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 asuite.logsymlinked 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-134copies the "reviewed" helper and reporter fromHOME/checkout. That is the checkout owned byinfraternal-browser, which has run unreviewed dev code every night. The/tmp/ibsclone 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/checkoutof 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 asinfraternal-fuzzwith the token.shutil.copyfilefollows symlinks. Ifscripts/nightly-browser-suite.pyis 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 itsgit rev-parse HEADequals--refand 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_MARKERis(\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:
^[A-Za-z0-9._/-]{1,120}\.spec\.ts$, no..). Map anything else to one "unparseable spec" bucket.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
```fences unescaped. A line containing```closes the fence, and the attacker's markdown follows:@mentionsthat 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.tail(30 lines of any length), the spec name in the title and body, andmeta['head']/evidence/logare not.Fix:
@(e.g. replace it with@).headto match^[0-9a-f]{40}$. Build the evidence and log paths in the reporter from the validated run directory name instead of trustingmeta.json.P2: install instructions clone as root into a predictable
/tmppathThe request on this issue runs
sudo git -C /tmp clone … /tmp/ibs.git cloneinto an existing empty directory succeeds. Any local account (for exampleclaude, which runs unreviewed AI-job code) can pre-create/tmp/ibs, own it, and swapscripts/before root runspython3 /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
PrivateTmp=yes(isolates the suite's/tmpfrom the claude jobs and the fuzz),ProtectSystem=strictwith explicitReadWritePaths=/var/lib/infraternal-browser /tmp/infraternal-build-slots /nix/var(check that nix still works), andProtectKernelTunables=yes. SkipNoNewPrivileges/ProtectHomeuntil you've checked them againstrunuserand 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 onruns/, 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.issue.user.login== the token's account.~/infraternal-ops/slots= 3). BRIEF-COMMON still says 1. The orchestrator should fix BRIEF-COMMON.What held up (questions 3 and 4)
Persistent=true, does not collide with the 02:00 fuzz. The service takes a real slot throughrunuser claude build-slotand holds it for the whole run, the same pattern as the installed fuzz helper. 6 h covers the measured ~3 h 22 min.useraddonly runs if the account is missing,cloneonly if the checkout is missing, and the units are rewritten each time.runs/latestonly 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.Gates I ran (dev-02, detached worktree at the reviewed SHA)
/usr/bin/python3 -I scripts/test-nightly-browser-suite.py:build-slot nix develop -c scripts/verify quick: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/ibsone) is: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.Round 2: all of J427's findings fixed (review comment 88658)
Branch
nightly-browser-suite, head9920e10817(git ls-remoteconfirms; 4 new commits on7121538). 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 quickon dev-02 through build-slot: all 8 rows PASS (log~/j406-ev/quick2.log).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.latestis a bare run name checked against a regex. Tests: symlinked log to a secret, FIFO,/dev/zerolink, oversize, directory, wrong owner, symlinked run folder and pointer, bad names, and "a refused run never reaches the reporter".mktemp -dclone (root, 0700),--refmust 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.^[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).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-slotsqueue, so the suite could skip the slot; a test asserts it stays out) andNoNewPrivileges(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.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>.Security re-review (J427 follow-up) of
nightly-browser-suite@9920e10817bac008f4c2dacc4fef19c108aeb29aVerdict: 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 asclaudeagainst a detached worktree at the reviewed SHA.Round-1 PoC re-run: no leak
Both the root helper's
stage()and the reporter'sread_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-suitecheckout():/var/lib/infraternal-browserbelongs toinfraternal-browser, which runs unreviewed dev code every night. That code can replacerunswithruns -> /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, becauseis_dir()follows the link;chown infraternal-browser:infraternal-browserwithout-h: coreutils dereferences by default, so the account now owns the target directory;chmod 0700: also follows the link.Owning
/usr/local/libexecor/etc/systemd/systemlets 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):Fix: never touch the browser home by path as root. Do the step as the account (
as_user('mkdir', '-p', '-m', '0700', runs)), or openHOMEand thenrunswithO_NOFOLLOW|O_DIRECTORYand usefchown/fchmodon the descriptor. Ifrunsis anything other than a real directory owned by the account, refuse or remove it. Red test: plantruns -> victim_dirand 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'sspec.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:This is round-1's Markdown injection again through another field. The planted state marker is not believed today, because
last_statereads 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 must appear only inside the fence.P3s
$accepts a trailing newline.SPEC_NAME.match('d.spec.ts\n')is True, sod.spec.tsandd.spec.ts\nhash to different markers: two issues for one spec (PoC 6).RUN_NAME's\dalso matches Unicode digits (٢٠٢٦…is accepted) in the helper and in the reporter. It is harmless today, but tighten all three regexes: usefullmatchor\Z, andre.ASCIIor[0-9].read_ownedhas nost_nlinkcheck (PoC 2: nlink=2 is staged). On dev-02 it is not exploitable:fs.protected_hardlinks=1and thest_uid == infraternal-browsercheck together mean a hard link can only point at a file the suite already owns. Addst_nlink == 1as defense in depth; it costs nothing.staging.mkdir(); os.chown(staging, …)and the lateropen(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 ifinfraternal-fuzzis compromised, and that account already holds the token, so this is P3. Fix: open the new folder withO_NOFOLLOW|O_DIRECTORY, thenfchownit and create the files withdir_fd=.Checked and sound
fstaton the descriptor that is then read, the read loop is capped, andO_NONBLOCKdefeats FIFOs.latest_nameandstageopenrunsseparately. 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.reviewed_treerequires 40 hex characters, clones over HTTPS into a 0700 rootmkdtempfolder, checks out the commit, verifies withrev-parse HEAD == ref, refuses links (lstat), and writes.newbeside the destination then callsos.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.python3 -Ifrom a root-owned path, reads only the staging copies, and the token travels only in a header.PrivateTmp / NoNewPrivileges
execve(setuid/setgid binaries, file capabilities). When root usesrunuserorsetprivto drop to another uid, that is a plainsetuid()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: addNoNewPrivileges=yes, then prove it with one manual run (systemctl start). If anything breaks,setpriv --no-new-privson theinfraternal-browsercommand alone gives the same protection./tmp/infraternal-build-slots(~claude/infraternal-ops/build-slotline 13). UsePrivateTmp=yesplusBindPaths=/tmp/infraternal-build-slots(pre-create it with atmpfiles.dlined /tmp/infraternal-build-slots 1777 claude claude -, or useBindPaths=-…so a missing folder does not fail the unit). Then the suite's scratch files stop landing in the shared/tmpbeside other jobs, and the queue stays shared. Also drop/tmpfromReadWritePaths. 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.pyon dev-02:Ran 35 tests in 10.168s OKscripts/verify quick: not run. build-slot on dev-02 waited about 20 min withless 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.Round 3: P1, P2 and every P3 of comment 88733 fixed
Branch
nightly-browser-suite, heade08a949340(git ls-remoteconfirms). Nothing installed.python3 -I scripts/test-nightly-browser-suite.py: 43 tests OK (was 35);scripts/test-nightly-fuzz.py: 22 OK;verify quickon dev-02 through build-slot after a short queue wait: all 8 rows PASS (log~/j406-ev/quick3.log).Red first, then fixed:
checkout()no longer touchesrunsas root: the suite account makes it (mkdir -p -m 0700throughsetpriv), and the browser home is openedO_NOFOLLOW|O_DIRECTORYand changed withfchown/fchmod. Other root steps in the installer never name a path inside that home (git steps run as the account). Tests:runs -> victim dirleaves the victim's mode and owner unchanged and nochown/chmodis run; a symlinked home is refused.@kayg, an image and a fake state marker appears only inside the fence.SPEC_NAME,RUN_NAMEin reporter and helper,HEAD) usefullmatchwith\A..\Zandre.ASCII(tests: trailing newline,é, Arabic-Indic digits);st_nlink == 1in 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 hasNoNewPrivileges=yes,PrivateTmp=yes,BindPaths=-/tmp/infraternal-build-slotsand no writable/tmp, and the installer writes a tmpfiles.d lined /tmp/infraternal-build-slots 1777 claude claude -and runssystemd-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.Unchanged otherwise; the installer now also writes
/etc/tmpfiles.d/infraternal-browser-suite.conf.Security review, round 3 (J432 follow-up) of
nightly-browser-suite@e08a9493403fdb3fec6b8c868b265394b8bed0a3Verdict: 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
1777in 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 asclaudeagainst a detached worktree at the reviewed SHA (~/worktrees/j443-r3). Root was used only forsudo -u nobodyin a scratch replica and for one read-onlysystemd-run … build-slot --status. Nothing was installed.1. Earlier proofs of concept: both fail now
poc.py, updated only for the newrun_nameargument and a validhead):OSError: [Errno 40] Too many levels of symbolic links: '…/run/suite.log'. The symlinked token is never read.poc2.py): The old PoC still prints5 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.poc_installer3.py, the realcheckout()/secure_home()withas_userrunning as the same user):2. Audit of root operations on paths the suite account can write
secure_homeopen(HOME, O_NOFOLLOW|O_DIRECTORY)+fchown/fchmod/var/libis root's, so the account cannot swapHOMEitself.checkout(HOME/checkout/.git).exists()as rootcheckoutmkdir -previewed_tree/install_filemkdtempand/usr/local/libexecrev-parseverified,lstatregular,.new+os.replace.latest_name/stageopen(runs, O_NOFOLLOW)→openat(name, O_NOFOLLOW)→openat(file, O_NOFOLLOW|O_NONBLOCK)→fstat(uid,S_ISREG,st_nlink==1, size)openatis relative to a descriptor, names are validated (no/), and the final checks run on the open descriptor. Devices cannot be created withoutCAP_MKNOD. Leases and FIFOs fail fast underO_NONBLOCK.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, filesO_CREAT|O_EXCL|O_NOFOLLOWviadir_fdOSErrorand exit 1, never a write elsewhere.as_user(…, cwd=checkout)chdir()through/var/lib/infraternal-browser/checkout, which follows a linkI found no name-based
open,chown,chmod,rename,unlinkormkdirthat 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-slotfor every jobtmpfiles_config()writesd /tmp/infraternal-build-slots 1777 claude claude -. tmpfilesdadjusts the mode of an existing directory. On dev-02 that folder isdrwxrwxr-x claudetoday, and the install turns it intodrwxrwxrwt(verified unprivileged with systemd 255:before: 775 claude→after: 1777 claude). From then on, any uid can create entries there, andfs.protected_regular=2makesclaude's ownO_CREATopens of those entries fail. Replica (build-slotcopied with onlydir=changed, a foreign uid simulated withsudo -u nobody):Today
queue/andslot.1..3already exist and belong toclaude, 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 (includinginfraternal-browserthrough 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 theclaudeholder.Fix:
d /tmp/infraternal-build-slots 0775 claude claude -, which matches whatbuild-slotcreates. Optionally addd /tmp/infraternal-build-slots/queue 0775 claude claude -. Red test: the generated tmpfiles line has mode0775, not world-writable. Before this lands, the Maintainer should not run thee08a949installer. If it was already run,chmod 0775 /tmp/infraternal-build-slots.P2-2: Forgejo ignores
limit/pageon issue comments, so one issue with 50+ comments stops the whole night's reportinglast_state()pagesrepos/…/issues/{n}/commentsthroughpages(). Checked against git.kayg.org (read-only, #443):Every page returns the full list. Once a suite issue has ≥ 50 comments,
pages()fetches the same list 40 times and raisesRuntimeError.report()only catchesOSError, 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 bybefore/since. Red test: a fake API that ignorespageand returns 60 comments must not raise.P3
checkout()runsgit fetch/git checkoutasinfraternal-browserin the repository that account controls. Apost-checkouthook orcore.fsmonitorplanted by unreviewed dev code then runs withoutNoNewPrivileges/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=0on dev-02, so it cannot inject keystrokes. Suggest-c core.hooksPath=/dev/null -c core.fsmonitor=falseandstdin/stdout/stderr=DEVNULLinas_user, or skip the fetch and checkout when the checkout already exists (the service moves it to dev every night anyway).chdirs as root through a suite-owned link.subprocess.run(…, cwd=checkout)doeschdir()as root beforesetpriv. Ifcheckoutis 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. Suggestcwd=Noneandgit -C/nix develop <dir>with an explicit path, or opening the checkout withO_NOFOLLOWand usingfchdirinpreexec_fn.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.continuepath 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).infraternal-browseraccount may still be running while root stages the files and the reporter runs. They share the unit's private/tmpwith the reporter. Staging is race-safe and the reporter uses no/tmp, so this is defence in depth only. Asetpriv … kill -KILL -1as the suite account after the run would close it.Checked and sound
st_nlink:== 1is 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).fullmatchwith ASCII-only patterns:RUN_NAME,HEADandSPEC_NAMEall use\A…\Zwithre.ASCIIand[0-9].latestdrops exactly one trailing\n, then runsfullmatch.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, andSPEC_NAME-validated spec names (no@,<,[or space).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 --statusworks and prints exactly the host's 3 busy slots and 8 queued tickets (same PIDs and ages), withunit_exit=0(sandbox.out)./procis shared andflockacts 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 isclaude, andfs.protected_symlinks=1stops 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.pyon dev-02 ate08a949:(
tests.out.) Per the reviewer rule, I did not runverify fast/quick. The author'sverify quickresult (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:
After the first manual start, check:
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.journalctl -u infraternal-browser-suite.service:nix develop,bun install,playwright install chromiumand the Chromium sandbox all work underNoNewPrivileges+ProtectSystem=strict. There is norefusing the run folder, and the log ends withBROWSER_REPORT_DONE … request_errors=0.sudo stat -c '%h %a %U' /var/lib/infraternal-fuzz/forgejo-tokenshows1 600 infraternal-fuzz. If the link count is not 1, the reporter refuses the token.sudo stat -c '%a %U' /var/lib/infraternal-browser /var/lib/infraternal-fuzz/browser-stagingshows700for each, with its own account as owner. The staged files are600 infraternal-fuzz.pgrep -u infraternal-browseris empty.Reviewed SHA:
e08a9493403fdb3fec6b8c868b265394b8bed0a3.Round 4: both P2 and all five P3 of comment 88761 fixed
Branch
nightly-browser-suite, headc15cbb5142(git ls-remoteconfirms). Nothing installed.python3 -I scripts/test-nightly-browser-suite.py: 48 tests OK (was 43);scripts/test-nightly-fuzz.py: OK;verify quickon 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.tmpfiles_config()is nowd /tmp/infraternal-build-slots 0775 claude claude -(matches the host's current 775 claude:claude). Test: exact line, no777.-c core.hooksPath=/dev/null -c core.fsmonitor=false,GIT_CONFIG_NOSYSTEM=1, and the root clone is--no-checkoutinto 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 aftersetprivdropped privileges and root stays in/; the nightly fetch also has hooks and fsmonitor off.BindPathshas 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 getkill -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
e08a949installer: it would set the queue folder to 1777 (if already run:chmod 0775 /tmp/infraternal-build-slots).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).Fix-check of round-3 findings (comment 88761): APPROVE @
c15cbb5142Delta checked:
e08a9493..c15cbb51(one commit, 4 files). Detached worktree atc15cbb5: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
d /tmp/infraternal-build-slots 0775 claude claude -1777→ FAILED (failures=1)issue_comments(): oneapi()call,setdefaultby idlast_stateback topages()→ FAILED (errors=1)GIT_SAFE+GIT_CONFIG_NOSYSTEM=1+--no-checkoutGIT_SAFEdropped from the clone → FAILED.gitis missing;as_userstdin/stdout → DEVNULLenv -C <dir>after setpriv, rootcwd='/'cwd=cwd→ FAILED--restored → FAILEDnote_pooled(): dedupe by name against the body and the account's own commentspass→ FAILEDkill -s KILL -- -1as the suite account before stagingTwo non-blocking test gaps. The code is correct in both places.
test_hooks_are_off_for_the_suite_checkoutis a substring test on the whole file. I removed the flags fromfetchonly and it stayed OK, becausecheckoutstill carries them. Worth tightening to check both git commands.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 -- -1runs throughsetpriv --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,claudeandinfraternal-fuzzjobs are out of reach, and so is theclaudebuild-slot holder.ensure_usercreates it withuseradd --system … --shell /usr/sbin/nologinand 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 -Cruns after privileges drop, and root stays in/.note_pooledruns inside the unprivileged reporter (infraternal-fuzz). The installer adds no rootopen,chown,chmodormkdirunderHOME. 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
e08a949installer, because it sets the queue folder to 1777. If it was already run, first runsudo chmod 0775 /tmp/infraternal-build-slots.Post-start checks:
Follow-up: the 6 h budget now counts from the slot grant, not the queue wait
Branch
nightly-suite-queue-timeout(from origin/dev9d225cb0e), head3b6f04d6aa(git ls-remoteconfirms).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 quickon dev-02: all 8 rows PASS (~/j406-ev/quick5.log).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.nice -n 10 timeout --kill-after=60 6h nix develop …, started only after the slot is held. A timeout (exit 124) prints oneSUITE TIMEOUTline, still kills leftover suite processes, files no report, and the helper exits 124. Neither case opens spec issues.TimeoutStartSec=24hstays as a backstop only (above 12 h + 6 h + report). I rejectedinfinityso a hung helper cannot run forever.docs/PROCESS.mddescribes 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:
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.
J406 progress (follow-up 2 and the 403/kill reports) — branch
nightly-suite-partial-report, head139ced7c1(pushed,git ls-remoteconfirms)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 credentialscripts/fjuses), and the issues' author iskayg(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 noread:userscope either (GET /userreturns "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/usercall 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.pyOK):/usercall. 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/userfiles a whole night, and no request to/useris made. Chosen over learning the login from the first own post, because the open issues must be matched before anything is posted.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 (nostaged.json), so use--run-name 20261109T134307Z-c442500; a test covers exactly that shape (meta.json, results.json, suite.log; headc442500...).kill -s KILL -- -1prints its usage (it reads-1as a signal number), so nothing was killed. The helper now runspython3 -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.apps/tracer/nightly-reporter.tsstreams one line per finished test toresults.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.jsonlon 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_BUDGETis now 9 h;TimeoutStartSecstays 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.jsonandsuite.logof20261109T134307Z-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 quickis queued on dev-02 (position 8 behind other jobs); the reinstall commands follow when it passes.Ready for review: branch
nightly-suite-partial-report, headee54708914(git ls-remoteconfirms; 4 commits on origin/dev547a48885)python3 -I scripts/test-nightly-browser-suite.py: 73 tests OK (red first);scripts/test-nightly-fuzz.py: OK;verify quickon dev-02 through build-slot: all rows PASS, includingdoc comments on diffandtracer 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
/usercall,--loginfrom/etc/infraternal-browser-suite.login,TOKEN LACKS PERMISSION+ exit 77,--report-only, working cleanup kill, partial results vianightly-reporter.ts, 9 h budget). New since then: the installer takes--forgejo-login <account>(required the first time; the Maintainer's token belongs tokayg).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.txton 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 Nis not safe. All 181 spec files share one test double, and 68 of them call/__mock/resetfirst; a second worker's reset ends the first one's session (#377).playwright.config.tsrefuses--workers > 1on purpose.TRACER_TEST_PORTandTRACER_DISTalready exist, so N independentplaywright test --shard=i/Nruns, each with its own port pair (TRACER_TEST_PORTand the next one up) and its ownTRACER_DIST, would not share a double. Cost and risk: the build output must be per-shard (bun run buildwipesdist),INFRATERNAL_EVIDENCE_DIRper 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.runlaunches 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-loginis required)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:
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.
Timing analysis of the first real run,
20261109T134307Z-c442500(no code changed; J464's review ofee5470891is untouched)Source:
results.jsonandsuite.logcopied from dev-02/home/claude/j406-ev/c442500/. The full numbers are in~/.cache/codex-scratch/j406/evidence/c442500/analysis.txton 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)
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.
?mock=1; whether it resets the shared double is for the author to say).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
--shardsplit 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 Nstays 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 thenightly-suite-shardsbranch: 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 inrun()plus a test, after J464's review lands, since both touchscripts/nightly-browser-suite.py.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.Security fix-check of
nightly-suite-partial-report: APPROVE atee547089148584098cb5361b52daf8c0aee82229I'm an independent reviewer (Claude Opus 5.5). This covers the 4 commits on origin/dev
547a48885, on top of the reviewed and installed5d9683074. I read the whole delta innightly-browser-suite-service,nightly-browser-suite.py,install-nightly-browser-suiteandapps/tracer/nightly-reporter.ts.Gate
python3 -I scripts/test-nightly-browser-suite.pyon a cleangit archiveofee5470891: Ran 73 tests in 18.307s, OK.Each new test fails without its fix (one hunk reverted per scratch copy)
kill -s KILL -- -1GET /usercall put backwritten_byalways true--report-onlyreturns earlylatestcheck removedskipped/interrupted counted as failedclean_nameskipped on streamed file namesTIMEOUT_LABELcheck removed[:MAX_LINE]cut removedAttacks on the delta
--report-onlyat its own files? No.--resultsis fixed to/var/lib/infraternal-fuzz/browser-staging, the token account's 0700 folder. Onlyfresh_staging/stage(descriptor-based: O_NOFOLLOW, owner check, nlink 1, CAPS) ever fill it.--report-onlystages nothing and reads nothing as root. The reporter reads every file, including the newstaged.json(4 KiB cap), withread_regular(O_NOFOLLOW, regular file, a single link, size cap). The run name read fromstaged.jsonstill goes throughRUN_NAME, and the timeout label goes throughTIMEOUT_LABEL(a../xname is refused, as tested). The helper's own--run-nameis checked againstRUN_NAMEbefore anything runs. The newresults.jsonlgoes through the sameread_ownedpath 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.LOGINregex).written_byis 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.clean_name, so a malformed name lands in UNPARSEABLE, which is never reported fixed. Titles and errors land inside thefence_forfence. Errors are cut byclip_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.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)
userfield,post_asaccepts it. Forgejo always returns one, so this is acceptable. A wrong login on a night that posts nothing is never detected.kayg, and AI agent jobs also post askaygthrough 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-loginis required the first time)(If it is merged first, use the merge commit's full SHA instead.) Install only while the unit is idle.
File run
20261109T134307Z-c442500from its staged filesThat staging was left by the older helper, so it has no
staged.jsonand needs--run-name. Run this before the next timer firing, because a new night replacesbrowser-staging:Expected: one
BROWSER_REPORT_DONE …line and exit 0. Exit 77 withTOKEN LACKS PERMISSIONmeans 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.
Started
nightly-suite-shards(from origin/dev06fc52d92). Plan, estimate 3 h plus the manual run (about 2 h of suite):run --shards 2: one sharedresults.jsonlthat 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_DISTand evidence folder; log lines prefixed[s1]/[s2]in onesuite.log.runs/file-times.json, written at the end of each run); with no usable times it falls back to Playwright's--shard=i/2.clusters-benchgets@perfin its title; the nightly passes--grep-invert=@perf. J386'sfix-compute01-browser-baseline(#434, not merged) addsgrepInvert: /@perf/toplaywright.config.tsunlessTRACER_PERF=1; I do not touch that file, so the two do not conflict, and the flag is redundant but harmless once theirs merges.keyboard-sweepstays at both sizes.Progress on
nightly-suite-shards(heada2b674871pushed). Code and tests are done (88 tests OK inscripts/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 quickfollows 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 usedand shard 2 (4733/4734) ran. I stopped that attempt by exact PIDs (the live nightly was not touched). That made me addNIGHTLY_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_PORTdiffers.Maintainer: reporter fix installed on dev-02; tonight's run is filed
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-browsershows nothing left.06fc52d928dab440ba919e80cdc0d1c862068e2awith--forgejo-login kayg, at 04:41 UTC. Installer exit 0./etc/infraternal-browser-suite.loginisroot:root 644, containingkayg. The new helper is in place (9 h suite budget, pythonos.kill(-1)cleanup),TimeoutStartSec=24h. The queue folder is775 claude:claudeand the token has 1 hard link. Next timer run: 2026-11-10 23:37:06 UTC.infraternal-browser-suite-service --report-only --run-name 20261109T234323Z-8c2b8f5printed: Exit 0. #482 is the roll-up: 34 of 165 failing is over the per-spec threshold. The staged files are unchanged.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-onlywithout re-staging it.Request for the Maintainer (via the orchestrator), for the #482 triage:
/var/lib/infraternal-browser/runs/20261109T234323Z-8c2b8f5/is 0700, soclaudecannot readsuite.log,results.jsonlor the evidence. Please copysuite.logandresults.jsonl(and, if small, theevidence/folder) to/home/claude/j406-ev/r8c2b8f5/witha+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 the8c2b8f5logs.Triage of the 34 red spec files (run
20261109T234323Z-8c2b8f5) — J406Verdict: 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.
8c2b8f5run itself (copied by the Maintainer to/home/claude/j406-ev/r8c2b8f5/on dev-02, copy ofsuite.logandresults.jsonon the control host in~/.cache/codex-scratch/j406/evidence/tri/): single worker, 4.24 h, 1,240 passed, 80 failed, 195 skipped. Insuite.logthere are 0ECONNRESET/ECONNREFUSED/socket hang uplines and 0 90 s timeouts, and every server-dependent spec outside these 34 passed. So no server death, no slow server.3c0b20252) of 33 of the 34 files (all exceptclusters-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 at8c2b8f5. Same errors in 31 of 33 files; onlykeytray-modifierswent green andsessions-canvaswent from 3 to 2 red tests (logs~/j406-ev/tri1/,tri2/on dev-02; results copied next to the8c2b8f5ones).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-hostwas not red in the8c2b8f5run; it is not one of the 34.)locator('.ui-toast')hits "strict mode violation: resolved to 2 elements"results.jsonof both runs, same on current devfix-compute01-browser-baseline(heada9a68b702, not merged): it edits exactly these specs. Merge it, then re-rungetByRole('dialog', { name: 'Setup of o2' })not found; the backdrop--mesh-imageis a gradient, notblob:core-screen.spec.tsandsrc/core-screen.tsare in it)a9a68b702("leave room under the consent buttons") and the per-architecture PNGscanvas.spec:871(palette::beforeinset) redclusters-benchtook 4 min and failed in both full runs; the other two fail identically at8c2b8f5and now@perfwithgrepInvertinplaywright.config.ts; my branchnightly-suite-shards(heada2b674871, #443) tagsclusters-bench@perfand passes--grep-invert=@perf. No product worklocalStorage(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 (commit469165a10updated one spec, not this one).terminal-theme(selection background after choosing a theme) looks like the same family but I did not trace itsavedisnull; machine-sections: keytracer.extended-infoisnullafter the click; settings-rows: extra row "Interface font" in the expected list7fbd176cc,22a981116,469165a10are 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-120hztouchesterminal-theme.spec.ts; its owner should say whether that fixes itstops5 vs >= 8); after swipescanvasLock.indexis 0 where 6 is expected; tapping a peeking neighbour does not moveswipe-content-first-d374(D374) touches the swipe behaviour but not these specs: its owner to say whether these specs are stale or the behaviour regressedloaded(mapleLoadedis false); the other 20 fonts pass. Fonts added in D329 are "fetched only when chosen"apps/tracer/fonts/; same at8c2b8f5and now8c2b8f5, and the next error is about script attribution now)perf-hudrecords an injected 200 ms stall and depends on the host's loadperf-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 itAll 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
@perfpart 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.
nightly-suite-shardsready for review — head3c46d3eee2(git ls-remoteconfirms; 5 commits on origin/dev06fc52d92). 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.pyOK;verify quickon 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 sharedresults.jsonlthat both shards append to, tagged by shard; onesuite.logwith[s1]/[s2]prefixes; spec issues name their shard; a crashed or unfinished shard adds one note to the roll-up.clusters-benchis tagged@perfand the nightly passes--grep-invert=@perf.keyboard-sweepis unchanged. J386's branch (fix-compute01-browser-baseline, #434) puts the same exclusion inplaywright.config.ts; I did not touch that file, so the two do not conflict.Manual 2-shard run on dev-02 (head
a2b674871= dev06fc52d92plus this branch;NIGHTLY_BASE_PORT=5731, one slot per shard):8c2b8f5night (34 files): 33 of 34 are identical. The only one missing isclusters-bench(now@perf, excluded). Two files are red only in this run:forget-retired-hostandpending-host-removal, both on dev after8c2b8f5(the first is a strict-mode "Forget" button match). Against c442500's 33 files: 25 in common; the 8 that went green or left aredocs-palette,glass-ghost,sign-out,toast-stack,pending-removal-command(fixed on dev since) and the perf-taggedclusters-bench,clusters-framesandmesh-perf(@perfis 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.~/.cache/codex-scratch/j406/evidence/shards/analysis.txtandresults.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_PORTexists. For the Maintainer when reinstalling: the nightly now holds two build slots, one per shard.Reinstall commands (as root; after review;
--forgejo-loginis only needed once and the login file already exists)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 asstage()(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.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
8c2b8f5night, not x1.97.