HOMELAB-1683: feat(cockpit-web): LD3.3 frontend half — sandbox attach (xterm.js pane, refusal postures, audited honesty) #584

Merged
aaron merged 11 commits from plane/HOMELAB-1683-attach-ui into epic/LD3 2026-10-02 20:01:37 +00:00
Owner

What

LD3.3 frontend half (LD3.3-ui): the sandbox attach surface. 5 commits, lab-director/cockpit/web/** only — no Go, no ci.yaml, no manifests, no flags.

  1. The attach column in the sandbox table (design Part 2 §4.2 Wave View). Every row carries an attach button driven by GET /api/v1/attach/{sandbox}/info, rendering the backend's three postures: enabled, disabled with the reason, or disabled because the cockpit cannot look (no registry read). The column is second, not last — measured: at 1440px the Fleet Overview's middle column is ~520px while the table wants ~640px, so a trailing column put the operator's one action off the right edge on the screen it was built for.
  2. The terminal dialog (design §4.1 screen 4 — "what is this agent doing, and let me type into it"): xterm 6.0.0 + fit-addon 0.11.0, lazily imported, themed from the page's own tokens, portalled to <body> with the LD3.2b viewport guard re-applied.
  3. Honest end-states, never a dead black box. session.svelte.ts probes info before it opens a socket, which is the contract rather than an optimisation: a browser's WebSocket.onerror carries no status and no body by spec, so a client that opened first could not name the reason for the failure it hits most often (flag off → 503). A refusal therefore renders with no socket, no upgrade, no ledger open — and the dialog draws no terminal element at all until a session has actually opened.
  4. Refusals are audited and the UI says so: P-ATT-00N policy line + "refused and recorded in the attach ledger", with the backend's own sentence verbatim (never a client paraphrase).

The one scope judgement to review

The rows carry the affordance, but the cockpit has no sandbox-list read at all — info is a lookup BY ID, and the read-model census names four sources, none of which enumerates sandboxes (the wave table says so in its own words). Rows-only would have shipped an unreachable feature: zero rows, zero ids, and the ticket's own acceptance ("operator attaches from the app into a live sandbox") impossible.

The alternatives were (a) fabricate census rows — the single worst lie this application could print, and the one its own table refuses to print — or (b) make the capability reachable by naming a target. I chose (b): an attach-by-id launcher under the table, labelled with the missing registry read (LD3.4), reusing the same info probe and the same dialog. When the census lands it becomes the escape hatch an operator wants mid-incident anyway. If you'd rather defer it, say so and I'll gate it behind the census — the row path is complete without it.

Dependency

Merges origin/plane/HOMELAB-1683-attach-bridge (PR #579) as its base, because this slice wires against that wire contract and its fixture parity guard reads attach/attach.go, attach/bridge.go and attach/protocol.go from the Go source. #579 must land first (it is mergeable; #579's diff is unchanged here). If #579 changes before it lands, the guard fails loudly rather than drifting.

Test plan

cd lab-director/cockpit/web
npx svelte-check --tsconfig ./tsconfig.json   → 0 errors, 0 warnings
npm run test                                  → 25 files passed (25), 221 tests passed (221)
                                                fixture parity guard: 13 tests collected (221 total)
npm run build                                 → build/index.html written
npx playwright test                           → 62 passed (1.3m)

Counts before → after (asserted, not inferred — measured by stashing this branch's changes and re-running):

  • vitest: 145 → 221 tests, 20 → 25 files
  • Playwright: 48 → 62 specs

New coverage, by layer:

  • protocol/session (46 unit tests, hand-written socket) — handshake, resize (including an INVALID resize being withheld rather than sent, because the backend closes the session over it), binary-vs-text split, close states, refusal rendering. A mocked socket is the only way to reach most of these: a refused handshake reports no status and no body by spec, which is exactly the path the flag-off server takes on every attempt.
  • components (20 unit tests) — the three postures per row, the no-TTY tooltip, the unreachable-vs-refused distinction, the status strip's recorded/not-recorded honesty.
  • fixture↔wire parity (13 tests, reads the Go) — pins the Info json tag set, the Term struct's absence of tags (the PascalCase trap), the RefusalKind vocabulary and status map, the no-TTY 200 override, registryWiring, DefaultTerm, the close-reason vocabulary, and the two refusal sentences character for character.
  • e2e (14 specs) — the live flag-off 503 end to end (including reading the response body the page received for disabled/audit_recorded), the three postures from fulfilled payloads, the dialog landing on the honest refusal with no terminal in the DOM, the portalled-to-<body> structural check, and the viewport guard at 1440 + 390.

Measured at the tip: at 390px the open dialog sits 675..836 inside an 844px viewport with its backdrop at 0..844, and documentElement.scrollWidth is 390 against a 390px clientWidth (no sideways scroll). At 1440px: dialog 369..531 inside 900, backdrop 0..900, scrollWidth 1440 == clientWidth 1440.

Screenshots

/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-screenshots/

file what
01-sandbox-row-attach-cell-no-id-1440.png the registry-less row: disabled attach button + "no id" chip, with the census named
02-attach-button-disabled-live-1440.png live flag-off 503: "attach disabled", P-ATT-001, the server's own reason
03-attach-button-no-tty-1440.png the no-TTY class posture (fulfilled): "no tty" + the reason, nothing to open
04-attach-button-attachable-1440.png the attachable posture (fulfilled): "open terminal" enabled
05-attach-dialog-refused-1440.png the dialog at 1440, landed on the refused end-state — no terminal drawn
06-attach-dialog-refused-390.png the same at 390px, fully inside the viewport

Open items for the landing reviewer

  1. The attach-by-id launcher — the scope judgement above. The one thing I'd want a second opinion on.
  2. No read-model census row for attach. PR #579 deliberately left it out ("the census row lands with the xterm.js pane slice, which owns that guard") — but that row needs readmodel.ExtraSources in cmd/cockpit/main.go, i.e. Go, and this slice's bound is web/** only. So the attach seam declares itself where it can without Go: the info answers, the boot log and --check. The census row + the e2e/shell.spec.ts "unnamed fifth row" guard extension is a follow-up ticket, not a gap I can close from here.
  3. Per-row info probes are one request per row on mount. With one row that is one request; when LD3.4's census lands a 20-sandbox wave is 20 cheap reads, and if that ever matters the fix is a batched census read rather than a button that lies until it is hovered.
  4. The sr-only containing-block trap is documented in the cell (relative is load-bearing). Worth knowing if another cell ever hides a sentence.
## What **LD3.3 frontend half (`LD3.3-ui`): the sandbox attach surface.** 5 commits, `lab-director/cockpit/web/**` only — no Go, no `ci.yaml`, no manifests, no flags. 1. **The attach column in the sandbox table** (design Part 2 §4.2 Wave View). Every row carries an attach button driven by `GET /api/v1/attach/{sandbox}/info`, rendering the backend's three postures: enabled, disabled **with the reason**, or disabled because the cockpit cannot look (no registry read). The column is **second, not last** — measured: at 1440px the Fleet Overview's middle column is ~520px while the table wants ~640px, so a trailing column put the operator's one action off the right edge on the screen it was built for. 2. **The terminal dialog** (design §4.1 screen 4 — "what is this agent doing, and let me type into it"): xterm 6.0.0 + fit-addon 0.11.0, lazily imported, themed from the page's own tokens, portalled to `<body>` with the LD3.2b viewport guard re-applied. 3. **Honest end-states, never a dead black box.** `session.svelte.ts` probes `info` **before** it opens a socket, which is the contract rather than an optimisation: a browser's `WebSocket.onerror` carries no status and no body by spec, so a client that opened first could not name the reason for the failure it hits most often (flag off → 503). A refusal therefore renders with **no socket, no upgrade, no ledger open** — and the dialog draws **no terminal element at all** until a session has actually opened. 4. **Refusals are audited and the UI says so**: `P-ATT-00N` policy line + "refused and recorded in the attach ledger", with the backend's own sentence verbatim (never a client paraphrase). ## The one scope judgement to review The rows carry the affordance, but **the cockpit has no sandbox-list read at all** — `info` is a lookup BY ID, and the read-model census names four sources, none of which enumerates sandboxes (the wave table says so in its own words). Rows-only would have shipped an **unreachable** feature: zero rows, zero ids, and the ticket's own acceptance ("operator attaches from the app into a live sandbox") impossible. The alternatives were (a) fabricate census rows — the single worst lie this application could print, and the one its own table refuses to print — or (b) make the capability reachable by naming a target. I chose (b): an **attach-by-id launcher** under the table, labelled with the missing registry read (LD3.4), reusing the same info probe and the same dialog. When the census lands it becomes the escape hatch an operator wants mid-incident anyway. If you'd rather defer it, say so and I'll gate it behind the census — the row path is complete without it. ## Dependency Merges `origin/plane/HOMELAB-1683-attach-bridge` (PR #579) as its **base**, because this slice wires against that wire contract and its fixture parity guard reads `attach/attach.go`, `attach/bridge.go` and `attach/protocol.go` from the Go source. **#579 must land first** (it is mergeable; #579's diff is unchanged here). If #579 changes before it lands, the guard fails loudly rather than drifting. ## Test plan ``` cd lab-director/cockpit/web npx svelte-check --tsconfig ./tsconfig.json → 0 errors, 0 warnings npm run test → 25 files passed (25), 221 tests passed (221) fixture parity guard: 13 tests collected (221 total) npm run build → build/index.html written npx playwright test → 62 passed (1.3m) ``` **Counts before → after** (asserted, not inferred — measured by stashing this branch's changes and re-running): - vitest: **145 → 221** tests, **20 → 25** files - Playwright: **48 → 62** specs New coverage, by layer: - **protocol/session (46 unit tests, hand-written socket)** — handshake, resize (including an INVALID resize being withheld rather than sent, because the backend closes the session over it), binary-vs-text split, close states, refusal rendering. A mocked socket is the only way to reach most of these: a refused handshake reports no status and no body by spec, which is exactly the path the flag-off server takes on every attempt. - **components (20 unit tests)** — the three postures per row, the no-TTY tooltip, the unreachable-vs-refused distinction, the status strip's recorded/not-recorded honesty. - **fixture↔wire parity (13 tests, reads the Go)** — pins the `Info` json tag set, the `Term` struct's **absence** of tags (the PascalCase trap), the `RefusalKind` vocabulary and status map, the no-TTY 200 override, `registryWiring`, `DefaultTerm`, the close-reason vocabulary, and the two refusal sentences character for character. - **e2e (14 specs)** — the live flag-off 503 end to end (including reading the response body the page received for `disabled`/`audit_recorded`), the three postures from fulfilled payloads, the dialog landing on the honest refusal with **no terminal in the DOM**, the portalled-to-`<body>` structural check, and the viewport guard at 1440 + 390. Measured at the tip: at **390px** the open dialog sits 675..836 inside an 844px viewport with its backdrop at 0..844, and `documentElement.scrollWidth` is **390 against a 390px clientWidth** (no sideways scroll). At 1440px: dialog 369..531 inside 900, backdrop 0..900, scrollWidth 1440 == clientWidth 1440. ## Screenshots `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-screenshots/` | file | what | |---|---| | `01-sandbox-row-attach-cell-no-id-1440.png` | the registry-less row: disabled attach button + "no id" chip, with the census named | | `02-attach-button-disabled-live-1440.png` | **live** flag-off 503: "attach disabled", `P-ATT-001`, the server's own reason | | `03-attach-button-no-tty-1440.png` | the no-TTY class posture (fulfilled): "no tty" + the reason, nothing to open | | `04-attach-button-attachable-1440.png` | the attachable posture (fulfilled): "open terminal" enabled | | `05-attach-dialog-refused-1440.png` | the dialog at 1440, landed on the refused end-state — no terminal drawn | | `06-attach-dialog-refused-390.png` | the same at 390px, fully inside the viewport | ## Open items for the landing reviewer 1. **The attach-by-id launcher** — the scope judgement above. The one thing I'd want a second opinion on. 2. **No read-model census row for `attach`.** PR #579 deliberately left it out ("the census row lands with the xterm.js pane slice, which owns that guard") — but that row needs `readmodel.ExtraSources` in `cmd/cockpit/main.go`, i.e. **Go**, and this slice's bound is `web/**` only. So the attach seam declares itself where it can without Go: the info answers, the boot log and `--check`. The census row + the `e2e/shell.spec.ts` "unnamed fifth row" guard extension is a follow-up ticket, not a gap I can close from here. 3. **Per-row info probes** are one request per row on mount. With one row that is one request; when LD3.4's census lands a 20-sandbox wave is 20 cheap reads, and if that ever matters the fix is a batched census read rather than a button that lies until it is hovered. 4. **The `sr-only` containing-block trap** is documented in the cell (`relative` is load-bearing). Worth knowing if another cell ever hides a sentence.
HOMELAB-1683: feat(cockpit): LD3.3 sandbox attach bridge, built disabled
Some checks failed
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Has been cancelled
CI Review / cockpit-test (pull_request) Has been cancelled
CI Review / cockpit-e2e (pull_request) Has been cancelled
Build Lab Director Cockpit Image / build-and-push (pull_request) Has been cancelled
1e59acd576
Adds the attach bridge behind LD_ATTACH_ENABLED (default false): the
cockpit/** mechanism, the server wiring and the refusal matrix, with no
manifests, no RBAC, no CNP, no dispatcher change and no web/**.

attach/ (new):
- Registry seam (read-only interface, recording fixture, and the refusing
  unwired registry that names the unratified RBAC) — the live CRD reads
  wait for the operator's ratification package §4/§5.
- Executor seam + client-go impl: remotecommand.NewSPDYExecutor over
  pods/exec with Tty:true and a TerminalSizeQueue; construction reads a
  kubeconfig (LD_ATTACH_KUBECONFIG, empty = in-cluster) and contacts
  nothing.
- Refusal matrix with a P-ATT-001..008 policy book, the class harness
  allowlist (spawn's own class constants; unknown class refuses), and the
  ld.audit.attach/v1 writer (audit.Entry gains attach kinds, outcomes and
  fields; Validate enforces the schema/kind/outcome pairing both ways and
  keeps sentinel entries free of attach fields).
- Wire() is the one place a kube client can be constructed: disabled
  builds none (asserted by a construction counter), enabled builds one and
  refuses per attempt while the registry is unwired.

server/:
- GET /api/v1/attach/{sandbox}/info and WebSocket /ws/attach/{sandbox},
  registered outside RequireOperator so that a refused handshake is still
  a ledger entry; identity resolution, refusals and the WS protocol all
  audit (open/close/refused), statusLogger gains the Hijack passthrough a
  websocket upgrade needs through the chain.
- One attach_open is answered by exactly one attach_close (a failed dial
  writes the aborted close), and an attach never opens when its open entry
  cannot be written.

config: LD_ATTACH_ENABLED (default false) + LD_ATTACH_KUBECONFIG; a
kubeconfig configured while the flag is off is refused as ambiguous.
main: attach declared in the read-model census, in --check's OK lines and
in the boot log ("no kube client constructed").

Tests: 192 pass under -race (attach 32, audit 9, server 47, config 13 new
or extended); full refusal matrix, WS protocol over a real socket
(upgrade/resize/EOF both ways/abrupt disconnect/protocol error/slow
consumer), ledger pairing and entry JSON shape.
HOMELAB-1683: fix(cockpit): bound the attach socket read frame at the library, label the refusal log by policy
Some checks failed
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 19s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 54s
CI Review / dispatcher-test (pull_request) Successful in 2m36s
CI Review / cockpit-test (pull_request) Successful in 2m49s
CI Review / cockpit-e2e (pull_request) Failing after 2m50s
f565272bf0
HOMELAB-1683: fix(cockpit): keep attach out of the read-model census until the pane slice owns its parity guard
Some checks failed
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 8s
CI Review / receiver-test (pull_request) Successful in 9s
CI Review / alert-receiver-test (pull_request) Successful in 29s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m6s
CI Review / cockpit-test (pull_request) Failing after 2m43s
CI Review / dispatcher-test (pull_request) Successful in 2m58s
CI Review / cockpit-e2e (pull_request) Failing after 3m16s
e79ed6ff4b
HOMELAB-1683: ci(rerun): no code change — re-trigger cockpit jobs after environment flakes
All checks were successful
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m2s
CI Review / cockpit-e2e (pull_request) Successful in 2m17s
CI Review / cockpit-test (pull_request) Successful in 2m29s
CI Review / dispatcher-test (pull_request) Successful in 2m38s
0/0 projects applied successfully.
ca9022f338
Previous run (1715): cockpit-e2e failed only on the PWA service-worker
activation spec (a known flake on this base; the de-flake is 694e8a7 on the
in-flight three-panes branch) and cockpit-test failed in its npm step
(registry), while the same Go tree passed cockpit-test in run 1709 and
passes go test -race locally. Empty commit to re-run the matrix.
The frontend half of LD3.3 starts at the contract. Every shape in
types/attach.ts was read off the Go it mirrors (attach.Info,
attach.Server*Frame, attach.RefusalKind, server.attachRefusal) and then
VERIFIED by marshalling those structs and diffing the JSON — which is how
two things the Go doc comments get wrong were found:

  - attach.Term carries NO json tags, so the info payload's terminal object
    is {"Columns":120,"Rows":32} (PascalCase), NOT the cols/rows the comment
    above the type claims. normaliseTerm accepts both spellings so a future
    tag addition is a no-op rather than a silent 0x0 geometry.
  - class_without_tty is answered 200, not the refusal kind's own 409: the
    read succeeded, so a 4xx would tell the frontend its request was wrong.

attachApi.info therefore does NOT throw on non-2xx. The backend answers a
decidable refusal as HTTP so a browser's onerror and an operator's curl see
the same JSON — but a browser cannot read a failed WebSocket handshake's
status at all, which is why every 400/401/403/404/409/502/503 is a POSTURE
the button renders rather than a transport failure it retries.

xterm 6.0.0 and @xterm/addon-fit 0.11.0 are pinned exactly (both are
lazily imported, so they stay out of the home screen's first-paint chunk).
formatBytes joins the format helpers for the session status strip.
Three modules, and the split between them is the point.

protocol.ts is the wire grammar, pure: no socket, no store, no DOM. The
backend states it once (attach/protocol.go) and says why in the comment
above it — "the xterm.js pane is the other half of this contract, and a
protocol described in two places is a protocol with two dialects". The rule
the whole file hangs off: binary is data, text is control. A control frame
typed into the shell leaks the cockpit's bookkeeping; keystrokes parsed as
JSON are dropped on the floor. Both are one if-statement away.

Binary detection is STRUCTURAL (ArrayBuffer.isView plus a
Object.prototype.toString tag read) rather than `instanceof ArrayBuffer`,
because instanceof is realm-local and the suite proved it: under jsdom a
Uint8Array from TextEncoder failed that check, and a client that failed it
would print "a message of an unknown type" at an operator instead of their
shell.

posture.ts turns the info answer into the ONE thing a control may be
(enabled, or disabled WITH A REASON) and never guesses. The order of its
checks is load-bearing and was found by a failing test: the handler's
pre-bridge refusal body carries no exists/has_tty at all, so it normalises
to exists:false — and reading that first filed "you are not signed in" as
"unknown sandbox", telling an operator their sandbox does not exist when the
truth is that the cockpit never looked.

session.svelte.ts probes BEFORE it connects, and that is the contract, not
an optimisation. WebSocket.onerror carries no status and no body by spec, so
a client that opened first could not name the reason for the failure it will
hit most often (the flag is off -> 503). Probing first means the refusal is
rendered with no socket at all, no upgrade and no ledger open. The
socket-died-without-a-frame path re-probes and, when the two answers
disagree, says so rather than picking one. Reconnection is deliberately
absent: a PTY is not an SSE read model, and reconnecting would write a
SECOND open entry against the same operator and target.
The UI half: a per-row attach cell, a by-id launcher, an xterm terminal and
the Sandbox Environment View as a modal (design Part 2 §4.1 screen 4 — "what
is this agent doing, and let me type into it").

Three decisions worth a reviewer's attention:

  - The rows carry the affordance, and the LAUNCHER exists because the
    cockpit has no sandbox-list read at all: info is a lookup BY ID, and the
    read model's census names four sources, none of which enumerates
    sandboxes. Rows-only would have shipped an unreachable feature. The
    launcher is the same posture as every other seam here — declare what is
    missing, make what IS possible possible — and the empty row renders a
    disabled button WITH the reason rather than no button, because "the
    registry is not read" and "this sandbox cannot be attached" are
    different facts.

  - The attach column is SECOND, not last, and that was decided by
    measurement: at 1440px the Fleet Overview's middle column is ~520px while
    the table wants ~640px, so a trailing column put the operator's one
    action off the right edge on the screen it was built for.

  - The dialog draws NO terminal until a session has actually opened. A
    refusal renders the status alone; an empty xterm below it would read as a
    shell that has not printed yet, which is the dead pane R6 forbids. It is
    portalled to <body> (the LD3.2b containing-block lesson), and its height
    is the terminal's height — with no terminal it shrinks to its five lines
    of status instead of wasting the screen.

One measurement worth recording, because nothing in review would have found
it: the visually-hidden reason sentence is position:absolute with no
positioned ancestor, so it escaped the table's overflow-x-auto scroll
container and grew the DOCUMENT's scrollWidth to 575px at a 390px viewport
— the exact sideways-scrolling page the 3.1a responsive net exists to catch,
and it caught it. Anchoring the cell (position: relative) keeps the sentence
inside the column the scroller clips.
76 unit tests across five new files, plus 9 more in the existing parity
guard (145 -> 221 total, 20 -> 25 files).

The protocol and session suites run against a hand-written socket, which is
the ONLY way most of these branches can be reached: a refused WebSocket
handshake reports no status and no body by spec, so the pre-upgrade refusal
path cannot be exercised against a real server at all — and it is the path
the flag-off server takes on every single attempt. Every honesty guarantee
is asserted at the layer that owns it, including the ones a passing suite
would otherwise hide: an invalid resize is WITHHELD rather than sent (the
backend closes the session over it), a binary frame that happens to spell
valid JSON is still data, a control frame never reaches the terminal, a late
frame from a replaced socket is ignored, and a fresh session resets every
counter so no previous sandbox's bytes are rendered as this one's.

The parity guard is extended to the attach contract and reads the Go source,
so the fixtures cannot drift behind a stale copy. It pins the Info json tag
set, the Term struct's ABSENCE of tags (the PascalCase trap), the
RefusalKind vocabulary, the status map, the no-TTY 200 override, the
registry wiring list, the DefaultTerm geometry, the close-reason vocabulary,
and the two refusal sentences character for character.

That guard is not ceremony. Two of the failures it and the e2e suite caught
were fixtures asserting payloads the backend never sends: a wiring array on
the disabled answer (the flag-off refusal is raised with an EMPTY wiring
argument, so the key is named in the reason instead) and a paraphrase of the
no-TTY sentence. Both were green in isolation and wrong about the wire.
HOMELAB-1683: test(cockpit-web): attach e2e — three postures, the dialog, 390px
Some checks are pending
Plan in progress...
CI Review / receiver-test (pull_request) Successful in 7s
CI Review / helm-validate (pull_request) Successful in 9s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m3s
CI Review / dispatcher-test (pull_request) Successful in 2m22s
CI Review / cockpit-test (pull_request) Successful in 2m39s
CI Review / cockpit-e2e (pull_request) Successful in 2m51s
126af06113
14 new Playwright specs (48 -> 62), against the real cockpit binary in dev
mode serving the real frontend build.

The split is forced by what this build can produce. LD_ATTACH_ENABLED is
false by design (the RBAC/CNP/arming delta is the operator's ratification
package), so the LIVE server answers 503 "attach disabled" on both
transports and records every attempt — the disabled posture is therefore
asserted completely end to end, including reading the response the page
actually received to check `disabled` and `audit_recorded` are true. An
attachable answer is impossible live, so the other two postures run with the
info payload fulfilled in the browser, from the same fixtures the Go tests
assert.

The most valuable spec is the one that follows the real refusal through the
frontend: a fulfilled "attachable" answer opens the dialog, the live server
then refuses the WebSocket upgrade, and the dialog has to land on an honest
end state — the disabled refusal, its policy line and "refused and recorded"
— with NO terminal element in the DOM. It is the R6 rule ("never a dead
black box") as an assertion rather than a promise, and it is also the path a
real operator hits whenever the registry moves under them.

The viewport guard from LD3.2b is parameterised rather than copied, so the
attach dialog — a second portal-to-body surface, mounted from inside a pane
nested under the sticky blurred header — gets the same measured
containing-block assertions at 1440 and 390, plus a structural check that
its parent really is <body>. Measured at the end of this branch: at 390px
the open dialog sits 675..836 inside an 844px viewport with its backdrop at
0..844, and the document scrollWidth is 390 against a 390px clientWidth.
Author
Owner

Builder report — implementation complete, CI green

Head: 126af06113 · CI: run #1730 (cockpit-test ✔ 2m39s, cockpit-e2e ✔ 2m51s, dispatcher-test ✔, helm-validate ✔) and run #1731 (build-and-push ✔ 2m3s) — all pass.

Local verification (at the tip, full paste)

$ npx svelte-check --tsconfig ./tsconfig.json
svelte-check found 0 errors and 0 warnings

$ npm run test
 Test Files  25 passed (25)
      Tests  221 passed (221)
fixture parity guard: 13 tests collected (221 tests total)

$ npm run build
> Using @sveltejs/adapter-static
  Wrote site to "build"
  ✔ done
build/index.html  2624 bytes

$ npx playwright test
  62 passed (1.3m)

Counts asserted, not inferred

Measured by git stash -u on this branch and re-running the untouched tree:

suite before after
vitest tests 145 221
vitest files 20 25
Playwright specs 48 62

Every pre-existing spec is green: shell.spec.ts (including the "unnamed fifth row" census guard, still 4 rows), panes.spec.ts, gates-configured.spec.ts, responsive.spec.ts (all three routes at 390 and 1440), auth.spec.ts.

390px, measured directly with the dialog open

390px  -> {"scrollWidth":390,"clientWidth":390,
           "dialogTop":675,"dialogBottom":836,"dialogWidth":374,
           "backdropTop":0,"backdropBottom":844,"viewportHeight":844,
           "terminals":0,"state":"refused"}
1440px -> {"scrollWidth":1440,"clientWidth":1440,
           "dialogTop":369,"dialogBottom":531,"dialogWidth":896,
           "backdropTop":0,"backdropBottom":900,"viewportHeight":900,
           "terminals":0,"state":"refused"}

scrollWidth == clientWidth at both widths — no sideways scroll — and zero terminal elements are drawn for a refused session.

Three bugs this slice found in itself (all fixed, all now tested)

  1. The sr-only containing-block escape. The visually-hidden reason sentence is position: absolute with no positioned ancestor, so it left the table's overflow-x-auto scroll container and grew the document's scrollWidth to 575px at 390px. responsive.spec.ts and panes.spec.ts went red naming no offender (the element is invisible). Fixed with relative on the cell; documented in the component.
  2. formatDuration fed milliseconds — the status strip rendered a 61-second session as "17h 05m".
  3. instanceof ArrayBuffer is realm-local. Under jsdom a Uint8Array from TextEncoder failed the check, so terminal output was misclassified as a control frame. Now ArrayBuffer.isView + a tag read.

Two fixtures that were wrong about the wire (found by the parity guard + the live binary)

  • A wiring array on the disabled answer: the flag-off refusal is raised with an empty wiring argument, so the key is named in the reason and the payload carries no list. Only registry_unwired populates it.
  • A paraphrased no-TTY sentence. Both now assert the Go string character for character.

Evidence

  • Artifacts: /home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-builder.md (full record) and ld3-1683b-review.html (all six screenshots on one dark page).
  • Screenshots: /home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-screenshots/ — 01 row cell / 02 live disabled / 03 no-tty / 04 attachable / 05 dialog @1440 / 06 dialog @390.

For the landing reviewer, in priority order

  1. The attach-by-id launcher — my one scope judgement, explained in the PR body. Withdrawable without touching the row path.
  2. The missing read-model census row for attach — needs Go (cmd/cockpit/main.go ExtraSources), outside this slice's bound. Follow-up ticket.
  3. The dependency merge of #579 must land first; if #579's wire changes, the parity guard fails loudly rather than drifting.
## Builder report — implementation complete, CI green **Head:** `126af06113` · **CI:** run #1730 (`cockpit-test` ✔ 2m39s, `cockpit-e2e` ✔ 2m51s, `dispatcher-test` ✔, `helm-validate` ✔) and run #1731 (`build-and-push` ✔ 2m3s) — **all pass**. ### Local verification (at the tip, full paste) ``` $ npx svelte-check --tsconfig ./tsconfig.json svelte-check found 0 errors and 0 warnings $ npm run test Test Files 25 passed (25) Tests 221 passed (221) fixture parity guard: 13 tests collected (221 tests total) $ npm run build > Using @sveltejs/adapter-static Wrote site to "build" ✔ done build/index.html 2624 bytes $ npx playwright test 62 passed (1.3m) ``` ### Counts asserted, not inferred Measured by `git stash -u` on this branch and re-running the untouched tree: | suite | before | after | |---|---|---| | vitest tests | 145 | **221** | | vitest files | 20 | **25** | | Playwright specs | 48 | **62** | Every pre-existing spec is green: `shell.spec.ts` (including the "unnamed fifth row" census guard, still 4 rows), `panes.spec.ts`, `gates-configured.spec.ts`, `responsive.spec.ts` (all three routes at 390 and 1440), `auth.spec.ts`. ### 390px, measured directly with the dialog open ``` 390px -> {"scrollWidth":390,"clientWidth":390, "dialogTop":675,"dialogBottom":836,"dialogWidth":374, "backdropTop":0,"backdropBottom":844,"viewportHeight":844, "terminals":0,"state":"refused"} 1440px -> {"scrollWidth":1440,"clientWidth":1440, "dialogTop":369,"dialogBottom":531,"dialogWidth":896, "backdropTop":0,"backdropBottom":900,"viewportHeight":900, "terminals":0,"state":"refused"} ``` `scrollWidth == clientWidth` at both widths — no sideways scroll — and **zero terminal elements** are drawn for a refused session. ### Three bugs this slice found in itself (all fixed, all now tested) 1. **The `sr-only` containing-block escape.** The visually-hidden reason sentence is `position: absolute` with no positioned ancestor, so it left the table's `overflow-x-auto` scroll container and grew the **document's** `scrollWidth` to 575px at 390px. `responsive.spec.ts` and `panes.spec.ts` went red naming no offender (the element is invisible). Fixed with `relative` on the cell; documented in the component. 2. **`formatDuration` fed milliseconds** — the status strip rendered a 61-second session as "17h 05m". 3. **`instanceof ArrayBuffer` is realm-local.** Under jsdom a `Uint8Array` from `TextEncoder` failed the check, so terminal output was misclassified as a control frame. Now `ArrayBuffer.isView` + a tag read. ### Two fixtures that were wrong about the wire (found by the parity guard + the live binary) - A `wiring` array on the **disabled** answer: the flag-off refusal is raised with an *empty* wiring argument, so the key is named in the reason and the payload carries no list. Only `registry_unwired` populates it. - A paraphrased no-TTY sentence. Both now assert the Go string character for character. ### Evidence - Artifacts: `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-builder.md` (full record) and `ld3-1683b-review.html` (all six screenshots on one dark page). - Screenshots: `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1683b-screenshots/` — `01` row cell / `02` live disabled / `03` no-tty / `04` attachable / `05` dialog @1440 / `06` dialog @390. ### For the landing reviewer, in priority order 1. **The attach-by-id launcher** — my one scope judgement, explained in the PR body. Withdrawable without touching the row path. 2. **The missing read-model census row for `attach`** — needs Go (`cmd/cockpit/main.go` `ExtraSources`), outside this slice's bound. Follow-up ticket. 3. The dependency merge of #579 must land first; if #579's wire changes, the parity guard fails loudly rather than drifting.
Author
Owner

Review 1 — contract/security lens (fresh context, deepseek/deepseek-flash, head 126af0611)

Independent reviewer, no author context. VERDICT: BLOCK

Blockers:

  1. The session's displayed byte accounting is wrong: every output byte is counted as BOTH bytesIn and bytesOut, and no input byte is counted at all.

    • web/src/lib/attach/session.svelte.ts:204-207 — emit() does bytesOut += bytes.byteLength; its only caller is :332-333, which already did bytesIn += message.bytes.byteLength for the same server→client data frame. sendInput() (:540-543) increments nothing.
    • web/src/lib/components/attach/attach-status.svelte:70 renders {formatBytes(session.bytesIn)} in · {formatBytes(session.bytesOut)} out, i.e. two identical numbers that can never reflect the operator's keystrokes. The backend's direction is the opposite (cockpit/server/attach.go: bytesIn = client→sandbox keystrokes, bytesOut = sandbox→client output, the same pair the ledger and ServerCloseFrame carry).
    • The authoritative numbers arrive in the close frame and are never rendered: attach-status.svelte:70 reads the store counters, while end.bytesIn/bytesOut (session.svelte.ts:402-403) are only reachable through end, which the accounting line does not use.
    • Why it matters: this is a displayed, wrong quantity on the one surface whose whole justification is auditability, and the component's own test calls it "the ledger's own accounting" (web/src/lib/components/attach/attach-status.test.ts:137). The store test codifies the bug: session.svelte.test.ts:393 asserts bytesOut === 15 after 15 received bytes, while attach-status.test.ts's stub feeds end.bytesIn/out into session.bytesIn/out (:43-44), so the component test cannot catch it.
    • Evidence (my probe, run against the PR sources): after 13 bytes of OUTPUT: in = 13 out = 13 / after 3 bytes of INPUT: in = 13 out = 13.
  2. The cockpit's 401 (and 403) attach refusals are converted into a transport failure, so the UI prints "unreachable · API error 401" for a refusal the backend decided, reasoned about and audited.

    • web/src/lib/api/client.ts:274 + :279 + the predicate at :308-317: isAttachAnswer requires reason|attachable|disabled|has_tty. server.writeAttachRefusal answers an unauthenticated /api/.../info with s.opts.Auth.DenyUnauthenticated (cockpit/server/attach.go:249-253), which for an /api/ path (wantsJSON, auth/middleware.go:288-293) writes the Problem envelope {code,detail} (auth/middleware.go:236-237,299-311) — no reason, no attachable. So attachApi.info takes the throw new ApiError(res.status, body) branch.
    • All three callers then fold it into unreachablePosture(error.message): attach-cell.svelte:76-79, attach-launcher.svelte:73-76, session.svelte.ts:~250. ApiError.message is API error 401, and ApiError.detail — which does hold the server's sentence — is not consulted. The operator sees chip "unreachable", tone bad, reason "API error 401", and explainUnexplainedClose/start label the end state kind: 'failed' / code: 'probe_unreachable' ("the cockpit did not decide anything").
    • Consequence: posture.ts:179-190 (401), :192-203 (403) and the entire postureFromStatus / STATUS_REFUSAL_KIND path (:113, :282-330) are dead code on the real wire. The server's verbatim sentence — "Sign in at /auth/login to use the cockpit." — never reaches the operator, and the distinction this lens asks about ("refused by policy" vs "cockpit cannot look") is inverted: the cockpit did look, and refused.
    • Reachability in production: the shell only redirects on 401/403 from the readmodel boot fetch or the SSE unauthorized tick (web/src/routes/+layout.svelte:45,49,77), and session.me.operator === false at boot. A session that lapses after boot leaves the SPA up, so the next attach probe is a 401 → "unreachable". The e2e cannot see this (dev identity always carries the role, auth/middleware.go:200-213), and no unit test imports attachApi at all (grep over src/**/*.test.ts).

Concerns:

  1. start() never tears down a previous socket, and the dialog has no unmount teardown. session.svelte.ts:213-230 resets every field except socket; :307 overwrites the reference. The orphan is then unreachable by design — the onclose/onmessage guards compare socket !== source (:320-323, :327) and return — so nothing ever closes it: the backend keeps the PTY of serveAttach running and leaves an attach_open ledger entry with no matching close (P-ATT-005: "every open is answered by exactly one close"). Confirmed empirically: after a second start(), sockets built: 2 first.closedWith = null. Reachability is narrow (the modal backdrop at attach-dialog.svelte:120 blocks a second attach click, so the concrete trigger is an effect-driven re-start with a changed sandbox), but session.svelte.test.ts "ignores a late frame from a socket the operator already replaced" exercises exactly this double-start and asserts only that frames are ignored — the leak is untested rather than absent. Separately, attach-dialog.svelte has no onDestroy/effect cleanup; session.close() runs only from dismiss() (:94), so unmounting the pane (browser back, route change) with a live session orphans the socket and its ledger-open shell.

  2. recorded: true is asserted for every error frame without evidence. session.svelte.ts:414; the rendered claim is attach-status.svelte:104-110 ("refused and recorded in the attach ledger"). attach.ServerErrorFrame (cockpit/attach/protocol.go:110-116) carries no audit flag, yet Refusal.AuditRecorded is false whenever the sink refused the entry — and RefusalAuditUnavailable is raised precisely because auditor.Opened failed (bridge.go, Open). So the one case where the ledger is provably broken is the case where the UI says the refusal is in it. The honest rendering is "the backend does not report whether this entry landed". (Contrast the info path, which does carry audit_recorded and is rendered honestly.)

  3. exists === false mislabels two named refusals. posture.ts:240 maps exists === false to kind: 'unknown_sandbox'. RefusalMalformedTarget is returned with Sandbox{} (bridge.go, resolve), so Info.Exists is false and a 409 renders chip "unknown sandbox" beside the server's own sentence "the registry resolved a sandbox the bridge cannot dial." — the label contradicts the reason. Same for status 409 generally: it has no branch in postureFromInfo at all, despite attach.go giving it three kinds. And sandbox_without_ticket (200) falls through to the generic refused bucket (:265-274): honest reason, but the chip drops the named refusal the backend sent. Probe output: 409 malformed -> unknown_sandbox | unknown sandbox | the registry resolved a sandbox the bridge cannot dial.

  4. No connect budget, so a black-holed upgrade is an empty terminal forever. showTerminal (attach-dialog.svelte:86-88) is true while state === 'connecting', and connecting has no timeout — nothing in session.svelte.ts bounds the handshake, and onerror merely re-asserts connecting (:~313-317). A proxy that accepts and never completes leaves a blank xterm under "opening" indefinitely, which is exactly the ambiguity R6 exists to remove (the operator can dismiss, which does close the socket, but nothing says the open is stuck).

Suggestions:

  1. attach-terminal.svelte:2 statically imports @xterm/xterm/css/xterm.css while :66-67 lazily imports the JS. I built the PR (npm install && npm run build): the JS claim holds — xterm is its own chunk (build/_app/immutable/chunks/B-qIQCd3.js, 329 KB) imported only from the page node and never modulepreloaded in index.html — but the CSS is a page-level asset (assets/2.DDGTF8rc.css, 3.6 KB) pulled in by attach-dialog.svelte's static import, so it is fetched on first paint of the home route, not "when the component mounts" as the comment at :18-22 states. Additionally the (pre-existing) service worker precaches [...build, ...files] (web/src/service-worker.ts), which includes the xterm chunk, so on an installed SW the lazy import defers parsing, not download. Fix the comment or move the CSS import into the dynamic path.

  2. attachInfoPath/attachSocketUrl (client.ts:287,299-302) percent-encode the sandbox id, but Go decodes %2F into r.URL.Path before the GET /api/v1/attach/{sandbox}/info pattern is matched, so an id containing / fails at the mux (plain-text 404) instead of reaching the handler as an unknown sandbox — i.e. the opposite of the reasoning in the comment at :283-286. Harmless for real ids; the justification is wrong.

  3. normaliseTerm (types/attach.ts:42-47) accepts any finite positive number, including non-integers; only openTerm → validateTerm (protocol.ts:284, :78-89) enforces integrality. Adding Number.isInteger to the normaliser would make ?cols=120.5 unrepresentable at the boundary rather than relying on the second check.

  4. attach-cell.svelte:56-57 states "the sandbox registry is not read yet (LD3.4)" as a fact about the cockpit, but the cell only knows its sandbox prop is null. Once LD3.4 supplies rows, that sentence becomes a client invention about backend state (and the (LD3.4) ticket reference will be stale).

  5. No unit coverage for attachApi.info, attachInfoPath or attachSocketUrl. The socket URL construction (Q2's whole subject) is asserted only indirectly by the e2e; a three-line test for http→ws / https→wss, the escaped path and the absent query when the terminal is null would make the contract checkable without a browser. (protocol.test.ts does this well for the frame grammar; the URL builder got none of it.)

Evidence:

  • Read (Go contract): git show origin/epic/LD3:lab-director/cockpit/{attach/wire.go,attach/attach.go,attach/bridge.go,attach/protocol.go,attach/registry.go,server/attach.go}; plus cockpit/server/server.go:189-190 (routes), cockpit/auth/middleware.go:185-193,234-263,288-311 (DenyUnauthenticated/DenyForbidden/wantsJSON/Problem).
  • Read (frontend, PR blobs): all of web/src/lib/{api/client.ts,attach/posture.ts,attach/protocol.ts,attach/session.svelte.ts,types/attach.ts,components/attach/*.svelte,tests/fixtures.ts,e2e/attach.spec.ts}.
  • Ran against the PR content: npm test → 25 files, 221 tests, all pass (including the 13-test fixture↔Go parity guard, which confirms the attach.Term PascalCase trap is genuinely handled).
  • Ran a temporary probe suite (created, executed, then deleted; package-lock.json restored with git checkout --):
    • attachApi.info with the cockpit's real 401 body → THREW API error 401 (posture never derived); 403 → THREW API error 403.
    • postureOf with an Info body for malformed_target @409 → unknown_sandbox; for sandbox_without_ticket @200 → refused.
    • session with a fake socket: 13 output bytes → in = 13 out = 13; then 3 input bytes → in = 13 out = 13; second start() → first.closedWith = null.
  • node_modules was absent; ran npm install (2 packages) then npm run build to inspect chunking: build/index.html modulepreloads only the layout/entry chunks; grep -rl B-qIQCd3 build/_app → only nodes/2.CGcRWatI.js (confirms the lazy JS import); grep -o 'href="[^"]*\.css"' build/index.html → only 0.DYSU5LqX.css while 2.DDGTF8rc.css (the xterm stylesheet) is referenced from the client manifest.
  • Verified @xterm/xterm@6.0.0 / @xterm/addon-fit@0.11.0 are exact pins (package.json:29-30), self-hosted (no CDN), and that css/xterm.css exists in the tarball with no exports map blocking the subpath (npm pack + unpack).

What I verified as correct, for the record: the button's enabled state is derived only from info.attachable (posture.ts:146-153, attach-cell.svelte:90), and the disabled reason is the server's own sentence where the server gave one; no credential/token is placed in the socket URL and the scheme/host come from the page origin (client.ts:299-302); binary-vs-text is split before parsing (protocol.ts:180-200) and the realm-independent ArrayBuffer.isView/toString checks are the right call; resize is bounded by the same 1000-cell limit as the backend and withheld rather than sent (protocol.ts:57,110-124); attach.Term's PascalCase geometry is normalised, so the {"Columns":120,"Rows":32} trap does not silently become 0×0; 503-flag-off and 200-class_without_tty are interpreted correctly (200 + has_tty:false → no_tty, and ResizeObserver/window listeners/unsubscribe are all torn down in attach-terminal.svelte:127-140).

## Review 1 — contract/security lens (fresh context, `deepseek/deepseek-flash`, head 126af0611) Independent reviewer, no author context. **VERDICT: BLOCK** **Blockers:** 1. **The session's displayed byte accounting is wrong: every output byte is counted as BOTH `bytesIn` and `bytesOut`, and no input byte is counted at all.** - `web/src/lib/attach/session.svelte.ts:204-207` — `emit()` does `bytesOut += bytes.byteLength`; its only caller is `:332-333`, which already did `bytesIn += message.bytes.byteLength` for the same server→client data frame. `sendInput()` (`:540-543`) increments nothing. - `web/src/lib/components/attach/attach-status.svelte:70` renders `{formatBytes(session.bytesIn)} in · {formatBytes(session.bytesOut)} out`, i.e. two identical numbers that can never reflect the operator's keystrokes. The backend's direction is the opposite (`cockpit/server/attach.go`: `bytesIn` = client→sandbox keystrokes, `bytesOut` = sandbox→client output, the same pair the ledger and `ServerCloseFrame` carry). - The authoritative numbers arrive in the close frame and are never rendered: `attach-status.svelte:70` reads the *store* counters, while `end.bytesIn/bytesOut` (`session.svelte.ts:402-403`) are only reachable through `end`, which the accounting line does not use. - Why it matters: this is a displayed, wrong quantity on the one surface whose whole justification is auditability, and the component's own test calls it "the ledger's own accounting" (`web/src/lib/components/attach/attach-status.test.ts:137`). The store test **codifies the bug**: `session.svelte.test.ts:393` asserts `bytesOut === 15` after 15 *received* bytes, while `attach-status.test.ts`'s stub feeds `end.bytesIn/out` into `session.bytesIn/out` (`:43-44`), so the component test cannot catch it. - Evidence (my probe, run against the PR sources): `after 13 bytes of OUTPUT: in = 13 out = 13` / `after 3 bytes of INPUT: in = 13 out = 13`. 2. **The cockpit's 401 (and 403) attach refusals are converted into a transport failure, so the UI prints "unreachable · API error 401" for a refusal the backend decided, reasoned about and audited.** - `web/src/lib/api/client.ts:274` + `:279` + the predicate at `:308-317`: `isAttachAnswer` requires `reason|attachable|disabled|has_tty`. `server.writeAttachRefusal` answers an unauthenticated `/api/.../info` with `s.opts.Auth.DenyUnauthenticated` (`cockpit/server/attach.go:249-253`), which for an `/api/` path (`wantsJSON`, `auth/middleware.go:288-293`) writes the Problem envelope `{code,detail}` (`auth/middleware.go:236-237,299-311`) — no `reason`, no `attachable`. So `attachApi.info` takes the `throw new ApiError(res.status, body)` branch. - All three callers then fold it into `unreachablePosture(error.message)`: `attach-cell.svelte:76-79`, `attach-launcher.svelte:73-76`, `session.svelte.ts:~250`. `ApiError.message` is `API error 401`, and `ApiError.detail` — which does hold the server's sentence — is not consulted. The operator sees chip **"unreachable"**, tone `bad`, reason **"API error 401"**, and `explainUnexplainedClose`/`start` label the end state `kind: 'failed'` / `code: 'probe_unreachable'` ("the cockpit did not decide anything"). - Consequence: `posture.ts:179-190` (401), `:192-203` (403) and the entire `postureFromStatus` / `STATUS_REFUSAL_KIND` path (`:113`, `:282-330`) are **dead code on the real wire**. The server's verbatim sentence — "Sign in at /auth/login to use the cockpit." — never reaches the operator, and the distinction this lens asks about ("refused by policy" vs "cockpit cannot look") is inverted: the cockpit *did* look, and refused. - Reachability in production: the shell only redirects on 401/403 from the *readmodel* boot fetch or the SSE `unauthorized` tick (`web/src/routes/+layout.svelte:45,49,77`), and `session.me.operator === false` at boot. A session that lapses after boot leaves the SPA up, so the next attach probe is a 401 → "unreachable". The e2e cannot see this (dev identity always carries the role, `auth/middleware.go:200-213`), and **no unit test imports `attachApi` at all** (grep over `src/**/*.test.ts`). **Concerns:** 3. **`start()` never tears down a previous socket, and the dialog has no unmount teardown.** `session.svelte.ts:213-230` resets every field except `socket`; `:307` overwrites the reference. The orphan is then unreachable *by design* — the `onclose`/`onmessage` guards compare `socket !== source` (`:320-323`, `:327`) and return — so nothing ever closes it: the backend keeps the PTY of `serveAttach` running and leaves an `attach_open` ledger entry with no matching close (P-ATT-005: "every open is answered by exactly one close"). Confirmed empirically: after a second `start()`, `sockets built: 2 first.closedWith = null`. Reachability is narrow (the modal backdrop at `attach-dialog.svelte:120` blocks a second attach click, so the concrete trigger is an effect-driven re-`start` with a changed `sandbox`), **but** `session.svelte.test.ts` "ignores a late frame from a socket the operator already replaced" exercises exactly this double-start and asserts only that frames are ignored — the leak is untested rather than absent. Separately, `attach-dialog.svelte` has no `onDestroy`/effect cleanup; `session.close()` runs only from `dismiss()` (`:94`), so unmounting the pane (browser back, route change) with a live session orphans the socket and its ledger-open shell. 4. **`recorded: true` is asserted for every `error` frame without evidence.** `session.svelte.ts:414`; the rendered claim is `attach-status.svelte:104-110` ("refused and recorded in the attach ledger"). `attach.ServerErrorFrame` (`cockpit/attach/protocol.go:110-116`) carries no audit flag, yet `Refusal.AuditRecorded` is false whenever the sink refused the entry — and `RefusalAuditUnavailable` is raised *precisely because* `auditor.Opened` failed (`bridge.go`, `Open`). So the one case where the ledger is provably broken is the case where the UI says the refusal is in it. The honest rendering is "the backend does not report whether this entry landed". (Contrast the info path, which does carry `audit_recorded` and is rendered honestly.) 5. **`exists === false` mislabels two named refusals.** `posture.ts:240` maps `exists === false` to `kind: 'unknown_sandbox'`. `RefusalMalformedTarget` is returned with `Sandbox{}` (`bridge.go`, `resolve`), so `Info.Exists` is false and a 409 renders chip **"unknown sandbox"** beside the server's own sentence "the registry resolved a sandbox the bridge cannot dial." — the label contradicts the reason. Same for status 409 generally: it has no branch in `postureFromInfo` at all, despite `attach.go` giving it three kinds. And `sandbox_without_ticket` (200) falls through to the generic `refused` bucket (`:265-274`): honest reason, but the chip drops the named refusal the backend sent. Probe output: `409 malformed -> unknown_sandbox | unknown sandbox | the registry resolved a sandbox the bridge cannot dial.` 6. **No connect budget, so a black-holed upgrade is an empty terminal forever.** `showTerminal` (`attach-dialog.svelte:86-88`) is true while `state === 'connecting'`, and `connecting` has no timeout — nothing in `session.svelte.ts` bounds the handshake, and `onerror` merely re-asserts `connecting` (`:~313-317`). A proxy that accepts and never completes leaves a blank xterm under "opening" indefinitely, which is exactly the ambiguity R6 exists to remove (the operator can dismiss, which does close the socket, but nothing says the open is stuck). **Suggestions:** 7. `attach-terminal.svelte:2` statically imports `@xterm/xterm/css/xterm.css` while `:66-67` lazily imports the JS. I built the PR (`npm install && npm run build`): the JS claim holds — xterm is its own chunk (`build/_app/immutable/chunks/B-qIQCd3.js`, 329 KB) imported only from the page node and never `modulepreload`ed in `index.html` — but the CSS is a *page-level* asset (`assets/2.DDGTF8rc.css`, 3.6 KB) pulled in by `attach-dialog.svelte`'s static import, so it is fetched on first paint of the home route, not "when the component mounts" as the comment at `:18-22` states. Additionally the (pre-existing) service worker precaches `[...build, ...files]` (`web/src/service-worker.ts`), which includes the xterm chunk, so on an installed SW the lazy import defers parsing, not download. Fix the comment or move the CSS import into the dynamic path. 8. `attachInfoPath`/`attachSocketUrl` (`client.ts:287,299-302`) percent-encode the sandbox id, but Go decodes `%2F` into `r.URL.Path` before the `GET /api/v1/attach/{sandbox}/info` pattern is matched, so an id containing `/` fails at the mux (plain-text 404) instead of reaching the handler as an unknown sandbox — i.e. the opposite of the reasoning in the comment at `:283-286`. Harmless for real ids; the justification is wrong. 9. `normaliseTerm` (`types/attach.ts:42-47`) accepts any finite positive number, including non-integers; only `openTerm` → `validateTerm` (`protocol.ts:284`, `:78-89`) enforces integrality. Adding `Number.isInteger` to the normaliser would make `?cols=120.5` unrepresentable at the boundary rather than relying on the second check. 10. `attach-cell.svelte:56-57` states "the sandbox registry is not read yet (LD3.4)" as a fact about the cockpit, but the cell only knows its `sandbox` prop is null. Once LD3.4 supplies rows, that sentence becomes a client invention about backend state (and the `(LD3.4)` ticket reference will be stale). 11. No unit coverage for `attachApi.info`, `attachInfoPath` or `attachSocketUrl`. The socket URL construction (Q2's whole subject) is asserted only indirectly by the e2e; a three-line test for `http→ws` / `https→wss`, the escaped path and the absent query when the terminal is null would make the contract checkable without a browser. (`protocol.test.ts` does this well for the frame grammar; the URL builder got none of it.) **Evidence:** - Read (Go contract): `git show origin/epic/LD3:lab-director/cockpit/{attach/wire.go,attach/attach.go,attach/bridge.go,attach/protocol.go,attach/registry.go,server/attach.go}`; plus `cockpit/server/server.go:189-190` (routes), `cockpit/auth/middleware.go:185-193,234-263,288-311` (`DenyUnauthenticated`/`DenyForbidden`/`wantsJSON`/`Problem`). - Read (frontend, PR blobs): all of `web/src/lib/{api/client.ts,attach/posture.ts,attach/protocol.ts,attach/session.svelte.ts,types/attach.ts,components/attach/*.svelte,tests/fixtures.ts,e2e/attach.spec.ts}`. - Ran against the PR content: `npm test` → **25 files, 221 tests, all pass** (including the 13-test fixture↔Go parity guard, which confirms the `attach.Term` PascalCase trap is genuinely handled). - Ran a temporary probe suite (created, executed, then deleted; `package-lock.json` restored with `git checkout --`): - `attachApi.info` with the cockpit's real 401 body → `THREW API error 401` (posture never derived); 403 → `THREW API error 403`. - `postureOf` with an `Info` body for `malformed_target` @409 → `unknown_sandbox`; for `sandbox_without_ticket` @200 → `refused`. - session with a fake socket: 13 output bytes → `in = 13 out = 13`; then 3 input bytes → `in = 13 out = 13`; second `start()` → `first.closedWith = null`. - `node_modules` was absent; ran `npm install` (2 packages) then `npm run build` to inspect chunking: `build/index.html` modulepreloads only the layout/entry chunks; `grep -rl B-qIQCd3 build/_app` → only `nodes/2.CGcRWatI.js` (confirms the lazy JS import); `grep -o 'href="[^"]*\.css"' build/index.html` → only `0.DYSU5LqX.css` while `2.DDGTF8rc.css` (the xterm stylesheet) is referenced from the client manifest. - Verified `@xterm/xterm@6.0.0` / `@xterm/addon-fit@0.11.0` are exact pins (`package.json:29-30`), self-hosted (no CDN), and that `css/xterm.css` exists in the tarball with no `exports` map blocking the subpath (`npm pack` + unpack). **What I verified as correct, for the record:** the button's enabled state is derived only from `info.attachable` (`posture.ts:146-153`, `attach-cell.svelte:90`), and the disabled reason is the server's own sentence where the server gave one; no credential/token is placed in the socket URL and the scheme/host come from the page origin (`client.ts:299-302`); binary-vs-text is split before parsing (`protocol.ts:180-200`) and the realm-independent `ArrayBuffer.isView`/`toString` checks are the right call; resize is bounded by the same 1000-cell limit as the backend and withheld rather than sent (`protocol.ts:57,110-124`); `attach.Term`'s PascalCase geometry is normalised, so the `{"Columns":120,"Rows":32}` trap does not silently become 0×0; 503-flag-off and 200-`class_without_tty` are interpreted correctly (200 + `has_tty:false` → `no_tty`, and `ResizeObserver`/window listeners/unsubscribe are all torn down in `attach-terminal.svelte:127-140`).
Author
Owner

Review 2 — test-adequacy lens (fresh context, deepseek/deepseek-flash, head 126af0611)

Independent reviewer, no author context. VERDICT: BLOCK

Blockers:

  1. The terminal — the thing this PR exists to render — is never mounted by any test, and its mount gate can be pinned to false while 221 unit + 14 e2e tests stay green.

    • Gate: lab-director/cockpit/web/src/lib/components/attach/attach-dialog.svelte:86 (showTerminal), consumed at :126 (h-[92vh]) and :177 ({#if showTerminal}).
    • attach-dialog.svelte has no unit test file (src/lib/components/attach/ contains tests for cell/launcher/status only), and grep -rn attach-terminal over src/ + e2e/ finds exactly one assertion in the whole suite: e2e/attach.spec.ts:177 toHaveCount(0).
    • The e2e fixture makes the positive case unreachable: fulfilInfoThenDisabled (e2e/attach.spec.ts:58-72) returns attachable on call #1 and disabled thereafter, but the launcher consumes call #1, so the dialog's own probe is call #2 = disabled and the session refuses before ever reaching state = 'connecting' (session.svelte.ts:285). Instrumented observation (my temporary spec, MutationObserver with attributeOldValue on [data-state], saw counter on [data-testid=attach-terminal]): PROBE2-EVENTS ["state:idle->probing","state:idle->probing","state:probing->refused","state:probing->refused"] and the terminal node count {"saw":0} — it is never inserted, not even transiently. So the e2e test's own name ("an attachable answer enables the terminal") and the spec comment ("so the frontend opens a terminal") describe something the spec cannot reach, and line 177 passes for the wrong reason (it does retain exactly one discriminating power: a gate that is true too early, verified — forcing showTerminal = true turns it red).
    • Falsified both directions: showTerminal = $derived(false) → unit 221/221 GREEN, e2e 14/14 GREEN (nothing detects it); $derived(true) → e2e RED (test 5). Therefore attach-terminal.svelte (178 lines: xterm mount + lazy chunk load, term.onData → session.sendInput, session.onOutput → term.write, ResizeObserver fit() → session.resize, theme token resolution, teardown) is 100% unverified, as is showTerminal's opened !== null and live legs. Why it matters: the ticket's acceptance ("operator attaches from the app into a live sandbox") is exactly the path with no assertion, and a one-token regression ships green.
  2. The fixture↔wire parity guard is silently blind to the two numeric wire bounds it claims to mirror.

    • src/lib/attach/protocol.ts:57 MAX_TERM_DIMENSION = 1000 and :62 MAX_CONTROL_FRAME_BYTES = 4096 are hard-coded restatements of Go attach.MaxTermDimension (../attach/attach.go:109) and attach.MaxControlFrameBytes (../attach/protocol.go:49), and both doc-comments name the Go symbols. fixtures.test.ts reads attach/attach.go, attach/bridge.go, attach/protocol.go and pins tags, refusal kinds, Status(), both reason sentences, registryWiring, DefaultTerm and close reasons — but never these two constants; protocol.test.ts:165-169,180-196 and session.svelte.test.ts:360 only compare the constants against themselves.
    • Falsified: Go MaxTermDimension = 1000 → 500 → 221/221 GREEN; Go MaxControlFrameBytes = 4<<10 → 1<<10 → 221/221 GREEN (full suite, both times). A lowered Go bound is the direction that bites: the client would then send a resize the backend answers by closing the session with protocol_error — the precise failure encodeResize exists to prevent. Blast radius today is small (real panes ≲300 cols), but this is structurally the #578 pattern (a guard that doesn't guard) inside the guard that was written to close it, and the fix is ~6 lines in the same parser.

Concerns:

  1. The #578 viewport guard is real geometry, but it never measures the dialog variant that can overflow. e2e/dialog-viewport.ts:112-123 asserts all four dialog edges plus backdropTop === 0 / backdropBottom === viewportHeight, collected into one toEqual([]) — a genuine geometric assertion, not a no-op. But instrumented at e2e/attach.spec.ts:240 (1440) and :280 (390), the terminal is absent (toHaveCount(1, {timeout:5000}) failed at both), so the measured dialog is the small status-only one; the h-[92vh] sm:h-[80vh] (attach-dialog.svelte:126) terminal-bearing dialog — the real 390px overflow risk — is measured by nothing, at any viewport. The containing-block canary still catches the #578 class (a header-sized backdrop fails regardless of dialog size), but min-h-0/flex-1 terminal geometry is uncovered.
  2. e2e/attach.spec.ts:290 computes scrollsInsideSelf: backdrop.scrollHeight >= backdrop.clientHeight and never asserts it (also cover at :285 is measured and unused); the test name's first half — "the dialog scrolls inside itself" — is unasserted. :297 toBeGreaterThan(300) at a 390px viewport is a weak floor for a w-full box.
  3. Untested + unreachable: postureFromStatus / the info === null branch of postureOf (posture.ts:129-131, :246-...), and STATUS_REFUSAL_KIND (posture.ts:113). All four production call sites pass a non-null info; no test calls postureOf at all. The map is load-bearing only for fixtures.test.ts:307-327.
  4. The API-client wire-to-model layer has no direct test. attachApi.info, isAttachAnswer and normaliseAttachAnswer (api/client.ts) were added without touching api/client.test.ts. The e2e does route stubbed payloads through the real client, which covers the posture-relevant fields, but the detail → reason fallback and the wiring list path are exercised only through injected probes that bypass the client entirely (e.g. session.svelte.test.ts "pre-bridge refusal body" constructs the merged object itself).
  5. formatBytes (format.ts:74, new export) has no unit test — format.test.ts does not import it; only two happy values (1024→1.0 kB, 2048→2.0 kB) are covered indirectly via attach-status.test.ts. Boundaries (0, negative, 999, 1000, 999_999, 1e6, non-finite) are untested.
  6. AttachRefusalKind (the TS union) is not actually pinned. fixtures.test.ts:286-305 compares Go's RefusalKind constants against a hand-copied literal list inside the test; a new Go kind plus an updated literal list leaves the union in types/attach.ts stale and green (the comment at :289 claims a compile error, but the union is only used as AttachRefusalKind | string, so it isn't load-bearing).
  7. Per-row posture coverage is 6 of 11. attach-cell.test.ts covers no_id, attachable, no_tty, disabled, unknown_sandbox, unreachable; the row-level rendering of unwired, unauthenticated, not_operator, invalid_terminal, dial_failed and generic refused is never asserted per row.

Suggestions:

  1. Extend fulfilInfoThenDisabled so the dialog's own probe also gets an attachable payload (e.g. stay attachable until a flag is flipped, or key on call count ≥ 2), then assert attach-terminal is visible and that a keystroke reaches the wire — that single change converts blocker 1 into the test the spec already claims to be.
  2. Add attach-dialog.svelte.test.ts with the existing FakeSocket seam (session.svelte.ts already takes an injected socket), asserting: terminal mounts on an open frame, keystrokes → sendInput (binary), a resize frame carries fit()'s geometry, and the terminal stays mounted through ended.
  3. Pin the two bounds in the parity guard (MaxTermDimension, and MaxControlFrameBytes — note Go spells it 4 << 10, so compare against a computed value or ask the Go side for a literal).
  4. Make session.svelte.test.ts:360 assert the refusal via String(MAX_TERM_DIMENSION) rather than the literal '1000' (the literal survives a change to 10000, since "10000-cell" contains "1000").
  5. Either delete postureFromStatus/STATUS_REFUSAL_KIND or unit-test the null-info branch; today it is untested scaffolding whose only consumer is the parity guard.
  6. Assert measured.scrollsInsideSelf in e2e/attach.spec.ts (and either rename the "terminal is usable at 390px" test or make it actually mount a terminal).
  7. e2e/run-server.sh defaults its binary to a shared ${TMPDIR:-/tmp}/ld-cockpit-e2e, which two concurrent worktree runs can clobber (pre-existing, not this PR — worth a follow-up).

Parity-guard falsification: Hard-fails in every shape I could construct; no silent skip. Two blind spots proven separately.

Isolated harness: git worktree add --detach /tmp/rev-1683 126af06 (node_modules symlinked from the author's worktree; temp worktrees removed afterwards), mutations applied to lab-director/cockpit/, run via npx vitest run src/lib/tests/fixtures.test.ts, restored with git checkout --:

mutation observed
baseline `Test Files 1 passed (1)
attach/attach.go renamed away FAIL src/lib/tests/fixtures.test.ts [ ... ] Error: attach fixture parity guard cannot read attach/attach.go — tried … (ENOENT), `Test Files 1 failed (1)
whole attach/ dir moved away same ENOENT error, exit 1
full suite with attach.go hidden `Test Files 1 failed
attach.go gutted to package attach `1 failed
Term gains `json:"cols"` 1 failed, exit 1
disabledReason reworded one word 1 failed, exit 1
CloseReasonStreamEOF value → stream_ended 1 failed, exit 1
registryWiring chip edited 1 failed, exit 1
RefusalUnknownSandbox 404 → 410 1 failed, exit 1
DefaultTerm 120x32 → 100x40 1 failed, exit 1
pattern no longer matches: DefaultTerm → defaultTerm 1 failed, exit 1
pattern no longer matches: registryWiring → registryGrants 1 failed, exit 1
pattern no longer matches (const type renamed, values untouched → zero items collected) `1 failed
registryWiring block moved to another file 1 failed, exit 1
RefusalNoTTY removed from the 200-override case 1 failed, exit 1
all restored 13 passed, exit 0

Negative control included so I did not over-claim: my first "pattern break" attempt (reformat DefaultTerm across lines) stayed green — the regex still matched and parsed the same 120/32, so green was correct, not a silent skip. Also verified the aggregate guard: npm test prints fixture parity guard: 13 tests collected (221 tests total) exit 0; with src/lib/tests/fixtures.test.ts renamed away, npm test exit 1 with Error: the fixture parity guard (…) collected 0 tests, expected at least 4. Note npx vitest run (the command I was asked to use) does not have that count floor — it relies on the collection error alone, which it does raise.
Blind spots: Go MaxTermDimension 1000→500 → 221/221 GREEN, exit 0; Go MaxControlFrameBytes 4<<10 → 1<<10 → 221/221 GREEN, exit 0.

Evidence:

  • git diff --name-status origin/epic/LD3..origin/plane/HOMELAB-1683-attach-ui → 26 files, --diff-filter=D empty (no test deleted); only pre-existing test file touched is src/lib/tests/fixtures.test.ts (additive only), plus the e2e helper e2e/dialog-viewport.ts (parameterised, CONFIRM_DIALOG default preserves both existing callers).
  • grep -nE '^\+.*(\.skip|\.only|fixme|xit\(|xdescribe\(|waitForTimeout|setTimeout)' over the whole diff → no matches.
  • Unit: baseline origin/epic/LD3 at the same checkout = 20 files / 145 tests passed; PR head = 25 files / 221 tests passed; delta +76 = 19 (protocol) + 27 (session) + 6 (cell) + 8 (launcher) + 7 (status) + 9 (new guard tests) — exact, so no pre-existing test was added, removed or weakened. Per-file JSON report confirms all 20 pre-existing files at their baseline counts.
  • e2e (real Go binary, dev mode, E2E_PORT off the shared 8080 to avoid reusing another session's server): full suite 62 passed (1.3m); e2e/attach.spec.ts alone 14 passed, zero skipped, forbidOnly honoured.
  • Mutation spot-checks (client, in /tmp/rev-1683): attachSocketUrl wss-on-https broken → RED (2 failed, session.svelte.test.ts handshake tests); disabled-posture reason replaced → RED (3 files, 3 failed, incl. attach-cell title ⊇ LD_ATTACH_ENABLED); no_tty branch removed → RED (3 files, 4 failed); openTerm fallback removed → RED (protocol.test.ts "falls back to the default"); showTerminal = false → GREEN everywhere (blocker 1); showTerminal = true → e2e RED (1 failed).
  • Honesty checks that passed: no fixture hand-rolls a mock a different way from the repo pattern — page.route(...).fulfill is how auth/gates-configured/panes already stub (grep page.route e2e/*.spec.ts); the flag-off 503 path is asserted end-to-end against the live binary (attach.spec.ts:105-151, including expect(answers[0]?.status).toBe(503) and body.audit_recorded === true read off the real response); attach-cell.test.ts waits on text/tone rather than tone alone (:118-121 comment is accurate); protocol tests assert concrete values, not shapes — I found no tautology among the binary/text split, encode/decode, resize bounds or close-reason vocabulary.
  • Environment note: I did not run anything in /home/aaron/repos/homelab-wt/LD3/infra-core — that worktree is dirty with another session's HOMELAB-1684 changes. All runs were in the clean -1683b worktree at 126af06 or in throwaway detached worktrees, which have been removed (git worktree list | grep -c rev- → 0); -1683b is clean at 126af06.
## Review 2 — test-adequacy lens (fresh context, `deepseek/deepseek-flash`, head 126af0611) Independent reviewer, no author context. **VERDICT: BLOCK** **Blockers:** 1. **The terminal — the thing this PR exists to render — is never mounted by any test, and its mount gate can be pinned to `false` while 221 unit + 14 e2e tests stay green.** - Gate: `lab-director/cockpit/web/src/lib/components/attach/attach-dialog.svelte:86` (`showTerminal`), consumed at `:126` (`h-[92vh]`) and `:177` (`{#if showTerminal}`). - `attach-dialog.svelte` has **no unit test file** (`src/lib/components/attach/` contains tests for cell/launcher/status only), and `grep -rn attach-terminal` over `src/` + `e2e/` finds exactly one assertion in the whole suite: `e2e/attach.spec.ts:177` `toHaveCount(0)`. - The e2e fixture makes the positive case unreachable: `fulfilInfoThenDisabled` (`e2e/attach.spec.ts:58-72`) returns *attachable* on call #1 and *disabled* thereafter, but the **launcher** consumes call #1, so the **dialog's own probe is call #2 = disabled** and the session refuses *before* ever reaching `state = 'connecting'` (`session.svelte.ts:285`). Instrumented observation (my temporary spec, MutationObserver with `attributeOldValue` on `[data-state]`, `saw` counter on `[data-testid=attach-terminal]`): `PROBE2-EVENTS ["state:idle->probing","state:idle->probing","state:probing->refused","state:probing->refused"]` and the terminal node count `{"saw":0}` — it is never inserted, not even transiently. So the e2e test's own name ("an attachable answer **enables the terminal**") and the spec comment ("so the frontend opens a terminal") describe something the spec cannot reach, and line 177 passes for the wrong reason (it does retain exactly one discriminating power: a gate that is true *too early*, verified — forcing `showTerminal = true` turns it red). - Falsified both directions: `showTerminal = $derived(false)` → **unit 221/221 GREEN, e2e 14/14 GREEN** (nothing detects it); `$derived(true)` → e2e RED (test 5). Therefore `attach-terminal.svelte` (178 lines: xterm mount + lazy chunk load, `term.onData → session.sendInput`, `session.onOutput → term.write`, ResizeObserver `fit() → session.resize`, theme token resolution, teardown) is **100% unverified**, as is `showTerminal`'s `opened !== null` and `live` legs. Why it matters: the ticket's acceptance ("operator attaches from the app into a live sandbox") is exactly the path with no assertion, and a one-token regression ships green. 2. **The fixture↔wire parity guard is silently blind to the two numeric wire bounds it claims to mirror.** - `src/lib/attach/protocol.ts:57` `MAX_TERM_DIMENSION = 1000` and `:62` `MAX_CONTROL_FRAME_BYTES = 4096` are hard-coded restatements of Go `attach.MaxTermDimension` (`../attach/attach.go:109`) and `attach.MaxControlFrameBytes` (`../attach/protocol.go:49`), and both doc-comments name the Go symbols. `fixtures.test.ts` reads `attach/attach.go`, `attach/bridge.go`, `attach/protocol.go` and pins tags, refusal kinds, `Status()`, both reason sentences, `registryWiring`, `DefaultTerm` and close reasons — **but never these two constants**; `protocol.test.ts:165-169,180-196` and `session.svelte.test.ts:360` only compare the constants against themselves. - Falsified: Go `MaxTermDimension = 1000 → 500` → **221/221 GREEN**; Go `MaxControlFrameBytes = 4<<10 → 1<<10` → **221/221 GREEN** (full suite, both times). A lowered Go bound is the direction that bites: the client would then *send* a resize the backend answers by closing the session with `protocol_error` — the precise failure `encodeResize` exists to prevent. Blast radius today is small (real panes ≲300 cols), but this is structurally the #578 pattern (a guard that doesn't guard) inside the guard that was written to close it, and the fix is ~6 lines in the same parser. **Concerns:** 1. **The #578 viewport guard is real geometry, but it never measures the dialog variant that can overflow.** `e2e/dialog-viewport.ts:112-123` asserts all four dialog edges plus `backdropTop === 0` / `backdropBottom === viewportHeight`, collected into one `toEqual([])` — a genuine geometric assertion, not a no-op. But instrumented at `e2e/attach.spec.ts:240` (1440) and `:280` (390), the terminal is absent (`toHaveCount(1, {timeout:5000})` failed at both), so the measured dialog is the *small status-only* one; the `h-[92vh] sm:h-[80vh]` (`attach-dialog.svelte:126`) terminal-bearing dialog — the real 390px overflow risk — is measured by nothing, at any viewport. The containing-block canary still catches the #578 class (a header-sized backdrop fails regardless of dialog size), but `min-h-0`/`flex-1` terminal geometry is uncovered. 2. **`e2e/attach.spec.ts:290` computes `scrollsInsideSelf: backdrop.scrollHeight >= backdrop.clientHeight` and never asserts it** (also `cover` at `:285` is measured and unused); the test name's first half — "the dialog scrolls inside itself" — is unasserted. `:297` `toBeGreaterThan(300)` at a 390px viewport is a weak floor for a `w-full` box. 3. **Untested + unreachable: `postureFromStatus` / the `info === null` branch of `postureOf`** (`posture.ts:129-131`, `:246-...`), and `STATUS_REFUSAL_KIND` (`posture.ts:113`). All four production call sites pass a non-null `info`; no test calls `postureOf` at all. The map is load-bearing only for `fixtures.test.ts:307-327`. 4. **The API-client wire-to-model layer has no direct test.** `attachApi.info`, `isAttachAnswer` and `normaliseAttachAnswer` (`api/client.ts`) were added without touching `api/client.test.ts`. The e2e does route stubbed payloads through the real client, which covers the posture-relevant fields, but the `detail → reason` fallback and the `wiring` list path are exercised only through injected probes that bypass the client entirely (e.g. `session.svelte.test.ts` "pre-bridge refusal body" constructs the merged object itself). 5. **`formatBytes` (`format.ts:74`, new export) has no unit test** — `format.test.ts` does not import it; only two happy values (`1024→1.0 kB`, `2048→2.0 kB`) are covered indirectly via `attach-status.test.ts`. Boundaries (0, negative, 999, 1000, 999_999, 1e6, non-finite) are untested. 6. **`AttachRefusalKind` (the TS union) is not actually pinned.** `fixtures.test.ts:286-305` compares Go's `RefusalKind` constants against a hand-copied literal list *inside the test*; a new Go kind plus an updated literal list leaves the union in `types/attach.ts` stale and green (the comment at `:289` claims a compile error, but the union is only used as `AttachRefusalKind | string`, so it isn't load-bearing). 7. **Per-row posture coverage is 6 of 11.** `attach-cell.test.ts` covers `no_id`, `attachable`, `no_tty`, `disabled`, `unknown_sandbox`, `unreachable`; the row-level rendering of `unwired`, `unauthenticated`, `not_operator`, `invalid_terminal`, `dial_failed` and generic `refused` is never asserted per row. **Suggestions:** 1. Extend `fulfilInfoThenDisabled` so the dialog's own probe also gets an attachable payload (e.g. stay attachable until a flag is flipped, or key on call count ≥ 2), then assert `attach-terminal` is visible and that a keystroke reaches the wire — that single change converts blocker 1 into the test the spec already claims to be. 2. Add `attach-dialog.svelte.test.ts` with the existing `FakeSocket` seam (`session.svelte.ts` already takes an injected `socket`), asserting: terminal mounts on an open frame, keystrokes → `sendInput` (binary), a resize frame carries `fit()`'s geometry, and the terminal stays mounted through `ended`. 3. Pin the two bounds in the parity guard (`MaxTermDimension`, and `MaxControlFrameBytes` — note Go spells it `4 << 10`, so compare against a computed value or ask the Go side for a literal). 4. Make `session.svelte.test.ts:360` assert the refusal via `String(MAX_TERM_DIMENSION)` rather than the literal `'1000'` (the literal survives a change to `10000`, since `"10000-cell"` contains `"1000"`). 5. Either delete `postureFromStatus`/`STATUS_REFUSAL_KIND` or unit-test the null-info branch; today it is untested scaffolding whose only consumer is the parity guard. 6. Assert `measured.scrollsInsideSelf` in `e2e/attach.spec.ts` (and either rename the "terminal is usable at 390px" test or make it actually mount a terminal). 7. `e2e/run-server.sh` defaults its binary to a shared `${TMPDIR:-/tmp}/ld-cockpit-e2e`, which two concurrent worktree runs can clobber (pre-existing, not this PR — worth a follow-up). **Parity-guard falsification:** *Hard-fails in every shape I could construct; no silent skip. Two blind spots proven separately.* Isolated harness: `git worktree add --detach /tmp/rev-1683 126af06` (node_modules symlinked from the author's worktree; temp worktrees removed afterwards), mutations applied to `lab-director/cockpit/`, run via `npx vitest run src/lib/tests/fixtures.test.ts`, restored with `git checkout --`: | mutation | observed | |---|---| | baseline | `Test Files 1 passed (1) | Tests 13 passed (13)`, exit 0 | | `attach/attach.go` renamed away | `FAIL src/lib/tests/fixtures.test.ts [ ... ] Error: attach fixture parity guard cannot read attach/attach.go — tried … (ENOENT)`, `Test Files 1 failed (1) | Tests no tests`, **exit 1** | | whole `attach/` dir moved away | same ENOENT error, **exit 1** | | full suite with `attach.go` hidden | `Test Files 1 failed | 24 passed (25) Tests 208 passed (208)`, **exit 1** | | `attach.go` gutted to `package attach` | `1 failed | 12 passed` (4 failed assertions), exit 1 | | `Term` gains `` `json:"cols"` `` | 1 failed, exit 1 | | `disabledReason` reworded one word | 1 failed, exit 1 | | `CloseReasonStreamEOF` value → `stream_ended` | 1 failed, exit 1 | | `registryWiring` chip edited | 1 failed, exit 1 | | `RefusalUnknownSandbox` 404 → 410 | 1 failed, exit 1 | | `DefaultTerm` 120x32 → 100x40 | 1 failed, exit 1 | | pattern no longer matches: `DefaultTerm` → `defaultTerm` | 1 failed, exit 1 | | pattern no longer matches: `registryWiring` → `registryGrants` | 1 failed, exit 1 | | pattern no longer matches (const type renamed, values untouched → **zero items collected**) | `1 failed | 12 passed` (2 failed), exit 1 | | registryWiring block moved to another file | 1 failed, exit 1 | | `RefusalNoTTY` removed from the 200-override `case` | 1 failed, exit 1 | | all restored | 13 passed, exit 0 | Negative control included so I did not over-claim: my first "pattern break" attempt (reformat `DefaultTerm` across lines) stayed **green** — the regex still matched and parsed the same `120/32`, so green was correct, not a silent skip. Also verified the aggregate guard: `npm test` prints `fixture parity guard: 13 tests collected (221 tests total)` exit 0; with `src/lib/tests/fixtures.test.ts` renamed away, `npm test` **exit 1** with `Error: the fixture parity guard (…) collected 0 tests, expected at least 4`. Note `npx vitest run` (the command I was asked to use) does *not* have that count floor — it relies on the collection error alone, which it does raise. Blind spots: Go `MaxTermDimension 1000→500` → **221/221 GREEN, exit 0**; Go `MaxControlFrameBytes 4<<10 → 1<<10` → **221/221 GREEN, exit 0**. **Evidence:** - `git diff --name-status origin/epic/LD3..origin/plane/HOMELAB-1683-attach-ui` → 26 files, `--diff-filter=D` **empty** (no test deleted); only pre-existing *test* file touched is `src/lib/tests/fixtures.test.ts` (additive only), plus the e2e helper `e2e/dialog-viewport.ts` (parameterised, `CONFIRM_DIALOG` default preserves both existing callers). - `grep -nE '^\+.*(\.skip|\.only|fixme|xit\(|xdescribe\(|waitForTimeout|setTimeout)'` over the whole diff → **no matches**. - Unit: baseline `origin/epic/LD3` at the same checkout = **20 files / 145 tests passed**; PR head = **25 files / 221 tests passed**; delta **+76 = 19 (protocol) + 27 (session) + 6 (cell) + 8 (launcher) + 7 (status) + 9 (new guard tests)** — exact, so no pre-existing test was added, removed or weakened. Per-file JSON report confirms all 20 pre-existing files at their baseline counts. - e2e (real Go binary, dev mode, `E2E_PORT` off the shared 8080 to avoid reusing another session's server): full suite **62 passed (1.3m)**; `e2e/attach.spec.ts` alone **14 passed**, zero skipped, `forbidOnly` honoured. - Mutation spot-checks (client, in `/tmp/rev-1683`): `attachSocketUrl` wss-on-https broken → RED (2 failed, `session.svelte.test.ts` handshake tests); disabled-posture reason replaced → RED (3 files, 3 failed, incl. `attach-cell` `title` ⊇ `LD_ATTACH_ENABLED`); `no_tty` branch removed → RED (3 files, 4 failed); `openTerm` fallback removed → RED (`protocol.test.ts` "falls back to the default"); `showTerminal = false` → **GREEN everywhere** (blocker 1); `showTerminal = true` → e2e RED (1 failed). - Honesty checks that passed: no fixture hand-rolls a mock a different way from the repo pattern — `page.route(...).fulfill` is how `auth/gates-configured/panes` already stub (`grep page.route e2e/*.spec.ts`); the flag-off **503 path is asserted end-to-end against the live binary** (`attach.spec.ts:105-151`, including `expect(answers[0]?.status).toBe(503)` and `body.audit_recorded === true` read off the real response); `attach-cell.test.ts` waits on text/tone rather than tone alone (`:118-121` comment is accurate); protocol tests assert concrete values, not shapes — I found no tautology among the binary/text split, encode/decode, resize bounds or close-reason vocabulary. - Environment note: I did **not** run anything in `/home/aaron/repos/homelab-wt/LD3/infra-core` — that worktree is dirty with another session's HOMELAB-1684 changes. All runs were in the clean `-1683b` worktree at `126af06` or in throwaway detached worktrees, which have been removed (`git worktree list | grep -c rev-` → 0); `-1683b` is clean at `126af06`.
HOMELAB-1683: fix(cockpit-web): review blockers — byte accounting, refused-vs-unreachable, terminal mount coverage, guard bounds
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m1s
CI Review / dispatcher-test (pull_request) Successful in 2m28s
CI Review / cockpit-test (pull_request) Successful in 2m43s
CI Review / cockpit-e2e (pull_request) Successful in 2m54s
d581628a66
Four landing-review blockers from PR #584's dual fresh-context review, all in
lab-director/cockpit/web/**.

1. Byte accounting. `emit()` added every server→client frame to bytesOut while
   its caller also added the same bytes to bytesIn, and `sendInput` counted
   nothing — so the strip's pair was always equal (probe: 13 out + 3 in →
   in=13 out=13) and keystrokes were counted nowhere. Output is now counted once
   on the out side, input once on the in side (and only when actually sent), the
   status strip prints the close frame's authoritative totals when there is one
   and labels a tab-local count as such, and the tests assert a mixed in/out
   probe exactly.

2. 401/403 refusals. `isAttachAnswer` did not recognise the auth helper's
   Problem envelope, so an audited identity refusal threw and rendered as
   "unreachable · API error 401" with the server's sentence dropped. The envelope
   is now an answer at 401/403 (only those: a 5xx stays a failure), the sentence
   is rendered verbatim, and `audit_recorded`'s ABSENCE is a third value
   ('unknown') rather than a false claim about the ledger. WS-handshake refusals
   reach the same first-class refused state through the re-probe.

3. Terminal mount coverage. No test mounted the terminal in either direction
   (`showTerminal = false` left 221 unit + 14 e2e green). The e2e suite now
   serves a live session over a routed WebSocket: the dialog goes live, the
   terminal mounts, xterm renders the bytes the fixture sent, and the
   terminal-bearing dialog is geometry-checked at 390px.

4. Parity guard bounds. `MAX_TERM_DIMENSION` / `MAX_CONTROL_FRAME_BYTES` were
   pinned nowhere; lowering either in the Go left the suite green. The guard now
   parses both declarations (including `4 << 10`) out of the Go source and
   hard-fails on mismatch or on a bound it cannot read.

Verified: svelte-check 0 errors/0 warnings; vitest 26 files, 249 tests (guard 17);
build ok; Playwright 64 passed (attach.spec 14 → 16).
Author
Owner

Fix worker — all 4 landing blockers addressed (d581628a66)

Not a review verdict; the landing worker still owns the fresh-context re-review on this head.

1. Byte accounting. emit() counted every output frame into bytesOut while its caller counted the same bytes into bytesIn, and sendInput counted nothing. Output is now counted once (out), input once (in) and only when actually sent. The status strip prints the close frame's authoritative bytes_in/bytes_out when there is one (data-accounting-source="close-frame") and labels a tab-local count as such. Tests: a mixed probe — 13 output bytes then 3 input bytes — now asserts in=0/out=13, then in=3/out=13, then [in,out]=[4,18] after a second frame each, and again from the end state a client-ended session writes.

  • Mutation proof: reintroducing the reviewed code → session.svelte.test.ts 3 failed / 30 passed, AssertionError: output is not input: expected 13 to be +0.
  • Mutation proof (UI label): strip back on the session counters → the closed-session component test fails (1 failed / 8 passed).

2. 401/403 refused ≠ unreachable. attachApi.info now accepts the auth helper's Problem envelope ({code, detail}) as an answer at 401/403 only — a 5xx carrying the same shape still throws, so a failing cockpit is not dressed as a refusal. The server's detail sentence is rendered verbatim; the WS-handshake path reaches the same first-class refused state through the re-probe. Because the envelope carries no audit_recorded, that fact is now three-valued ('unknown') instead of rendering "this refusal was NOT recorded" about a refusal the backend audited. Tests: envelope 401/403 through the real client, a thrown ApiError 401/403 vs 502 vs TypeError, and the refused-upgrade re-probe (200 attachable → 401 envelope).

3. Terminal mount coverage. New e2e: page.routeWebSocket serves a real session (open frame → dialog live → terminal mounts → xterm's .xterm-rows renders the bytes the fixture sent), plus the terminal-bearing dialog is geometry-checked inside a 390px viewport.

  • Mutation proof: showTerminal = $derived(false) → 2 failed / 14 passed (both new specs fail on attach-terminal count 0). The reviewed blind spot was 14/14 green.

4. Parity guard bounds. fixtures.test.ts now parses MaxTermDimension and MaxControlFrameBytes (expression evaluator, so 4 << 10 is read rather than restated) out of the Go and compares them to the client constants; an unreadable/renamed bound throws.

  • Mutation proof: Go MaxTermDimension 1000→500 → guard red; Go MaxControlFrameBytes 4<<10 → 1<<10 → guard red (expected 4096 to be 1024). Both were green before.

Suites on this head: svelte-check 0 errors / 0 warnings · vitest 26 files, 249 tests passed (guard 17 collected; was 25/221) · build ok · Playwright 64 passed (attach.spec 14 → 16). Scope: lab-director/cockpit/web/** only, no Go changes.

Observed gap, NOT fixed here (reported, not silently touched): the terminal subscribes to session.onOutput only after its lazily-imported xterm chunks resolve, and the session fans output out to whatever sinks exist at that instant — so output the backend sends immediately after the open frame can be dropped before the terminal finishes loading. My first e2e run reproduced it (session live, terminal mounted, banner frame sent at connect time → empty .xterm-rows); the test now sends after mount and says so in a comment. Worth its own ticket (bounded pre-subscription buffer), out of this fix set.

## Fix worker — all 4 landing blockers addressed (`d581628a66`) Not a review verdict; the landing worker still owns the fresh-context re-review on this head. **1. Byte accounting.** `emit()` counted every output frame into `bytesOut` while its caller counted the same bytes into `bytesIn`, and `sendInput` counted nothing. Output is now counted once (out), input once (in) and only when actually sent. The status strip prints the **close frame's** authoritative `bytes_in`/`bytes_out` when there is one (`data-accounting-source="close-frame"`) and labels a tab-local count as such. Tests: a mixed probe — 13 output bytes then 3 input bytes — now asserts `in=0/out=13`, then `in=3/out=13`, then `[in,out]=[4,18]` after a second frame each, and again from the end state a client-ended session writes. - Mutation proof: reintroducing the reviewed code → `session.svelte.test.ts` 3 failed / 30 passed, `AssertionError: output is not input: expected 13 to be +0`. - Mutation proof (UI label): strip back on the session counters → the closed-session component test fails (1 failed / 8 passed). **2. 401/403 refused ≠ unreachable.** `attachApi.info` now accepts the auth helper's Problem envelope (`{code, detail}`) as an answer at **401/403 only** — a 5xx carrying the same shape still throws, so a failing cockpit is not dressed as a refusal. The server's `detail` sentence is rendered verbatim; the WS-handshake path reaches the same first-class refused state through the re-probe. Because the envelope carries no `audit_recorded`, that fact is now three-valued (`'unknown'`) instead of rendering "this refusal was NOT recorded" about a refusal the backend audited. Tests: envelope 401/403 through the real client, a thrown `ApiError` 401/403 vs 502 vs `TypeError`, and the refused-upgrade re-probe (200 attachable → 401 envelope). **3. Terminal mount coverage.** New e2e: `page.routeWebSocket` serves a real session (open frame → dialog `live` → terminal mounts → xterm's `.xterm-rows` renders the bytes the fixture sent), plus the terminal-bearing dialog is geometry-checked inside a 390px viewport. - Mutation proof: `showTerminal = $derived(false)` → **2 failed / 14 passed** (both new specs fail on `attach-terminal` count 0). The reviewed blind spot was 14/14 green. **4. Parity guard bounds.** `fixtures.test.ts` now parses `MaxTermDimension` and `MaxControlFrameBytes` (expression evaluator, so `4 << 10` is read rather than restated) out of the Go and compares them to the client constants; an unreadable/renamed bound throws. - Mutation proof: Go `MaxTermDimension 1000→500` → guard red; Go `MaxControlFrameBytes 4<<10 → 1<<10` → guard red (`expected 4096 to be 1024`). Both were green before. **Suites on this head:** svelte-check 0 errors / 0 warnings · vitest 26 files, **249 tests passed** (guard 17 collected; was 25/221) · build ok · Playwright **64 passed** (attach.spec 14 → 16). Scope: `lab-director/cockpit/web/**` only, no Go changes. **Observed gap, NOT fixed here (reported, not silently touched):** the terminal subscribes to `session.onOutput` only after its lazily-imported xterm chunks resolve, and the session fans output out to whatever sinks exist at that instant — so output the backend sends immediately after the open frame can be dropped before the terminal finishes loading. My first e2e run reproduced it (session `live`, terminal mounted, banner frame sent at connect time → empty `.xterm-rows`); the test now sends after mount and says so in a comment. Worth its own ticket (bounded pre-subscription buffer), out of this fix set.
Author
Owner

Independent verification of head d581628 (fresh-context fix worker)

A second fix worker (dispatched for the same four landing blockers) checked out this head in a separate worktree and re-ran the mutation proofs against the LANDED tree. Result: all four blockers are genuinely covered; CI runs #1738 / #1739 are green on d581628.

Blocker 3 — the terminal is now mounted by a test (mutation-verified).
e2e/attach.spec.ts:358 (the terminal itself › a live session MOUNTS the terminal and renders the bytes the sandbox sent) with showTerminal = $derived(false):

✘ 1 [chromium] › e2e/attach.spec.ts:358:2 › a live session MOUNTS the terminal and renders the bytes the sandbox sent (29.3s)
  Error: expect(locator).toHaveCount(expected) failed
    Expected: 1  —  Expect "toHaveCount" getByTestId('attach-terminal') with timeout 10000ms
> 373 |  await expect(host).toHaveCount(1);

Restored: ✓ 1 … (1.2s). The test asserts a real .xterm subtree and .xterm-rows text, so an empty host div cannot satisfy it.

Blocker 4 — the parity guard is no longer blind to the Go bounds (mutation-verified).
const MaxTermDimension = 1000 → 500 in attach/attach.go:

× pins the two numeric bounds the client restates from the Go source
  AssertionError: attach.MaxTermDimension: expected 1000 to be 500

const MaxControlFrameBytes = 4 << 10 → 1 << 10 in attach/protocol.go:

× pins the two numeric bounds the client restates from the Go source
  AssertionError: attach.MaxControlFrameBytes: expected 4096 to be 1024

Both hard-fail (no skip), restored green.

Blockers 1 and 2 (read-only review of this head, not re-mutated here). session.svelte.ts counts output only in the emit path, counts input only in sendInput once the frame is on the wire, and endOfClose carries the close frame's totals with an explicit accountingSource: 'close-frame'; client.ts:274 accepts the 401/403 Problem envelope as an answer via isIdentityRefusal, with posture.test.ts covering the refused-vs-unreachable split.

Process note for the landing worker: this PR's branch received two independent implementations of the same four fixes (only one, d581628, is pushed; the second exists as a local patch and was deliberately not stacked). Please review d581628 on its own merits — the commit message appears once, so no history rewrite is needed.

Note for any re-run of these checks: ports 8080 and 8099 were occupied by other sessions' servers (and a reviewer binary serving the pre-fix build). A webServer with reuseExistingServer will silently test a stale build — use an isolated E2E_PORT.

## Independent verification of head `d581628` (fresh-context fix worker) A second fix worker (dispatched for the same four landing blockers) checked out this head in a separate worktree and re-ran the mutation proofs against the LANDED tree. Result: all four blockers are genuinely covered; CI runs #1738 / #1739 are green on `d581628`. **Blocker 3 — the terminal is now mounted by a test (mutation-verified).** `e2e/attach.spec.ts:358` (`the terminal itself › a live session MOUNTS the terminal and renders the bytes the sandbox sent`) with `showTerminal = $derived(false)`: ``` ✘ 1 [chromium] › e2e/attach.spec.ts:358:2 › a live session MOUNTS the terminal and renders the bytes the sandbox sent (29.3s) Error: expect(locator).toHaveCount(expected) failed Expected: 1 — Expect "toHaveCount" getByTestId('attach-terminal') with timeout 10000ms > 373 | await expect(host).toHaveCount(1); ``` Restored: `✓ 1 … (1.2s)`. The test asserts a real `.xterm` subtree and `.xterm-rows` text, so an empty host div cannot satisfy it. **Blocker 4 — the parity guard is no longer blind to the Go bounds (mutation-verified).** `const MaxTermDimension = 1000 → 500` in `attach/attach.go`: ``` × pins the two numeric bounds the client restates from the Go source AssertionError: attach.MaxTermDimension: expected 1000 to be 500 ``` `const MaxControlFrameBytes = 4 << 10 → 1 << 10` in `attach/protocol.go`: ``` × pins the two numeric bounds the client restates from the Go source AssertionError: attach.MaxControlFrameBytes: expected 4096 to be 1024 ``` Both hard-fail (no skip), restored green. **Blockers 1 and 2 (read-only review of this head, not re-mutated here).** `session.svelte.ts` counts output only in the emit path, counts input only in `sendInput` once the frame is on the wire, and `endOfClose` carries the close frame's totals with an explicit `accountingSource: 'close-frame'`; `client.ts:274` accepts the 401/403 Problem envelope as an answer via `isIdentityRefusal`, with `posture.test.ts` covering the refused-vs-unreachable split. **Process note for the landing worker:** this PR's branch received two independent implementations of the same four fixes (only one, `d581628`, is pushed; the second exists as a local patch and was deliberately not stacked). Please review `d581628` on its own merits — the commit message appears once, so no history rewrite is needed. Note for any re-run of these checks: ports 8080 and 8099 were occupied by other sessions' servers (and a reviewer binary serving the pre-fix build). A `webServer` with `reuseExistingServer` will silently test a stale build — use an isolated `E2E_PORT`.
Author
Owner

Landing verdict — delta review of head d581628 (fresh context) + class-D visual

Landing worker, fresh reviewer process (deepseek/deepseek-flash, no author context, no prior
review context), brief built from primary sources only: the delta 126af0611..d581628, the
ticket acceptance, the repo's frozen contracts. Reviewer ran the delta's own suite in a
throwaway worktree at d581628: 249 unit / 26 files · svelte-check 0/0 · 16 e2e specs
passed
. It ran 9 mutations of its own, and the landing worker independently re-ran the
four closure mutations (below).

VERDICT: PASS-WITH-CONCERNS — no blocker findings.

Fix closure (both reviewer and landing worker verified, independently)

  1. Byte accounting — REAL. emit() is the only writer of bytesOut (session.svelte.ts:231);
    the data branch no longer touches bytesIn; bytesIn moves after the wire in sendInput
    (:594-599); a close frame sets the frame's own bytes_in/bytes_out with
    accountingSource: 'close-frame' (:449-455); the strip prefers the end state's pair and
    labels the source (attach-status.svelte:60-73,87-105). Landing mutation: restoring
    bytesIn += message.bytes.byteLength in the data branch → 2 tests RED (expected 15 to be +0,
    output is not input: expected 13 to be +0); restored GREEN.
  2. 401/403 → REFUSED — REAL. isIdentityRefusal accepts {code, detail} at exactly 401/403
    (client.ts:341) and is wired into the answer test (:274); audit_recorded is genuinely
    optional (:389, types/attach.ts:104); auditRecordOf(undefined) === 'unknown'
    (posture.ts:57-61); probeFailure is the single classifier (posture.ts:218) and all
    four
    old unreachablePosture call sites use it (session.svelte.ts:305,556,
    attach-cell.svelte:79, attach-launcher.svelte:74). 5xx and a thrown TypeError still land
    in failed/unreachable. Landing mutation: isIdentityRefusal → false → 2 tests RED
    (client + session); restored GREEN.
  3. Terminal-mount e2e — REAL and discriminating. e2e/attach.spec.ts:358 asserts .xterm
    inside attach-terminal plus .xterm-rows carrying the fixture's bytes; :394 geometry-checks
    the terminal-bearing dialog at 390px. Landing mutation: showTerminal = $derived(false) →
    exactly specs 13/14 RED (2 failed, 14 passed), restored GREEN (2 passed).
  4. Go bounds guard — REAL. goIntConst/evaluateGoInt (fixtures.test.ts:257-272) parse the
    declarations; the pin test (:514) reads MaxTermDimension (1000) and MaxControlFrameBytes = 4 << 10.
    Landing mutations: 1000 → 500 → RED (attach.MaxTermDimension: expected 1000 to be 500);
    4 << 10 → 1 << 10 → RED (attach.MaxControlFrameBytes: expected 4096 to be 1024); both restored GREEN.

Findings accepted as concerns (non-blocking, carried — not silently dropped)

  • accountingSource has a false green from its producer. Deleting accountingSource: 'close-frame'
    from endOfClose leaves all 249 unit + 16 e2e specs green (the flag is only asserted against a
    hand-built end). The landing visual closes this seam — a fixture close frame drives the strip to
    data-accounting-source="close-frame", data-bytes-in="5", data-bytes-out="40", 5 B in · 40 B out · 4s
    with the provenance tooltip — but that spec is uncommitted. Recommend a one-line assertion in
    session.svelte.test.ts (expect(session.end?.accountingSource).toBe('close-frame'), and toBeUndefined()
    for client_close).
  • probeFailure classifies 401/403 by status alone, so a non-envelope 401/403 (an intermediary in
    front of the cockpit) renders a client-authored sentence and the ledger claim "The attempt is recorded."
    The reviewer could not verify the cockpit's ingress from this repo and escalated conditionally.
    Landing worker resolved the condition: the cockpit has no exposure definition anywhere in prod
    (no manifests/gateway/routes/* entry, no Pangolin resource, no Deployment — the namespace is the only
    cockpit-adjacent manifest), so no intermediary can answer 401/403 for /api/v1/attach/*/info today.
    This becomes live when the cockpit gets a gateway/Pangolin route; the reviewer's hardening (gate the
    classification on error.code/detail, or stop inventing the sentence and drop the "The attempt is
    recorded." claim) should land with that exposure.
  • Retained from the pre-fix reviews, unchanged by this delta and explicitly non-blocking there:
    endOfError still hardcodes recorded: true for an error frame whose Go struct carries no audit flag
    (session.svelte.ts:465); start() still never tears down a replaced socket (no unmount teardown);
    unreachablePosture hardcodes auditRecorded: false under the new tri-state doctrine (inert/unrendered);
    two stale doc comments (probeFailure's "the cockpit ANSWERING", attach-cell's two-outcome list);
    the accountingSource explanation lives only in a title attribute (touch/keyboard operators get the
    numbers without the "which").
  • Pre-existing and disclosed: output frames arriving before attach-terminal's lazy import resolves are
    dropped (no buffering in emit/sinks).

Class-D visual (built SPA + real Go binary at d581628, isolated port 8137)

10/10 assertions passed; screenshots in .rpiv/artifacts/build/ld3-1683b-landing2-screens/, measurements in
evidence.json.

  • Postures. enabled → chip attachable + "open terminal" (01); no-TTY → chip no tty, disabled, backend
    reason "the "browser" class has no TTY harness…" (02); disabled (LIVE server) → chip attach disabled,
    P-ATT-001, the real 503 sentence verbatim (03). Wave-table cell renders disabled with the reason as the
    title tooltip on both button and chip (10).
  • Refused state, verbatim sentence. Live 503 through the dialog → "attach disabled · disabled" + the
    server's own sentence + "P-ATT-001 refused and recorded in the attach ledger", no terminal element drawn
    (04). Identity 401 → "attach refused · unauthenticated", "Sign in at /auth/login to use the cockpit."
    (server's sentence), and the tri-state honesty row "the reply does not say whether this refusal was recorded
    in the attach ledger" (05) — the exact defect the first review found, now unrenderable.
  • Terminal dialog. xterm really mounts and renders the fixture's bytes: .xterm + .xterm-rows text
    "pi v0.4 ready — sandbox ld-light-1" at 1440 (06, dialog 896×720 inside 1440×900, top 90) and at 390
    (07, 374×776.5 inside 390×844, top 59.5); expectDialogFullyInViewport clean at both, backdrop exactly
    viewport-height, scrollWidth === clientWidth.
  • Status strip accounting. After a fixture session whose close frame carries the ledger's own totals, the
    strip prints 5 B in · 40 B out · 4s attributed to close-frame (08) — separated directions, authoritative
    totals, provenance labelled. (A client_close never shows a this-tab row on screen: dismiss() ends the
    session and unmounts the dialog in one action — recorded as an observation, not a defect.)

Merging on green CI (#1738/#1739 on d581628) + this verdict.

## Landing verdict — delta review of head `d581628` (fresh context) + class-D visual Landing worker, fresh reviewer process (`deepseek/deepseek-flash`, no author context, no prior review context), brief built from primary sources only: the delta `126af0611..d581628`, the ticket acceptance, the repo's frozen contracts. Reviewer ran the delta's own suite in a throwaway worktree at `d581628`: **249 unit / 26 files · svelte-check 0/0 · 16 e2e specs passed**. It ran **9 mutations** of its own, and the landing worker independently re-ran the four closure mutations (below). **VERDICT: PASS-WITH-CONCERNS — no blocker findings.** ### Fix closure (both reviewer and landing worker verified, independently) 1. **Byte accounting — REAL.** `emit()` is the only writer of `bytesOut` (`session.svelte.ts:231`); the data branch no longer touches `bytesIn`; `bytesIn` moves after the wire in `sendInput` (`:594-599`); a close frame sets the frame's own `bytes_in`/`bytes_out` with `accountingSource: 'close-frame'` (`:449-455`); the strip prefers the end state's pair and labels the source (`attach-status.svelte:60-73,87-105`). *Landing mutation:* restoring `bytesIn += message.bytes.byteLength` in the data branch → 2 tests RED (`expected 15 to be +0`, `output is not input: expected 13 to be +0`); restored GREEN. 2. **401/403 → REFUSED — REAL.** `isIdentityRefusal` accepts `{code, detail}` at exactly 401/403 (`client.ts:341`) and is wired into the answer test (`:274`); `audit_recorded` is genuinely optional (`:389`, `types/attach.ts:104`); `auditRecordOf(undefined) === 'unknown'` (`posture.ts:57-61`); `probeFailure` is the single classifier (`posture.ts:218`) and **all four** old `unreachablePosture` call sites use it (`session.svelte.ts:305,556`, `attach-cell.svelte:79`, `attach-launcher.svelte:74`). 5xx and a thrown `TypeError` still land in `failed`/`unreachable`. *Landing mutation:* `isIdentityRefusal → false` → 2 tests RED (client + session); restored GREEN. 3. **Terminal-mount e2e — REAL and discriminating.** `e2e/attach.spec.ts:358` asserts `.xterm` inside `attach-terminal` plus `.xterm-rows` carrying the fixture's bytes; `:394` geometry-checks the terminal-bearing dialog at 390px. *Landing mutation:* `showTerminal = $derived(false)` → exactly specs 13/14 RED (2 failed, 14 passed), restored GREEN (2 passed). 4. **Go bounds guard — REAL.** `goIntConst`/`evaluateGoInt` (`fixtures.test.ts:257-272`) parse the declarations; the pin test (`:514`) reads `MaxTermDimension` (1000) and `MaxControlFrameBytes = 4 << 10`. *Landing mutations:* `1000 → 500` → RED (`attach.MaxTermDimension: expected 1000 to be 500`); `4 << 10 → 1 << 10` → RED (`attach.MaxControlFrameBytes: expected 4096 to be 1024`); both restored GREEN. ### Findings accepted as concerns (non-blocking, carried — not silently dropped) - **`accountingSource` has a false green from its producer.** Deleting `accountingSource: 'close-frame'` from `endOfClose` leaves all 249 unit + 16 e2e specs green (the flag is only asserted against a hand-built `end`). The **landing visual closes this seam** — a fixture close frame drives the strip to `data-accounting-source="close-frame"`, `data-bytes-in="5"`, `data-bytes-out="40"`, `5 B in · 40 B out · 4s` with the provenance tooltip — but that spec is uncommitted. Recommend a one-line assertion in `session.svelte.test.ts` (`expect(session.end?.accountingSource).toBe('close-frame')`, and `toBeUndefined()` for `client_close`). - **`probeFailure` classifies 401/403 by status alone**, so a *non-envelope* 401/403 (an intermediary in front of the cockpit) renders a client-authored sentence and the ledger claim "The attempt is recorded." The reviewer could not verify the cockpit's ingress from this repo and escalated conditionally. **Landing worker resolved the condition:** the cockpit has *no* exposure definition anywhere in `prod` (no `manifests/gateway/routes/*` entry, no Pangolin resource, no Deployment — the namespace is the only cockpit-adjacent manifest), so no intermediary can answer 401/403 for `/api/v1/attach/*/info` today. This becomes live when the cockpit gets a gateway/Pangolin route; the reviewer's hardening (gate the classification on `error.code`/`detail`, or stop inventing the sentence and drop the "The attempt is recorded." claim) should land with that exposure. - Retained from the pre-fix reviews, unchanged by this delta and explicitly non-blocking there: `endOfError` still hardcodes `recorded: true` for an `error` frame whose Go struct carries no audit flag (`session.svelte.ts:465`); `start()` still never tears down a replaced socket (no unmount teardown); `unreachablePosture` hardcodes `auditRecorded: false` under the new tri-state doctrine (inert/unrendered); two stale doc comments (`probeFailure`'s "the cockpit ANSWERING", `attach-cell`'s two-outcome list); the `accountingSource` explanation lives only in a `title` attribute (touch/keyboard operators get the numbers without the "which"). - Pre-existing and disclosed: output frames arriving before `attach-terminal`'s lazy import resolves are dropped (no buffering in `emit`/`sinks`). ### Class-D visual (built SPA + real Go binary at `d581628`, isolated port 8137) 10/10 assertions passed; screenshots in `.rpiv/artifacts/build/ld3-1683b-landing2-screens/`, measurements in `evidence.json`. - **Postures.** enabled → chip `attachable` + "open terminal" (`01`); no-TTY → chip `no tty`, disabled, backend reason "the \"browser\" class has no TTY harness…" (`02`); disabled (LIVE server) → chip `attach disabled`, `P-ATT-001`, the real 503 sentence verbatim (`03`). Wave-table cell renders disabled with the reason as the `title` tooltip on both button and chip (`10`). - **Refused state, verbatim sentence.** Live 503 through the dialog → "attach disabled · disabled" + the server's own sentence + "P-ATT-001 refused and recorded in the attach ledger", **no terminal element drawn** (`04`). Identity 401 → "attach refused · unauthenticated", "Sign in at /auth/login to use the cockpit." (server's sentence), and the tri-state honesty row "the reply does not say whether this refusal was recorded in the attach ledger" (`05`) — the exact defect the first review found, now unrenderable. - **Terminal dialog.** xterm really mounts and renders the fixture's bytes: `.xterm` + `.xterm-rows` text "pi v0.4 ready — sandbox ld-light-1" at 1440 (`06`, dialog 896×720 inside 1440×900, top 90) and at 390 (`07`, 374×776.5 inside 390×844, top 59.5); `expectDialogFullyInViewport` clean at both, backdrop exactly viewport-height, `scrollWidth === clientWidth`. - **Status strip accounting.** After a fixture session whose close frame carries the ledger's own totals, the strip prints `5 B in · 40 B out · 4s` attributed to `close-frame` (`08`) — separated directions, authoritative totals, provenance labelled. (A `client_close` never shows a `this-tab` row on screen: `dismiss()` ends the session and unmounts the dialog in one action — recorded as an observation, not a defect.) Merging on green CI (#1738/#1739 on `d581628`) + this verdict.
Author
Owner

Blocker closure on d581628 — all four verified fixed

resolved 1 — byte accounting (session.svelte.ts:231 emit is the only bytesOut writer; :594-599 sendInput counts input after the wire; :449-455 close frame carries the frame's own totals with accountingSource: 'close-frame'; attach-status.svelte:60-73,87-105 labels the source). Closure mutation: restoring the pre-fix double count → 2 tests RED, restored GREEN.

resolved 2 — 401/403 rendered as REFUSED with the backend's verbatim sentence (client.ts:274,341 accepts the {code,detail} envelope at exactly 401/403; posture.ts:57-61 auditRecordOf gives a real third value 'unknown'; posture.ts:218 probeFailure is the single classifier, all four old unreachablePosture call sites converted). Closure mutation: isIdentityRefusal → false → 2 tests RED, restored GREEN.

resolved 3 — the terminal is mounted by a test and the test discriminates (e2e/attach.spec.ts:358 asserts real .xterm + .xterm-rows text; :394 re-checks the 390px geometry with the terminal present). Closure mutation: showTerminal = $derived(false) → exactly 2 specs RED, restored GREEN.

resolved 4 — the parity guard pins the Go bounds (fixtures.test.ts:257-272 parses the declarations, :514 pins both). Closure mutations: Go MaxTermDimension 1000 → 500 → RED; MaxControlFrameBytes 4<<10 → 1<<10 → RED; both restored GREEN.

All four blockers are fixed and independently mutation-verified on this head, and the landing delta review on d581628 found none open. The verdict is posted above; the accepted non-blocking concerns and the resolved ingress-escalation question are recorded there.

## Blocker closure on `d581628` — all four verified fixed resolved 1 — byte accounting (`session.svelte.ts:231` emit is the only `bytesOut` writer; `:594-599` `sendInput` counts input after the wire; `:449-455` close frame carries the frame's own totals with `accountingSource: 'close-frame'`; `attach-status.svelte:60-73,87-105` labels the source). Closure mutation: restoring the pre-fix double count → 2 tests RED, restored GREEN. resolved 2 — 401/403 rendered as REFUSED with the backend's verbatim sentence (`client.ts:274,341` accepts the `{code,detail}` envelope at exactly 401/403; `posture.ts:57-61` `auditRecordOf` gives a real third value `'unknown'`; `posture.ts:218` `probeFailure` is the single classifier, all four old `unreachablePosture` call sites converted). Closure mutation: `isIdentityRefusal → false` → 2 tests RED, restored GREEN. resolved 3 — the terminal is mounted by a test and the test discriminates (`e2e/attach.spec.ts:358` asserts real `.xterm` + `.xterm-rows` text; `:394` re-checks the 390px geometry with the terminal present). Closure mutation: `showTerminal = $derived(false)` → exactly 2 specs RED, restored GREEN. resolved 4 — the parity guard pins the Go bounds (`fixtures.test.ts:257-272` parses the declarations, `:514` pins both). Closure mutations: Go `MaxTermDimension 1000 → 500` → RED; `MaxControlFrameBytes 4<<10 → 1<<10` → RED; both restored GREEN. All four blockers are fixed and independently mutation-verified on this head, and the landing delta review on `d581628` found none open. The verdict is posted above; the accepted non-blocking concerns and the resolved ingress-escalation question are recorded there.
aaron merged commit 76625d2a79 into epic/LD3 2026-10-02 20:01:37 +00:00
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!584
No description provided.