Repository navigation
Conversation
The board has never carried colours. readTokens() in hud/scene.js reads nine custom properties off the page and paints from what it read, which is how the light/dark button moves the drawing with it. That was already the whole mechanism a theme needs; what was missing was somewhere for one to stand. So the nine become --board-*, declared once in style.css and each defaulting to the page token it used to name. Nothing about the light/dark path changed: the defaults follow the page into both grounds, because a var() resolves where it is used rather than where it is declared. What is new is that a stylesheet can now redefine them without reaching past the drawing and repainting the panels around it. A theme is a folder under ui/hud/themes/ with theme.json and theme.css in it. themes.go walks the embedded UI at startup and serves what it found at /api/themes, so the picker is populated from the server rather than from a list in the markup — adding a theme is a folder and a rebuild, and nothing else is edited. That is the same shape panels.go already has. Two themes ship. `chapter` overrides nothing and is the board exactly as it was; a test fails if it ever starts to, because a second copy of those nine colours is the copy that goes stale. `warroom` is amber on a dark ground, and exists so the seam is demonstrated rather than asserted. A theme that does not parse, or whose id disagrees with its folder, is skipped with a line at the console and the rest of the store still loads — the choice missing() already makes for set-pieces, for the same reason. theme.json's `ground` is advisory: selecting a theme drawn for dark moves the page there, and the light/dark button still wins afterwards. Measured against the running lab on 2026-09-12: /api/themes serves both, both stylesheets are served, selecting warroom moves --board-accent from #0d7a6f to #e8a33d while --accent is untouched, pressing the theme button afterwards changes the ground and leaves the palette, and selecting chapter again leaves --board-accent and --accent identical. No console errors.
The live half of this server was four SSE endpoints — status, tcpdump, logs,
stats — and each consumer opened the one it wanted and parsed it for itself.
Four places that know how to read the lab, which is four things a widget
somebody else writes has to get right before it can draw anything, and four
files a recording would have to be stitched out of.
Now there is /api/stream/hud and one envelope: {seq, at, type, data}. Ten types,
listed in an opening hello so a consumer can ask rather than assume. Seq and the
timestamp are both there for replay — the order, and how long apart — and every
data: line is complete on its own, so a `.jsonl` of them is the recording with
no framing to reconstruct.
Two things carried that were not on a stream before. Every Result the API
returns is published as `verdict` (action, set, preset) or `flow` (probe), with
the id of what ran travelling beside it, because the set-piece a replay has to
pick is chosen by id and cannot be inferred from a Result's shape. The board
still draws from the POST reply, so the feed is not in the drawing path and
cannot break it; what it adds is that something which did not press the button
can see what the button did.
The two sources that cost a process inside a container are asked for in the URL
rather than turned on by a POST — ?capture=evil-box, ?logs=lab-ubuntu. No
subscription state on the server, and a recording whose URL says what was being
watched.
A note now says which source it came from. On four streams it could only have
come from the stream it arrived on; on one wire, a capture pane headed
`tail -f /var/log/tailscaled.log` is what dropping that field gets you.
On the page, ui/feed.js owns the one EventSource and the four call sites that
used to open their own now share it: on() to listen, want() to ask for an
expensive source — reference counted, so two things wanting the capture is one
capture — and status() for what the connection is doing. A classic script loaded
before app.js, because the readouts read the lab from their first line.
Nothing looks different. Measured against the running lab: 904 events on one
wire with no gaps in seq, the sniff verdict and a probe's flow both landing on
it, each text pane getting its own banner and not the other's, two toggles in
one turn costing one reconnect, and no console errors.
The page's furniture was in index.html. Adding a readout meant editing the markup, the stylesheet and app.js, which is three files and a fork for anybody who wants a different instrument on the same lab. The feed made the data shareable last week; this makes the layout data too. There are four docks now — top, left, right, bottom — and a card in one of them is a widget: a folder under ui/hud/widgets/ with a widget.json saying what it is, a widget.js, and a widget.css scoped to its own card. widgets.go walks the embedded UI at startup and serves the registry at /api/widgets, so adding a widget is a folder and a rebuild. The same shape as the theme store, and panels.go before it. What a widget is handed is the whole seam: a div, the feed, and the options its placement carried. No board object, no page elements, no controller. The board is twenty-five coupled methods over 1300 lines of three.js and publishing it as a plugin API would freeze hud/scene.js the day somebody wrote against it — a widget that wants to know what the lab is doing reads the feed, which is what the feed is for. Where a card goes is the theme's business. theme.json grows a layout, the server prunes it against the registry at startup — unknown widget, or a dock the widget does not fit — and each drop says why at the console, so the page only mounts placements that can be built. warroom docks three; chapter docks none, for the same reason its stylesheet overrides nothing, and a test fails if either starts to. Three widgets to have something real in the docks, all three reading the feed and nothing else. wire is the feed reading itself — open or not, how far the sequence has got, and what the server threw away because a reader could not keep up, because a recording made through a gap is worth knowing about before it is posted. tape is every frame written down, which is the closest thing on the page to what a replay will work from. tally counts verdicts by the rung that decided them, allowed and denied apart: "rung 3 allowed eleven and denied none" is a finding and "rung 3 answered eleven times" is not. The board is not a widget and does not pretend to be. app.js still creates it and drives it; the docks' part is reporting what they cover so the camera frames the diagram in the gap the cards leave. Making it a real widget under the contract above means the board reading the feed instead of being called, which is the next piece of work and not this one — a privileged contract that handed one widget the board object would be the plugin API this change exists to avoid. One trap, found by driving it. --pad-* is measured against the canvas and consumed against the window, and those differ by the height of the rung ladder, which sits below the viewport rather than over it. A card measured one way and positioned the other is short by exactly one ladder, which put the verdict 40px inside the bottom card. So the docks are measured twice, against both rectangles, and --rail-* is split out from --pad-*: the docks are positioned from the furniture alone, because a dock positioned from a number that includes its own size is a layout that never settles. Measured against the running lab on 2026-09-12, Chromium 1600x1000. chapter: no layer in the DOM, no cards, --rail-* and --pad-* equal. warroom: three cards in three docks, three stylesheets loaded, --pad-r 616 against --rail-r 346. A probe from lab-ubuntu reached rung 5 and the tally read 1/0; the attacker to :22 was denied at rung 4 and the tally read 0/1, which is a denial and not a tick. Folding the readouts rail moved the tape card 984..1244 -> 1276..1536 and the board re-framed. At 820px the cards stack at the foot of the page with no horizontal scroll. Verdict, view controls and the board's gauges clear every card: no overlapping rectangles. The only console error on the page is the favicon 404 it already had. make -C lab check passes: ten new Go tests, and the JavaScript parse check now finds the widget folders rather than carrying a list of them, since a list here would have been the third place to edit and the one nobody remembers.
Branch 4 of the HUD sequence, and the one the other three were for. A recording is a `.jsonl` of /api/stream/hud — one JSON object per line — and `ui/replay.html` reads one and draws it on the same board, with the same set-pieces and the same cards a theme docked, asking no server anything. Two ways to make one. `make record` curls the wire and strips the SSE framing; the **record** button in the live page's bar writes down the frames already arriving, so a recording is exactly what that page saw — including the capture somebody had switched on. It opens no connection and starts no process in a container, which is why it sits above the busy guard: the minute worth recording is the minute every other button is disabled. The opening `hello` grew a `page`: the /api/meta payload, the theme store and the widget registry. That is what makes a file complete — the player has no server to ask, so a recording carrying only events could not say which machines to cut doorways for or where the cards go. `metaPayload()` is now one function rather than two literals, because the copy that drifts is the one in the recording and nothing would say so. The player is a viewer and deliberately not a control surface: no switches, no attacks, because a recording has nothing to press them against. What is there is a transport, and a list of the moments — every Result the lab returned — to jump to. Three things that are wrong invisibly rather than loudly, so they are tested: - A scrub is not a fast replay. The snapshots are sent only when they changed, so seeking re-applies the ones that were standing and skips the packets and verdicts between — replaying at 60× would fire every set-piece it passed. - The hello is a header, not a moment. On a recording of a session already in progress it is older than everything else in the file. - The snapshots a recorder seeds from can be ten minutes old. They are stamped when they were written, with the measurement's own clock kept as `wasAt` — driven against the live lab, a seven-second recording claimed forty-two before this. `hud/scene.js` now owns `refs()` and `HUD_IDS`, because two pages create boards and eighteen querySelector calls copied into the second is how one quietly loses a readout. A check asserts every id is in both pages. `wire.js` holds the two lists both feed.js and replay.js read — what the wire carries, and which of those are snapshots rather than moments. A stale second copy of the latter is a player drawing yesterday's board after every scrub. The player needs an HTTP origin and not a lab: `python3 -m http.server` over `lab/control/ui` serves it with Docker not installed. It cannot be a double-click, because `<script type="module">` is blocked on `file:` and the board is a module.
…rd (#83) The HUD overlay insets itself by whatever the furniture is covering on three sides and by a flat 12px on the fourth. Since #79 gave a theme a bottom dock, that fourth side has something under it: on Warroom the verdict panel was drawn 30px behind the tally card and the transcript — the one line on the board that is a command rather than a rendering of a number — was inside it completely. --pad-b could not simply be added to the padding. It is measured against the window, because the page verdict and the view controls have to clear the rung ladder, and the ladder sits below the viewport rather than over it. The overlay is laid out inside the viewport, so reading --pad-b there clears the ladder a second time and lifts the whole bottom stack a ladder higher than the card it only had to clear by a gap. So publish the number the camera already gets — what covers the board, not what is occupied at the foot of the window — as --pad-b-canvas, and read that. The other three sides need no second token: the viewport starts at the top-left corner of the window, so there the two rectangles agree. Measured in Chrome 147 at 1440x760 on 2026-09-13, Warroom, after a probe: before after tally card #hud-verdict 393-514 393-514 597-700 #hud-transcript 670-698 (inside) 557-585 overlay pad-b 12px 125px The default page is untouched: with nothing docked --pad-b-canvas is 0px and the padding stays at 12px, the transcript at 670-698 exactly as before. replay.html gets the same treatment, where the thing below the board is the transport rather than a card. The gauges do not move. The top row is an `auto` row above a `1fr`, so the padding comes out of the gap; at 625px — one pixel above the height where the layout floats and the question stops applying — the overlay already overflows with the old rule and both rules put the bottom stack in the same place. Closes #81
The mapping from a frame on the wire to a drawing on the board existed in
two places. app.js made it from the replies to its own POSTs; replay-app.js
made it from the frames in a file. Same wire, same four types, two copies —
and a copy that drifts is a recording that stops looking like the session it
recorded.
hud/driver.js is now the one copy. It takes { board, feed } and subscribes:
state to setState, a verdict that named an action to its set-piece, flow to
probe, and packet to whatever the shot asked to watch. `feed` is the widget
contract's feed and nothing more, so window.LabFeed satisfies it and so does
the player replay.js builds out of a file — which is what lets the same
three listeners serve a lab and a recording.
What the pages keep is what is genuinely theirs. app.js keeps the POSTs, the
busy guard, the text trace, the tabs and the rails; it reads the lab back
after a change and hands the snapshot to driver.state() rather than to the
board, because a switch has to move the picture without waiting for the poll.
replay-app.js keeps the transport, the moments and the verdict line — the
sentence lands even with no WebGL to draw the shot on.
Two rules the copies agreed on, now owned in one place and pinned by tests:
`set` and `preset` draw no shot. A switch moves the board by changing the
lab and letting the next snapshot redraw it; the board has never rendered
one as an event, and a driver that played a shot per verdict would draw
something no reader ever saw.
A held frame outranks a snapshot. run() calls hold() the moment a shot
starts and setState repaints the standing board underneath it, so a
snapshot arriving under a held frame waits — and it is kept rather than
dropped, so it goes on before the next shot rather than leaving the board
standing on the configuration from before the last one. `stat` is the
heartbeat that releases it when nothing else follows, which is the case
where a switch flipped under a held frame used to half-wipe the frame on
the live page and be dropped outright on the player.
Driven against the real lab on 2026-09-13, control rebuilt from this branch:
a probe drew from the `flow` frame (rung 5, "direct · punched"), scan-public
and scan-tailnet drew their set-pieces from `verdict` frames, a switch
flipped under scan-public's held frame left the frame intact, and the flat
view still draws nothing while the text trace lands. A 39-frame recording of
that session, opened in replay.html, drew the same sniff and probe off the
same driver, twice, with an empty console.
Closes #82
`tailscale up` refuses to run when the preferences already on the machine carry a non-default flag the command does not mention, and those preferences live in the ts-* volume, so they outlive the container. One `tailscale set --ssh` from the Tailnet policy panel was therefore enough to make every later join in the lab fail — at boot, on a configuration, on a reset, on a key rotation — with `make reset` the only way back, and no sign of why. Every `up` now carries --reset, which makes the command the whole statement of intent rather than a delta somebody can refuse. --reset also clears the one setting the lab means to keep, so setTailscaleSSH becomes the single place that owns Tailscale SSH: it sets it on every machine that is up and leaves the same fact in /lab/state/tailscale-ssh, which the entrypoint names explicitly on its own join. A machine restarted on its own now comes back the way the switch says it is. Measured on this host against tailscale 1.102.4 and headscale 0.29.3 on 2026-09-13: with RunSSH true and all three machines Stopped, applying `hardened` brings all three back to Running with RunSSH true; recreating all three containers has them join first time, no retries; all eleven audit checks pass afterwards.
6 of 13 tasks
aenawi
added a commit
that referenced
this pull request
Sep 13, 2026
Back-merge main after the #86 release, so develop contains it
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target
develop: the normal case for a fix, a chapter, alab change, a typo
develop→main, shipping to the published guidemain, because published advice iscurrently locking readers out or leaving them exposed
What this changes
Seven commits since #76. Six of them are one sequence — the board stops being a
fixed picture drawn by one page and becomes an instrument that can be themed,
fed, docked, recorded and played back with no lab behind it. The seventh is the
bug that sequence kept tripping over: once Tailscale SSH had been switched on,
no machine in the lab could rejoin the tailnet.
--board-*, and a theme is a folder the server finds at startup. Two themes ship:chapteroverrides nothing,warroomis amber on dark./api/stream/hud, in one envelope —{seq, at, type, data}. EveryResultthe API returns is published on it.widget.json,widget.js,widget.css. Where they go is the theme's business..jsonlof that wire, andui/replay.htmlplays one back on the same board with the same set-pieces, asking no server anything.hud/driver.jsis now the one copy, and the board reads the feed. Closes #82.tailscale upcarries--reset, so a preference left in ats-*volume can no longer refuse every later join. Closes #74.No chapter, no page under
assets/, no stylesheet a reader loads and nonavigation entry is touched. Nothing on the published guide's pages changes.
What moves is the lab:
lab/control(37 files),lab/checks,lab/Makefile,lab/images/node/entrypoint.sh,lab/README.mdandlab/design/five-gates.html.Kind
What shipped, in detail
#77 — give the board a palette of its own.
readTokens()inhud/scene.jsalready painted from nine custom properties read off the page, which is how the
light/dark button moves the drawing with it. What was missing was somewhere for
a theme to stand. The nine become
--board-*, declared once instyle.cssandeach defaulting to the page token it used to name, so the light/dark path is
unchanged — a
var()resolves where it is used, not where it is declared. Atheme is a folder under
ui/hud/themes/withtheme.jsonandtheme.css;themes.gowalks the embedded UI at startup and serves what it found at/api/themes, the same shapepanels.goalready had. A theme that does notparse, or whose id disagrees with its folder, is skipped with a line at the
console and the rest of the store still loads. A test fails if
chaptereverstarts overriding anything, because a second copy of those nine colours is the
copy that goes stale.
#78 — put the whole lab on one wire. The live half was four SSE endpoints —
status, tcpdump, logs, stats — and each consumer opened the one it wanted and
parsed it for itself. Now there is
/api/stream/hudand one envelope, with tentypes listed in an opening
helloso a consumer can ask rather than assume.seqand the timestamp are both there for replay, and everydata:line iscomplete on its own, so a
.jsonlof them is a recording with no framing toreconstruct. Two things are carried that were not on a stream before: every
Resultthe API returns is published asverdict(action, set, preset) orflow(probe), with the id of what ran beside it — the set-piece a replay hasto pick is chosen by id and cannot be inferred from a
Result's shape. Theboard still draws from the POST reply, so the feed is not in the drawing path
and cannot break it. The two sources that cost a process inside a container are
asked for in the URL (
?capture=evil-box,?logs=lab-ubuntu) rather thanturned on by a POST, so there is no subscription state on the server and a
recording's URL says what was being watched. On the page,
ui/feed.jsowns theone
EventSource;want()is reference counted, so two things wanting thecapture is one capture.
#79 — dock cards around the board. The page's furniture was in
index.html, so adding a readout meant editing markup, stylesheet andapp.js— three files and a fork for anybody who wants a different instrument on the
same lab. There are four docks now, and a card in one is a widget: a folder
under
ui/hud/widgets/withwidget.json,widget.jsand awidget.cssscoped to its own card.
widgets.gowalks the embedded UI at startup and servesthe registry at
/api/widgets. What a widget is handed is the whole seam — adiv, the feed, and the options its placement carried. No board object: the board
is twenty-five coupled methods over 1300 lines of three.js, and publishing it as
a plugin API would freeze
hud/scene.jsthe day somebody wrote against it.theme.jsongrows alayout, the server prunes it against the registry atstartup and says why at the console, so the page only mounts placements that can
be built. Three widgets ship, all reading the feed and nothing else:
wire(thefeed reading itself, including what the server threw away because a reader could
not keep up),
tape(every frame written down), andtally(verdicts countedby the rung that decided them, allowed and denied apart — "rung 3 allowed eleven
and denied none" is a finding, "rung 3 answered eleven times" is not).
#80 — write the wire to a file, and play it back with no lab. Two ways to
make a recording:
make recordcurls the wire and strips the SSE framing, andthe record button in the live bar writes down the frames already arriving,
so a recording is exactly what that page saw. The button opens no connection and
starts no process, which is why it sits above the busy guard — the minute worth
recording is the minute every other button is disabled. The opening
hellogrewa
page(the/api/metapayload, the theme store, the widget registry), whichis what makes a file complete: the player has no server to ask, so a recording
carrying only events could not say which machines to cut doorways for or where
the cards go. The player is deliberately a viewer and not a control surface — no
switches, no attacks, because a recording has nothing to press them against —
but it has a transport and a list of every moment to jump to. Three things that
would be wrong invisibly rather than loudly are tested: a scrub is not a fast
replay (seeking re-applies the snapshots that were standing and skips the
packets and verdicts between); the
hellois a header, not a moment; and thesnapshots a recorder seeds from are stamped when they were written, with the
measurement's own clock kept as
wasAt— a seven-second recording claimedforty-two before this.
#83 — measure the fourth side too. The HUD overlay inset itself by whatever
the furniture covered on three sides and by a flat 12px on the fourth. Since #79
gave a theme a bottom dock, that fourth side has something under it: on Warroom
the verdict panel was drawn 30px behind the tally card and the transcript — the
one line on the board that is a command rather than a rendering of a number —
was inside it completely.
--pad-bcould not simply be added, because it ismeasured against the window (the page verdict and view controls have to clear
the rung ladder, which sits below the viewport rather than over it) while the
overlay is laid out inside the viewport. So the number the camera already gets —
what covers the board, not what is occupied at the foot of the window — is
published as
--pad-b-canvasand read there. The other three sides need nosecond token: the viewport starts at the top-left corner of the window, so there
the two rectangles agree.
#84 — make the board read the feed. The mapping from a frame on the wire to
a drawing on the board existed in two places:
app.jsmade it from the repliesto its own POSTs,
replay-app.jsfrom the frames in a file. Same wire, same fourtypes, two copies — and a copy that drifts is a recording that stops looking like
the session it recorded.
hud/driver.jsis now the one copy. It takes{ board, feed }and subscribes:statetosetState, averdictthat namedan action to its set-piece,
flowtoprobe,packetto whatever the shotasked to watch.
feedis the widget contract's feed and nothing more, sowindow.LabFeedsatisfies it and so does the playerreplay.jsbuilds out of afile — which is what lets the same three listeners serve a lab and a recording.
Two rules the copies agreed on are now owned in one place and pinned by tests:
setandpresetdraw no shot (a switch moves the board by changing the lab andletting the next snapshot redraw it; a driver that played a shot per verdict
would draw something no reader ever saw), and a held frame outranks a snapshot
(a snapshot arriving under a held frame waits and is kept rather than dropped,
so it goes on before the next shot rather than leaving the board standing on the
configuration from before the last one).
#85 — say the whole command, so a machine can always rejoin.
tailscale uprefuses to run when the preferences already on the machine carry a non-default
flag the command does not mention, and those preferences live in the
ts-*volume, so they outlive the container. One
tailscale set --sshfrom the Tailnetpolicy panel was therefore enough to make every later join fail — at boot, on a
configuration, on a reset, on a key rotation — with
make resetthe only wayback and no sign of why. Every
upnow carries--reset, which makes thecommand the whole statement of intent rather than a delta somebody can refuse.
--resetalso clears the one setting the lab means to keep, sosetTailscaleSSHbecomes the single place that owns Tailscale SSH: it sets it onevery machine that is up and leaves the same fact in
/lab/state/tailscale-ssh,which the entrypoint names explicitly on its own join. A machine restarted on its
own now comes back the way the switch says it is.
If this changes advice or a command
No command a reader of the published guide runs changes, and no chapter is
touched. What changes is the lab's own behaviour and what it reports, which
lab/README.mdandlab/design/five-gates.htmldocument — both moved with it:the README gains
make recordand the new files, and the five-gates API tablenow describes the one wire in place of the three streams it replaced.
What was run for this release PR itself, and what it printed:
Each commit carries its own measurement against the running lab; the ones that
decide whether this is releasable:
touched; the two places that described the streams the lab serves
(
lab/README.md,lab/design/five-gates.html) both moved with Put the whole lab on one wire, so a recording is one file #78No score moved, so the comparison table in
lab/README.mdis unchanged. Theaudit still reads 11/11 on
hardened.Checks
make -C lab checkpassesgofmtis clean (make checkfails on any output at all)SPDX-License-Identifierheader — the licencecheck is the first thing
make checkruns, and it passed over every newtheme, widget, driver and replay file
If this touches the guide's pages
Not applicable — no file under
assets/, no chapter and no navigation entry istouched. The pages this release does add (
ui/replay.html) are the lab'scontrol server's own, served over HTTP from the embedded FS.
file://: no build step, no fetch, no new dependencydata-posterframe chosen deliberatelyassets/nav.jsNo dependency, build step or framework was added anywhere: the theme store, the
widget registry and the player are plain modules the Go binary embeds, and the
JavaScript half of
make checknow finds the widget folders rather thancarrying a list of them.
ui/replay.htmlneeds an HTTP origin rather than alab —
python3 -m http.serveroverlab/control/uiserves it with Docker notinstalled. It cannot be a double-click, because
<script type="module">isblocked on
file:and the board is a module.If this touches the lab or sandbox engine
dangerstill outranksokrecording's seeded snapshots are stamped when they were written, with the
measurement's own clock kept as
wasAt, and a scrub re-applies standingsnapshots rather than replaying at 60×
lab/README.mdmoved with it — noscore moved
Licence
CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched