HOMELAB-1683: feat(cockpit): LD3.3 sandbox attach bridge, built disabled #579

Merged
aaron merged 4 commits from plane/HOMELAB-1683-attach-bridge into epic/LD3 2026-10-02 07:21:41 +00:00
Owner

What

LD3.3 slice LD3.3-code: the sandbox attach bridge's cockpit backend — the PTY pipe, the server wiring, the refusal matrix and the ld.audit.attach/v1 ledger — built so it lands disabled.

Scope is exactly lab-director/cockpit/** (Go). No web/**, no ci.yaml, no prod/**, no lab-director/dispatcher/**, no manifests, no RBAC, no CNP.

New package attach/:

  • Registry seam: resolve a sandbox id → {namespace, pod, container, class, ticket, hasTTY, identity} through a narrow read interface, with a recording fixture and the refusing unwired registry that names the unratified RBAC. The live CRD reads are deliberately not built here (they need the ratified RBAC).
  • Executor/Session seam: Dial(namespace, pod, container, term) → bidirectional stdin/stdout + Resize. The client-go impl is remotecommand.NewSPDYExecutor over the pods/exec subresource with Tty: true and a TerminalSizeQueue. Construction reads a kubeconfig (LD_ATTACH_KUBECONFIG, empty = in-cluster) and contacts nothing; the dial is the only network call.
  • Refusal matrix, typed, each row citing a P-ATT-00N policy line (the gates/sentinel discipline).
  • ld.audit.attach/v1 writer. audit.Entry gains attach kinds/outcomes/fields; Validate now enforces the schema↔kind↔outcome pairing in both directions and keeps sentinel entries free of attach fields (a claimed schema the entry cannot satisfy is worse than a schema of its own — the existing "attach placeholder" assertion in audit_test.go became a real attach entry plus explicit pairing cases; that is a strengthening, not a weakening).
  • Wire() is the one place a kube client can be constructed. Disabled builds none (TestWireDisabledConstructsNoKubeClient counts constructions); enabled builds exactly one and refuses per attempt while the registry is unwired.

Server (server/attach.go, wired as root routes, i.e. outside RequireOperator so a refused handshake is still a ledger entry):

  • GET /api/v1/attach/{sandbox}/info — {exists, has_tty, attachable, reason, policy, wiring, terminal, policy_lines, audit_recorded}. 200 (attachable:false) when the sandbox exists but cannot attach, 404 unknown, 401/403 for identity, 503 disabled.
  • WebSocket /ws/attach/{sandbox} — text frames are JSON control ({"type":"resize","cols":N,"rows":N}, {"type":"close"}), binary frames are terminal bytes both ways; open frame first, close frame last, error frame instead of an open when the dial fails after the open was recorded.
  • statusRecorder (the access-log middleware) gained the Hijack passthrough a websocket upgrade needs through the chain — without it every attach handshake would have been a 500.

Config: LD_ATTACH_ENABLED (default false), LD_ATTACH_KUBECONFIG. A kubeconfig set while the flag is not true is refused as an ambiguous configuration (same shape as the half-set OIDC block). main renders attach in --check's OK lines and in the boot log. It is deliberately not a read-model census row (e79ed6f): the census has a frontend parity guard (web/e2e/shell.spec.ts asserts 4 source rows) and the attach row belongs with the xterm.js pane slice that will render it — see the NOTE at cmd/cockpit/main.go.

Why

The LD3.3 ticket's AC is "attach from the app into a live sandbox works and is audit-logged". The ratification it needs (start-up RBAC, CNP delta, mTLS, sentinel arming) is a separate, class-E operator decision (.rpiv/artifacts/designs/ld3/ratification-package-3.3.md). Splitting code from grant is what lets this land class B/C: the mechanism is complete and tested, the feature is inert.

Disabled-by-default posture (the review's main question)

  • Flag off ⇒ both endpoints answer and every refusal is a ledger entry (open/close/refused never silently swallowed).
  • Flag off ⇒ no kube client is constructed: the constructor is not called (asserted as a call count, not as a branch we hope is right), no kubeconfig is read, no in-cluster token is loaded. The boot log says it out loud.
  • Flag on without the ratified RBAC ⇒ the unwired registry refuses every attempt with the missing grant named (never guesses a pod).
  • Ledger invariants: exactly one attach_close per attach_open (a failed dial writes the aborted close), and an attach never opens when its open entry could not be written.
  • R6/R7 honoured: no herdr byte-routing, no mTLS, no new config surface beyond the two keys.

Test plan

cd lab-director/cockpit && go vet ./... && go test -race -count=1 ./... → 192 tests pass, 0 failures (attach 32, server 47, audit 9, config 13 new/extended). go build ./... clean.

Fixture-driven, no network, no cluster. Covered: the full refusal matrix (disabled · unauthenticated · non-operator · unwired registry · unknown sandbox · no-TTY class · unknown class · ticketless sandbox · malformed resolution · invalid terminal · dial failure · ledger unavailable), the WebSocket protocol over a real socket (upgrade, open frame, byte round-trip, resize reaching the executor, sandbox-side EOF, abrupt client disconnect, protocol error, slow consumer torn down by the write deadline), open/close ledger pairing and the close entry's JSON shape, and the no-kube-client-at-boot proof.

Manual dev-mode evidence (binary, OIDC_ISSUER unset):

  • GET /api/v1/attach/ld-light-1/info → 503 {"disabled":true,"attachable":false,"policy":"P-ATT-001","audit_recorded":true,…} + a ld.audit.attach/v1 attach_refused ledger line.
  • GET /ws/attach/ld-light-1 with websocket upgrade headers → HTTP/1.1 503, no 101, no Upgrade header, refusal recorded.
  • --check → OK attach enabled=false kube_client_constructed=false registry=not-applicable (disabled) kubeconfig=in-cluster.

Rollout

Nothing to roll out: no manifests in this PR. The live attach deployment (RBAC get/list sandboxclaims+pods and create pods/exec in agent-sandbox-system, the CNP egress delta, the mTLS ruling) is the ratified 3.3 deploy slice; the xterm.js pane is a separate frontend slice after the three-panes PR lands.

## What LD3.3 slice **`LD3.3-code`**: the sandbox attach bridge's cockpit backend — the PTY pipe, the server wiring, the refusal matrix and the `ld.audit.attach/v1` ledger — built so it lands **disabled**. Scope is exactly `lab-director/cockpit/**` (Go). No `web/**`, no `ci.yaml`, no `prod/**`, no `lab-director/dispatcher/**`, no manifests, no RBAC, no CNP. **New package `attach/`:** - `Registry` seam: resolve a sandbox id → `{namespace, pod, container, class, ticket, hasTTY, identity}` through a narrow read interface, with a recording fixture and the refusing *unwired* registry that names the unratified RBAC. The live CRD reads are deliberately **not** built here (they need the ratified RBAC). - `Executor`/`Session` seam: `Dial(namespace, pod, container, term)` → bidirectional stdin/stdout + `Resize`. The client-go impl is `remotecommand.NewSPDYExecutor` over the `pods/exec` subresource with `Tty: true` and a `TerminalSizeQueue`. Construction reads a kubeconfig (`LD_ATTACH_KUBECONFIG`, empty = in-cluster) and contacts nothing; the dial is the only network call. - Refusal matrix, typed, each row citing a `P-ATT-00N` policy line (the `gates`/`sentinel` discipline). - `ld.audit.attach/v1` writer. `audit.Entry` gains attach kinds/outcomes/fields; `Validate` now enforces the **schema↔kind↔outcome pairing in both directions** and keeps sentinel entries free of attach fields (a claimed schema the entry cannot satisfy is worse than a schema of its own — the existing "attach placeholder" assertion in `audit_test.go` became a real attach entry plus explicit pairing cases; that is a strengthening, not a weakening). - `Wire()` is the **one** place a kube client can be constructed. Disabled builds none (`TestWireDisabledConstructsNoKubeClient` counts constructions); enabled builds exactly one and refuses per attempt while the registry is unwired. **Server** (`server/attach.go`, wired as root routes, i.e. outside `RequireOperator` so a refused handshake is still a ledger entry): - `GET /api/v1/attach/{sandbox}/info` — `{exists, has_tty, attachable, reason, policy, wiring, terminal, policy_lines, audit_recorded}`. 200 (`attachable:false`) when the sandbox exists but cannot attach, 404 unknown, 401/403 for identity, 503 disabled. - `WebSocket /ws/attach/{sandbox}` — text frames are JSON control (`{"type":"resize","cols":N,"rows":N}`, `{"type":"close"}`), binary frames are terminal bytes both ways; `open` frame first, `close` frame last, `error` frame instead of an open when the dial fails after the open was recorded. - `statusRecorder` (the access-log middleware) gained the `Hijack` passthrough a websocket upgrade needs through the chain — without it every attach handshake would have been a 500. **Config**: `LD_ATTACH_ENABLED` (default **false**), `LD_ATTACH_KUBECONFIG`. A kubeconfig set while the flag is not `true` is refused as an ambiguous configuration (same shape as the half-set OIDC block). `main` renders attach in `--check`'s OK lines and in the boot log. It is deliberately **not** a read-model census row (e79ed6f): the census has a frontend parity guard (`web/e2e/shell.spec.ts` asserts 4 source rows) and the attach row belongs with the xterm.js pane slice that will render it — see the NOTE at `cmd/cockpit/main.go`. ## Why The LD3.3 ticket's AC is "attach from the app into a live sandbox works and is audit-logged". The *ratification* it needs (start-up RBAC, CNP delta, mTLS, sentinel arming) is a separate, class-E operator decision (`.rpiv/artifacts/designs/ld3/ratification-package-3.3.md`). Splitting code from grant is what lets this land class B/C: the mechanism is complete and tested, the feature is inert. ## Disabled-by-default posture (the review's main question) - Flag off ⇒ both endpoints answer and **every refusal is a ledger entry** (open/close/refused never silently swallowed). - Flag off ⇒ **no kube client is constructed**: the constructor is not called (asserted as a call count, not as a branch we hope is right), no kubeconfig is read, no in-cluster token is loaded. The boot log says it out loud. - Flag on without the ratified RBAC ⇒ the unwired registry refuses every attempt with the missing grant named (never guesses a pod). - Ledger invariants: exactly one `attach_close` per `attach_open` (a failed dial writes the aborted close), and an attach **never opens** when its open entry could not be written. - R6/R7 honoured: no herdr byte-routing, no mTLS, no new config surface beyond the two keys. ## Test plan `cd lab-director/cockpit && go vet ./... && go test -race -count=1 ./...` → **192 tests pass**, 0 failures (`attach` 32, `server` 47, `audit` 9, `config` 13 new/extended). `go build ./...` clean. Fixture-driven, no network, no cluster. Covered: the full refusal matrix (disabled · unauthenticated · non-operator · unwired registry · unknown sandbox · no-TTY class · unknown class · ticketless sandbox · malformed resolution · invalid terminal · dial failure · ledger unavailable), the WebSocket protocol over a real socket (upgrade, open frame, byte round-trip, resize reaching the executor, sandbox-side EOF, abrupt client disconnect, protocol error, **slow consumer torn down by the write deadline**), open/close ledger pairing and the close entry's JSON shape, and the no-kube-client-at-boot proof. Manual dev-mode evidence (binary, `OIDC_ISSUER` unset): - `GET /api/v1/attach/ld-light-1/info` → `503` `{"disabled":true,"attachable":false,"policy":"P-ATT-001","audit_recorded":true,…}` + a `ld.audit.attach/v1` `attach_refused` ledger line. - `GET /ws/attach/ld-light-1` with websocket upgrade headers → `HTTP/1.1 503`, no `101`, no `Upgrade` header, refusal recorded. - `--check` → `OK attach enabled=false kube_client_constructed=false registry=not-applicable (disabled) kubeconfig=in-cluster`. ## Rollout Nothing to roll out: no manifests in this PR. The live attach deployment (RBAC `get/list` sandboxclaims+pods and `create pods/exec` in `agent-sandbox-system`, the CNP egress delta, the mTLS ruling) is the ratified 3.3 deploy slice; the xterm.js pane is a separate frontend slice after the three-panes PR lands.
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.
Author
Owner

Review 2/2 — protocol & correctness (lens: WS framing, lifecycle, audit pairing, tests)

VERDICT: APPROVE-WITH-CONCERNS

Reviewed at head ca9022f (verified: git rev-parse origin/plane/HOMELAB-1683-attach-bridge = ca9022f3384444dc951339eaf9e2b88e9059e6f4), against origin/epic/LD3...``origin/plane/HOMELAB-1683-attach-bridge (27 files, +5659/-21, all under lab-director/cockpit/). I did not write this code. Line numbers below are the post-change file lines (as reported by the coverage profile and git show <branch>:<path>).

No BLOCKER found: the byte path is binary-clean, frames are disambiguated by frame type, the write budget is real and tested, 50 sessions leak 0 goroutines (measured), and no normal path produces an orphan attach_open. The concerns below are ledger-fidelity, comment-vs-code drift and test-honesty items.

Findings

  • [CONCERN] lab-director/cockpit/server/attach.go:416 — the pump's own frame-length check is unreachable, so the misclassification it exists to prevent is what actually happens. conn.SetReadLimit(maxAttachInputFrame) (attach.go:220, 1 MiB) and the inner bound are the same number, and gorilla enforces the limit off the frame header before the payload is materialised (websocket@v1.5.4-…/conn.go:933), returning ErrReadLimit — never a too-large payload. ErrReadLimit is not a *CloseError, so isCloseFrame (attach.go:552) is false and the session is recorded as client_eof / closed instead of protocol_error / aborted. Repro (probe, real socket through the full chain): client sends one binary frame of maxAttachInputFrame+1 bytes → ledger close entry reason="client_eof" outcome="closed" detail="websocket: read limit exceeded". Evidence: go tool cover -func shows the line at 0.0% and no test can reach it. Why it matters: the ledger's own vocabulary says a protocol violation is an aborted session; here a violation reads as a clean client hang-up, and the Detail is the only field that tells the truth. The allocation bound itself is fine.
  • [CONCERN] lab-director/cockpit/server/attach.go:286-300 (writeAttachError, 0.0% coverage) vs the contract in two other places. The handler doc says the post-upgrade refusal is "a {"type":"error"} frame followed by the close frame" (server/attach.go:43), and protocol.go:23 promises close "exactly once, last" on every attach — but writeAttachError writes the error frame and then conn.Close(); no close frame is ever sent on this path. Any client that implements the documented grammar and waits for the final close frame sees an abnormal socket close instead. Also: this is the only rendering of the dial_failed row on the wire, and nothing tests it (see the matrix below). Why it matters: the protocol doc is the contract the xterm.js pane slice will implement against, and it describes a frame sequence the server does not produce.
  • [CONCERN] lab-director/cockpit/server/attach.go:513-518 — the open/close pairing is fail-closed at open and fail-open at close. Bridge.Open aborts the whole attach when the open entry cannot be written (bridge.go:330-345), but finishAttach logs s.log.Error("the attach close could not be recorded") and carries on: it writes the close frame, closes the socket and tells the client the session ended (reason=… outcome=…) while the ledger still holds an attach_open with no answer — precisely the state audit/audit.go:78-82 calls "the one lie an attach ledger must never tell". Not a BLOCKER: the production sink is audit.LogSink, whose only failure mode is Entry.Validate, and the close entry built by Auditor.Closed (attach/auditor.go:106-146) always satisfies it (ticket scope, non-empty reason defaulted at auditor.go:121, session id always set) — so this needs a future durable sink to become reachable. It still makes the PR's blanket claim "exactly one attach_close per attach_open" untrue as stated, and the asymmetry deserves a comment at minimum.
  • [CONCERN] Process shutdown orphans an in-flight session — cmd/cockpit/main.go:243-275, server/attach.go:394-491. The pumps never observe their context: pumpCtx is only read once, in <-pumpCtx.Done() (attach.go:488), and the request context is not the signal context (main.go:215 uses signal.NotifyContext, no Server.BaseContext, no RegisterOnShutdown). Go's http.Server.Shutdown explicitly does not close or wait for hijacked connections. So SIGTERM during a redeploy with a live attach ends the process with the exec stream torn down by the kernel, no close frame, and an attach_open with no attach_close — a routine operational event, not an exotic one. Fix is a process-level one (a BaseContext cancelled on SIGTERM, plus a <-ctx.Done() leg in the teardown select); flagging rather than blocking because it is outside the slice's stated scope and the feature ships disabled.
  • [CONCERN] No read deadline, ping or idle timeout once the handshake is done — server/attach.go:404-413. The input pump loops on conn.ReadMessage() with no deadline; the 10 s budget (attach.go:464) only applies before writes. A client whose TCP is blackholed (lid closed, no FIN) plus a quiet sandbox (a shell at a prompt) therefore holds a sandbox shell open forever, which is exactly what protocol.go:63-66 says must not happen ("a stalled viewer must not hold a sandbox shell open forever") and what attach.go:47-49 claims is handled. The tested case (TestAttachSlowConsumerIsTornDownAndRecorded) covers the stalled-viewer-with-output case only. Why it matters: the shell is the expensive resource, and this is the unbounded one.
  • [CONCERN] lab-director/cockpit/attach/kube.go:99-141, 164-227 — the real backend is entirely unexecuted. go tool cover -func on the branch: Dial 0.0%, kubeSession.Read/Write/Resize/Close/Wait/finish 0.0%, errOrEOF 0.0%; only the pure functions (execURL, sizeQueue, construction) are covered. So nothing in the suite would catch Tty: false, a Stderr set alongside Tty: true (which remotecommand refuses), a missing TerminalSizeQueue, or the exec URL losing tty=true. The three claims in the PR description about this file are asserted by reading the constants (TestExecURLIsThePodsExecSubresource covers the URL; Tty/queue are only compile-time interface assertions at kube.go:356-359). Per the review brief this is a CONCERN, not a BLOCKER: the flag is off, the dial is the only network call, and execURL (the RBAC-relevant half) is well tested. A remotecommand.StreamOptions-shaped seam would have made Tty: true assertable without a cluster.
  • [CONCERN] lab-director/cockpit/audit/audit.go:260 — "sentinel entries are kept free of attach fields" is enforced for 4 of 7 fields. Validate's sentinel branch checks Sandbox, SessionID, Term, Class, but not BytesIn, BytesOut or DurationMS. Probe (added to the package, run, then removed): a schema=ld.audit.sentinel/v1 entry with BytesIn=4096, BytesOut=4096 or DurationMS=1234 validates with no error, while the same entry carrying Sandbox/Class/SessionID/Term is refused. The test that appears to prove purity (server/attach_test.go:879-883) marshals a zero-valued sentinel entry and asserts the JSON has no bytes_in/… key — which passes because of omitempty, and can only fail if someone drops omitempty, not if someone sets the field. No writer sets these today, so it is latent; one added || closes it.
  • [CONCERN] lab-director/cockpit/server/attach_test.go:474 — TestAttachInfoNamesTheUnratifiedRegistry does not test the unwired registry. The harness wires a ready RecordingRegistry (sandboxes/registry defaults at attach_test.go:81-91), so the test asserts attachable:true, 200 and the policy-book length; the in-test comment concedes the point ("the unwired case is asserted in the attach package and in the disabled default above"). Result: the registry_unwired row has no transport-level assertion, and the test name reports coverage that does not exist — worse than no test, because a green here is read as "the enabled-but-unratified posture was checked over HTTP".
  • [CONCERN] lab-director/cockpit/attach/bridge_test.go:325 — TestMalformedResolutionIsRefused asserts only ref.Kind, with no ledger assertion and no DialCount() != 0 check. Every sibling row in the same file asserts the entry and the no-dial proof; this one can pass while the refusal is unaudited.
  • [CONCERN] The "executor error mid-stream" direction of the disconnect matrix has no test that can observe it. attach/protocol.go:58 defines CloseReasonStreamError and server/attach.go:474-481 maps a non-EOF read error to it with outcome aborted, but no test in the tree references CloseReasonStreamError (grep: 0 hits in *_test.go) — the fixture has no knob to make Session.Read fail with a real error (FixtureExecutor offers DialErr/ResizeErr only; EndAfterBanner yields a clean io.EOF). The line is executed incidentally (line 476 is covered) because teardown closes the pipe under the output pump, but the observable outcome is always already recorded by the other pump. So one of the three disconnect directions is untested at the level where it matters (what the ledger says).
  • [SUGGESTION] lab-director/cockpit/server/middleware.go:210-218 — the Hijack passthrough is correct and does not break the access log, but it is redundant, and both justifications in its comment are wrong. Gorilla in use calls http.NewResponseController(w).Hijack() (server.go:175), which already walks Unwrap() — and statusRecorder.Unwrap has existed since the SSE fix (middleware.go:204). Proof: renaming the new method to HijackDISABLED leaves TestAttachSession*/TestAttachWebSocket*/TestAttachSlow* green (vet clean) — the upgrades still work. And the comment's "(the access log line … is whatever status was written before the upgrade (101))" is false: the recorder defaults to http.StatusOK (middleware.go:86) and gorilla writes HTTP/1.1 101 directly to the hijacked socket without calling WriteHeader (websocket@v1.5.4-…/server.go:228), so an upgraded attach logs status=200. Probe against the real chain: level=DEBUG msg=request method=GET status=200 bytes=0 for the upgrade, status=503 for the refused handshake. Keep the method if you like the fast path; fix the comment.
  • [SUGGESTION] PR body vs code: the description says "main renders attach in the read-model census", but cmd/cockpit/main.go:404-409 says the opposite ("deliberately NOT an ExtraSource in the read model's census … the census row lands with the xterm.js pane slice"). The README agrees with the code. The PR text should be corrected — a reviewer looking for the census row will not find it.
  • [SUGGESTION] lab-director/cockpit/cmd/cockpit/main.go:324-325 prints checkBridge.Enabled() for both enabled and kube_client_constructed. The two are equal only because Wire constructs an executor iff Enabled; the column is asserted by construction, not measured. The attach.Wire call in runCheck also constructs the real executor (and reads a cluster credential) when the flag is on, which is defensible for --check but is not what the "the constructor is NOT called on the disabled path" comment implies for the enabled one.

Claim verification (author's claims vs what I found)

  • binary-clean PTY byte path: confirmed. Client→sandbox: payload ([]byte from ReadMessage) goes straight into live.Exec.Write(payload) (server/attach.go:422); sandbox→client: buf[:n] straight into conn.WriteMessage(websocket.BinaryMessage, …) (attach.go:465). No string()/[]rune()/UTF-8 validation anywhere on the data path (the only conversion is strings.NewReader(string(payload)) inside DecodeClientFrame, control frames only, protocol.go:132). Disambiguation is by frame type only (switch typ at attach.go:414); no payload sniffing. No text frame can reach Exec.Write: the TextMessage arm only ever calls DecodeClientFrame. Round-trip proven over a real socket: TestAttachSessionRoundTrip writes binary input and reads it back byte-identical as a binary frame (server/attach_test.go:558-564), and TestAttachSessionClosesOnAProtocolError proves a hostile text frame ({"type":"exec","command":"rm -rf /"}) is refused, not typed (attach_test.go:723).
  • slow consumer torn down by write deadline: confirmed for the with-output case. Fresh 10 s deadline before every write (attach.go:383, 464, 533), no unbounded queue (writes go straight to the socket, so a stalled reader blocks the writer until the deadline fires), TestAttachSlowConsumerIsTornDownAndRecorded drives 32 MiB of flood with a 50 ms budget and asserts client_write_timeout + aborted (attach_test.go:762-793). Refuted for the no-output case: no read deadline/ping after the upgrade, so a blackholed client with a quiet sandbox is not torn down (see finding 5).
  • disconnect both directions handled: mostly confirmed. Abrupt client drop and sandbox-side EOF are both over a real socket and both paired: TestAttachSessionEndsWhenTheClientVanishes (client_eof/closed), TestAttachSessionEndsWhenTheSandboxHangsUp (stream_eof/closed), protocol error (protocol_error/aborted). Termination is structural, not hopeful: whichever pump finishes first cancels pumpCtx, and the teardown is ordered <-pumpCtx.Done() → live.Exec.Close() → conn.SetReadDeadline(time.Now()) → wg.Wait() (attach.go:488-491) — both unblocking actions precede the wait, so neither direction can keep copying. The third direction (executor error mid-stream) has no test that can observe it (finding 10). The error-frame semantics are implemented (Bridge.Open refuses after the ledger has the open, bridge.go:347-362; the frame is sent by server/attach.go:228) but untested on the wire.
  • exactly one close per open; no orphan open; failed dial writes aborted close: confirmed for the failed dial — TestDialFailureClosesTheOpenItWrote asserts open + aborted close + shared session_id + the dial error in Detail (bridge_test.go:403-428), and LiveSession.close is idempotent under a mutex (bridge.go:120-148, re-asserted at bridge_test.go:392-398). Every serveAttach exit path reaches finishAttach: the open-frame-write failure (attach.go:386-390), and the normal path (attach.go:493). Refuted as an absolute: a close can be lost — on a sink failure at close (logged, not retried: attach.go:513-518) and on process shutdown with a live session (findings 3 and 4). Neither is reachable on the normal path with today's LogSink; I judged both CONCERN rather than BLOCKER for that reason, and state it here so the call can be overridden deliberately.
  • refusal matrix rows implemented N / tested M: 12 / 12 implemented, 12 / 12 asserted somewhere, 1 row asserted only superficially, 2 rows never asserted at the transport layer. Kinds (11) at attach/attach.go:284-315; rows (12) mapping: disabled (bridge.go:307, tested bridge_test.go:115, server/attach_test.go:234,272); unauthenticated (server/attach.go:109, tested attach_test.go:297); non-operator (server/attach.go:112, tested attach_test.go:334); unwired registry (bridge.go:385, tested bridge_test.go:202, wire_test.go:92 — not at the transport, despite attach_test.go:474's name); unknown sandbox (bridge.go:389, tested + matrix); no-TTY class (bridge.go:412, tested + matrix + WS 409 with no dial at attach_test.go:499); unknown class (bridge.go:407, tested bridge_test.go:231 quantum-harness + matrix); ticketless (bridge.go:421, tested + matrix); malformed resolution (bridge.go:398, superficial — kind only); invalid terminal (bridge.go:313 / server/attach.go:120, tested both layers); dial failure (bridge.go:347, tested in-package, wire rendering 0%); ledger unavailable (bridge.go:330, tested bridge_test.go:446, wire rendering 0%). The README table lists 10 rows (it folds "unknown class" into the no-TTY row and omits malformed target).
  • fixture honesty (real executor covered?): stale. The suite is fixture-driven (attach.FixtureExecutor, attach.RecordingRegistry) as the PR states, and those fixtures are genuinely behavioural (real io.Pipes, real flood/EOF), so the transport claims are real. But kube.go — the only file where a Tty: true / TerminalSizeQueue / Stderr mistake could live — is 0.0% covered (finding 6). Disclosed honestly here: CONCERN, not BLOCKER.
  • test count claim (192 / attach 32, server 47, audit 9, config 13): confirmed. Top-level results: 192 PASS, 1 SKIP (--- SKIP: TestProductionCSPCoversTheRealBuild) = 193 test functions, 192 executed — matching "192 tests pass". Per package (top-level): attach 32, server 48 (47 pass + the 1 skip → the claimed 47), audit 9, config 13 (of which the new attach config test contributes 1 function with 5 subtests). Note these are package totals, not "new" counts.

Verified by (commands you actually ran)

  • git rev-parse origin/plane/HOMELAB-1683-attach-bridge origin/epic/LD3 → ca9022f… / 6f98509… (head matches the review target).
  • git diff --stat origin/epic/LD3...origin/plane/HOMELAB-1683-attach-bridge → 27 files, +5659/-21, all under lab-director/cockpit/.
  • cd lab-director/cockpit && go vet ./... && go test -race -count=1 ./... → VET_EXIT=0, TEST_EXIT=0; ok for all 10 packages (attach 1.043s, audit 1.023s, auth 1.026s, config 1.024s, gates 1.036s, plane 1.046s, readmodel 1.023s, server 1.324s, sse 1.279s; cmd/cockpit [no test files]). No race reports.
  • go test -count=1 -v ./... | grep -E '^--- (PASS|FAIL|SKIP)' | awk '{print $2}' | sort | uniq -c → 192 PASS:, 1 SKIP:.
  • go test -count=1 -v ./attach ./server ./audit ./config | grep -cE '^=== RUN Test[^/]*$' → 32 / 48 / 9 / 13.
  • go tool cover -func → attach 67.2%, server 90.0%, audit 96.0%, config 92.9%; attach/kube.go Dial/Read/Write/Resize/Close/Wait/finish/errOrEOF all 0.0%; server/attach.go writeAttachError 0.0%, serveAttach 83.3%, handleAttachWS 83.3%.
  • Probe (temporary test in a scratch worktree of ca9022f, since removed) — oversize input frame over a real socket through the full chain → close entry reason="client_eof" outcome="closed" detail="websocket: read limit exceeded" (inner bound unreachable).
  • Probe — statusRecorder.Hijack renamed away, go vet ./server/ clean, go test -run 'TestAttachSession|TestAttachWebSocket|TestAttachSlow|TestAttachAuditEntryJSONShape' ./server/ → ok (the passthrough is redundant; Unwrap suffices).
  • Probe — access log over the real Chain(inner, RequestID, Logger(log)) → upgraded ws status=200 bytes=0 (not 101); refused handshake status=503.
  • Probe — 50 attach sessions (25 clean close frames, 25 abrupt drops) with -race: goroutines baseline=3 after 50 sessions=3 (delta=0). No goroutine leak.
  • Probe — audit.Entry{Schema: SchemaSentinel, …} with BytesIn/BytesOut/DurationMS set → Validate() returns nil (accepted), while Sandbox/Class/SessionID/Term are refused.
  • grep -rn CloseReasonStreamError --include=*_test.go attach/ server/ → 0 hits.
  • forgejo_get_pr 579 → PR body used for the claim-verification section.

No byte corruption, no orphan open on a reachable path, no persistent goroutine leak, and no test that cannot fail was found. The two items I would fix before this is used (rather than merged) are the client_eof/closed misclassification (one-line: classify websocket.ErrReadLimit as protocol_error/aborted) and the untested wire rendering of the post-upgrade error frame, since the xterm.js slice will code against the documented grammar.

## Review 2/2 — protocol & correctness (lens: WS framing, lifecycle, audit pairing, tests) VERDICT: APPROVE-WITH-CONCERNS Reviewed at head `ca9022f` (verified: `git rev-parse origin/plane/HOMELAB-1683-attach-bridge` = `ca9022f3384444dc951339eaf9e2b88e9059e6f4`), against `origin/epic/LD3...``origin/plane/HOMELAB-1683-attach-bridge` (27 files, +5659/-21, all under `lab-director/cockpit/`). I did not write this code. Line numbers below are the post-change file lines (as reported by the coverage profile and `git show <branch>:<path>`). No BLOCKER found: the byte path is binary-clean, frames are disambiguated by frame type, the write budget is real and tested, 50 sessions leak 0 goroutines (measured), and no normal path produces an orphan `attach_open`. The concerns below are ledger-fidelity, comment-vs-code drift and test-honesty items. ### Findings - [CONCERN] `lab-director/cockpit/server/attach.go:416` — the pump's own frame-length check is **unreachable**, so the misclassification it exists to prevent is what actually happens. `conn.SetReadLimit(maxAttachInputFrame)` (`attach.go:220`, 1 MiB) and the inner bound are the *same* number, and gorilla enforces the limit off the frame header before the payload is materialised (`websocket@v1.5.4-…/conn.go:933`), returning `ErrReadLimit` — never a too-large payload. `ErrReadLimit` is not a `*CloseError`, so `isCloseFrame` (`attach.go:552`) is false and the session is recorded as `client_eof` / **`closed`** instead of `protocol_error` / `aborted`. Repro (probe, real socket through the full chain): client sends one binary frame of `maxAttachInputFrame+1` bytes → ledger close entry `reason="client_eof" outcome="closed" detail="websocket: read limit exceeded"`. Evidence: `go tool cover -func` shows the line at 0.0% and no test can reach it. Why it matters: the ledger's own vocabulary says a protocol violation is an aborted session; here a violation reads as a clean client hang-up, and the `Detail` is the only field that tells the truth. The allocation bound itself is fine. - [CONCERN] `lab-director/cockpit/server/attach.go:286-300` (`writeAttachError`, **0.0% coverage**) vs the contract in two other places. The handler doc says the post-upgrade refusal is "a `{"type":"error"}` frame **followed by the close frame**" (`server/attach.go:43`), and `protocol.go:23` promises `close` "exactly once, last" on every attach — but `writeAttachError` writes the error frame and then `conn.Close()`; no close frame is ever sent on this path. Any client that implements the documented grammar and waits for the final close frame sees an abnormal socket close instead. Also: this is the *only* rendering of the `dial_failed` row on the wire, and nothing tests it (see the matrix below). Why it matters: the protocol doc is the contract the xterm.js pane slice will implement against, and it describes a frame sequence the server does not produce. - [CONCERN] `lab-director/cockpit/server/attach.go:513-518` — the open/close pairing is fail-closed at open and **fail-open at close**. `Bridge.Open` aborts the whole attach when the open entry cannot be written (`bridge.go:330-345`), but `finishAttach` logs `s.log.Error("the attach close could not be recorded")` and carries on: it writes the close frame, closes the socket and tells the client the session ended (`reason=… outcome=…`) while the ledger still holds an `attach_open` with no answer — precisely the state `audit/audit.go:78-82` calls "the one lie an attach ledger must never tell". Not a BLOCKER: the production sink is `audit.LogSink`, whose only failure mode is `Entry.Validate`, and the close entry built by `Auditor.Closed` (`attach/auditor.go:106-146`) always satisfies it (ticket scope, non-empty reason defaulted at `auditor.go:121`, session id always set) — so this needs a future durable sink to become reachable. It still makes the PR's blanket claim "exactly one `attach_close` per `attach_open`" untrue as stated, and the asymmetry deserves a comment at minimum. - [CONCERN] Process shutdown orphans an in-flight session — `cmd/cockpit/main.go:243-275`, `server/attach.go:394-491`. The pumps never observe their context: `pumpCtx` is only read once, in `<-pumpCtx.Done()` (`attach.go:488`), and the request context is not the signal context (`main.go:215` uses `signal.NotifyContext`, no `Server.BaseContext`, no `RegisterOnShutdown`). Go's `http.Server.Shutdown` explicitly does not close or wait for hijacked connections. So SIGTERM during a redeploy with a live attach ends the process with the exec stream torn down by the kernel, no close frame, and an `attach_open` with no `attach_close` — a routine operational event, not an exotic one. Fix is a process-level one (a `BaseContext` cancelled on SIGTERM, plus a `<-ctx.Done()` leg in the teardown `select`); flagging rather than blocking because it is outside the slice's stated scope and the feature ships disabled. - [CONCERN] No read deadline, ping or idle timeout once the handshake is done — `server/attach.go:404-413`. The input pump loops on `conn.ReadMessage()` with no deadline; the 10 s budget (`attach.go:464`) only applies before *writes*. A client whose TCP is blackholed (lid closed, no FIN) plus a quiet sandbox (a shell at a prompt) therefore holds a sandbox shell open forever, which is exactly what `protocol.go:63-66` says must not happen ("a stalled viewer must not hold a sandbox shell open forever") and what `attach.go:47-49` claims is handled. The tested case (`TestAttachSlowConsumerIsTornDownAndRecorded`) covers the stalled-viewer-*with-output* case only. Why it matters: the shell is the expensive resource, and this is the unbounded one. - [CONCERN] `lab-director/cockpit/attach/kube.go:99-141`, `164-227` — the real backend is entirely unexecuted. `go tool cover -func` on the branch: `Dial` 0.0%, `kubeSession.Read/Write/Resize/Close/Wait/finish` 0.0%, `errOrEOF` 0.0%; only the pure functions (`execURL`, `sizeQueue`, construction) are covered. So nothing in the suite would catch `Tty: false`, a `Stderr` set alongside `Tty: true` (which `remotecommand` refuses), a missing `TerminalSizeQueue`, or the exec URL losing `tty=true`. The three claims in the PR description about this file are asserted by *reading the constants* (`TestExecURLIsThePodsExecSubresource` covers the URL; `Tty`/queue are only compile-time interface assertions at `kube.go:356-359`). Per the review brief this is a CONCERN, not a BLOCKER: the flag is off, the dial is the only network call, and `execURL` (the RBAC-relevant half) is well tested. A `remotecommand.StreamOptions`-shaped seam would have made `Tty: true` assertable without a cluster. - [CONCERN] `lab-director/cockpit/audit/audit.go:260` — "sentinel entries are kept free of attach fields" is enforced for **4 of 7** fields. `Validate`'s sentinel branch checks `Sandbox`, `SessionID`, `Term`, `Class`, but not `BytesIn`, `BytesOut` or `DurationMS`. Probe (added to the package, run, then removed): a `schema=ld.audit.sentinel/v1` entry with `BytesIn=4096`, `BytesOut=4096` or `DurationMS=1234` **validates with no error**, while the same entry carrying `Sandbox`/`Class`/`SessionID`/`Term` is refused. The test that appears to prove purity (`server/attach_test.go:879-883`) marshals a *zero-valued* sentinel entry and asserts the JSON has no `bytes_in`/… key — which passes because of `omitempty`, and can only fail if someone drops `omitempty`, not if someone sets the field. No writer sets these today, so it is latent; one added `||` closes it. - [CONCERN] `lab-director/cockpit/server/attach_test.go:474` — `TestAttachInfoNamesTheUnratifiedRegistry` does not test the unwired registry. The harness wires a ready `RecordingRegistry` (`sandboxes`/`registry` defaults at `attach_test.go:81-91`), so the test asserts `attachable:true`, 200 and the policy-book length; the in-test comment concedes the point ("the unwired case is asserted in the attach package and in the disabled default above"). Result: the `registry_unwired` row has **no transport-level assertion**, and the test name reports coverage that does not exist — worse than no test, because a green here is read as "the enabled-but-unratified posture was checked over HTTP". - [CONCERN] `lab-director/cockpit/attach/bridge_test.go:325` — `TestMalformedResolutionIsRefused` asserts only `ref.Kind`, with no ledger assertion and no `DialCount() != 0` check. Every sibling row in the same file asserts the entry and the no-dial proof; this one can pass while the refusal is unaudited. - [CONCERN] The "executor error mid-stream" direction of the disconnect matrix has no test that can observe it. `attach/protocol.go:58` defines `CloseReasonStreamError` and `server/attach.go:474-481` maps a non-EOF read error to it with outcome `aborted`, but no test in the tree references `CloseReasonStreamError` (grep: 0 hits in `*_test.go`) — the fixture has no knob to make `Session.Read` fail with a real error (`FixtureExecutor` offers `DialErr`/`ResizeErr` only; `EndAfterBanner` yields a clean `io.EOF`). The line is *executed* incidentally (line 476 is covered) because teardown closes the pipe under the output pump, but the observable outcome is always already recorded by the other pump. So one of the three disconnect directions is untested at the level where it matters (what the ledger says). - [SUGGESTION] `lab-director/cockpit/server/middleware.go:210-218` — the `Hijack` passthrough is correct and does not break the access log, but it is **redundant**, and both justifications in its comment are wrong. Gorilla in use calls `http.NewResponseController(w).Hijack()` (`server.go:175`), which already walks `Unwrap()` — and `statusRecorder.Unwrap` has existed since the SSE fix (`middleware.go:204`). Proof: renaming the new method to `HijackDISABLED` leaves `TestAttachSession*`/`TestAttachWebSocket*`/`TestAttachSlow*` green (vet clean) — the upgrades still work. And the comment's "(the access log line … is whatever status was written before the upgrade (101))" is false: the recorder defaults to `http.StatusOK` (`middleware.go:86`) and gorilla writes `HTTP/1.1 101` directly to the hijacked socket without calling `WriteHeader` (`websocket@v1.5.4-…/server.go:228`), so an upgraded attach logs `status=200`. Probe against the real chain: `level=DEBUG msg=request method=GET status=200 bytes=0` for the upgrade, `status=503` for the refused handshake. Keep the method if you like the fast path; fix the comment. - [SUGGESTION] PR body vs code: the description says "`main` renders attach in the read-model census", but `cmd/cockpit/main.go:404-409` says the opposite ("deliberately NOT an ExtraSource in the read model's census … the census row lands with the xterm.js pane slice"). The README agrees with the code. The PR text should be corrected — a reviewer looking for the census row will not find it. - [SUGGESTION] `lab-director/cockpit/cmd/cockpit/main.go:324-325` prints `checkBridge.Enabled()` for both `enabled` and `kube_client_constructed`. The two are equal only because `Wire` constructs an executor iff `Enabled`; the column is asserted by construction, not measured. The `attach.Wire` call in `runCheck` also constructs the real executor (and reads a cluster credential) when the flag is on, which is defensible for `--check` but is not what the "the constructor is NOT called on the disabled path" comment implies for the enabled one. ### Claim verification (author's claims vs what I found) - binary-clean PTY byte path: **confirmed**. Client→sandbox: `payload` (`[]byte` from `ReadMessage`) goes straight into `live.Exec.Write(payload)` (`server/attach.go:422`); sandbox→client: `buf[:n]` straight into `conn.WriteMessage(websocket.BinaryMessage, …)` (`attach.go:465`). No `string()`/`[]rune()`/UTF-8 validation anywhere on the data path (the only conversion is `strings.NewReader(string(payload))` inside `DecodeClientFrame`, control frames only, `protocol.go:132`). Disambiguation is by frame type only (`switch typ` at `attach.go:414`); no payload sniffing. No text frame can reach `Exec.Write`: the `TextMessage` arm only ever calls `DecodeClientFrame`. Round-trip proven over a real socket: `TestAttachSessionRoundTrip` writes binary input and reads it back byte-identical as a binary frame (`server/attach_test.go:558-564`), and `TestAttachSessionClosesOnAProtocolError` proves a hostile text frame (`{"type":"exec","command":"rm -rf /"}`) is refused, not typed (`attach_test.go:723`). - slow consumer torn down by write deadline: **confirmed** for the with-output case. Fresh 10 s deadline before every write (`attach.go:383`, `464`, `533`), no unbounded queue (writes go straight to the socket, so a stalled reader blocks the writer until the deadline fires), `TestAttachSlowConsumerIsTornDownAndRecorded` drives 32 MiB of flood with a 50 ms budget and asserts `client_write_timeout` + `aborted` (`attach_test.go:762-793`). **Refuted for the no-output case**: no read deadline/ping after the upgrade, so a blackholed client with a quiet sandbox is not torn down (see finding 5). - disconnect both directions handled: **mostly confirmed**. Abrupt client drop and sandbox-side EOF are both over a real socket and both paired: `TestAttachSessionEndsWhenTheClientVanishes` (`client_eof`/`closed`), `TestAttachSessionEndsWhenTheSandboxHangsUp` (`stream_eof`/`closed`), protocol error (`protocol_error`/`aborted`). Termination is structural, not hopeful: whichever pump finishes first cancels `pumpCtx`, and the teardown is ordered `<-pumpCtx.Done()` → `live.Exec.Close()` → `conn.SetReadDeadline(time.Now())` → `wg.Wait()` (`attach.go:488-491`) — both unblocking actions precede the wait, so neither direction can keep copying. The third direction (executor error mid-stream) has no test that can observe it (finding 10). The `error`-frame semantics are implemented (`Bridge.Open` refuses after the ledger has the open, `bridge.go:347-362`; the frame is sent by `server/attach.go:228`) but untested on the wire. - exactly one close per open; no orphan open; failed dial writes aborted close: **confirmed for the failed dial** — `TestDialFailureClosesTheOpenItWrote` asserts open + `aborted` close + shared `session_id` + the dial error in `Detail` (`bridge_test.go:403-428`), and `LiveSession.close` is idempotent under a mutex (`bridge.go:120-148`, re-asserted at `bridge_test.go:392-398`). Every `serveAttach` exit path reaches `finishAttach`: the open-frame-write failure (`attach.go:386-390`), and the normal path (`attach.go:493`). **Refuted as an absolute**: a close can be lost — on a sink failure at close (logged, not retried: `attach.go:513-518`) and on process shutdown with a live session (findings 3 and 4). Neither is reachable on the normal path with today's `LogSink`; I judged both CONCERN rather than BLOCKER for that reason, and state it here so the call can be overridden deliberately. - refusal matrix rows implemented N / tested M: **12 / 12 implemented, 12 / 12 asserted somewhere, 1 row asserted only superficially, 2 rows never asserted at the transport layer.** Kinds (11) at `attach/attach.go:284-315`; rows (12) mapping: disabled (`bridge.go:307`, tested `bridge_test.go:115`, `server/attach_test.go:234,272`); unauthenticated (`server/attach.go:109`, tested `attach_test.go:297`); non-operator (`server/attach.go:112`, tested `attach_test.go:334`); unwired registry (`bridge.go:385`, tested `bridge_test.go:202`, `wire_test.go:92` — **not** at the transport, despite `attach_test.go:474`'s name); unknown sandbox (`bridge.go:389`, tested + matrix); no-TTY class (`bridge.go:412`, tested + matrix + WS 409 with no dial at `attach_test.go:499`); unknown class (`bridge.go:407`, tested `bridge_test.go:231` `quantum-harness` + matrix); ticketless (`bridge.go:421`, tested + matrix); malformed resolution (`bridge.go:398`, **superficial** — kind only); invalid terminal (`bridge.go:313` / `server/attach.go:120`, tested both layers); dial failure (`bridge.go:347`, tested in-package, wire rendering 0%); ledger unavailable (`bridge.go:330`, tested `bridge_test.go:446`, wire rendering 0%). The README table lists 10 rows (it folds "unknown class" into the no-TTY row and omits malformed target). - fixture honesty (real executor covered?): **stale**. The suite is fixture-driven (`attach.FixtureExecutor`, `attach.RecordingRegistry`) as the PR states, and those fixtures are genuinely behavioural (real `io.Pipe`s, real flood/EOF), so the *transport* claims are real. But `kube.go` — the only file where a `Tty: true` / `TerminalSizeQueue` / `Stderr` mistake could live — is 0.0% covered (finding 6). Disclosed honestly here: CONCERN, not BLOCKER. - test count claim (192 / attach 32, server 47, audit 9, config 13): **confirmed**. Top-level results: `192 PASS`, `1 SKIP` (`--- SKIP: TestProductionCSPCoversTheRealBuild`) = 193 test functions, 192 executed — matching "192 tests pass". Per package (top-level): attach **32**, server **48** (47 pass + the 1 skip → the claimed 47), audit **9**, config **13** (of which the new attach config test contributes 1 function with 5 subtests). Note these are package *totals*, not "new" counts. ### Verified by (commands you actually ran) - `git rev-parse origin/plane/HOMELAB-1683-attach-bridge origin/epic/LD3` → `ca9022f…` / `6f98509…` (head matches the review target). - `git diff --stat origin/epic/LD3...origin/plane/HOMELAB-1683-attach-bridge` → 27 files, +5659/-21, all under `lab-director/cockpit/`. - `cd lab-director/cockpit && go vet ./... && go test -race -count=1 ./...` → `VET_EXIT=0`, `TEST_EXIT=0`; `ok` for all 10 packages (attach 1.043s, audit 1.023s, auth 1.026s, config 1.024s, gates 1.036s, plane 1.046s, readmodel 1.023s, server 1.324s, sse 1.279s; `cmd/cockpit [no test files]`). No race reports. - `go test -count=1 -v ./... | grep -E '^--- (PASS|FAIL|SKIP)' | awk '{print $2}' | sort | uniq -c` → `192 PASS:`, `1 SKIP:`. - `go test -count=1 -v ./attach ./server ./audit ./config | grep -cE '^=== RUN Test[^/]*$'` → 32 / 48 / 9 / 13. - `go tool cover -func` → `attach` 67.2%, `server` 90.0%, `audit` 96.0%, `config` 92.9%; `attach/kube.go` `Dial`/`Read`/`Write`/`Resize`/`Close`/`Wait`/`finish`/`errOrEOF` all **0.0%**; `server/attach.go` `writeAttachError` **0.0%**, `serveAttach` 83.3%, `handleAttachWS` 83.3%. - Probe (temporary test in a scratch worktree of `ca9022f`, since removed) — oversize input frame over a real socket through the full chain → close entry `reason="client_eof" outcome="closed" detail="websocket: read limit exceeded"` (inner bound unreachable). - Probe — `statusRecorder.Hijack` renamed away, `go vet ./server/` clean, `go test -run 'TestAttachSession|TestAttachWebSocket|TestAttachSlow|TestAttachAuditEntryJSONShape' ./server/` → `ok` (the passthrough is redundant; `Unwrap` suffices). - Probe — access log over the real `Chain(inner, RequestID, Logger(log))` → upgraded ws `status=200 bytes=0` (not 101); refused handshake `status=503`. - Probe — 50 attach sessions (25 clean close frames, 25 abrupt drops) with `-race`: goroutines `baseline=3 after 50 sessions=3 (delta=0)`. **No goroutine leak.** - Probe — `audit.Entry{Schema: SchemaSentinel, …}` with `BytesIn`/`BytesOut`/`DurationMS` set → `Validate()` returns **nil** (accepted), while `Sandbox`/`Class`/`SessionID`/`Term` are refused. - `grep -rn CloseReasonStreamError --include=*_test.go attach/ server/` → 0 hits. - `forgejo_get_pr 579` → PR body used for the claim-verification section. No byte corruption, no orphan open on a reachable path, no persistent goroutine leak, and no test that cannot fail was found. The two items I would fix before this is *used* (rather than merged) are the `client_eof`/`closed` misclassification (one-line: classify `websocket.ErrReadLimit` as `protocol_error`/aborted) and the untested wire rendering of the post-upgrade `error` frame, since the xterm.js slice will code against the documented grammar.
Author
Owner

Review 1/2 — safety & inertness (lens: provable inertness, flag-off)

VERDICT: APPROVE-WITH-CONCERNS

Inertness holds. I could not break it, and the central claim (disabled ⇒ no kube client, asserted as a call count) is real, not a hoped-for branch. Two enabled-mode findings (one unaudited refusal path, one demonstrably false code comment) keep this from a clean APPROVE; neither violates the flag-off property, scope, or identity rules, so neither is a blocker.

Findings

  • [CONCERN] lab-director/cockpit/server/attach.go:210-214 — a failed Upgrade is logged, never audited, so an authenticated attach attempt can leave zero ledger entries — repro: with LD_ATTACH_ENABLED=true and a resolvable sandbox, send GET /ws/attach/ld-light-1 with a valid operator session but no Connection: Upgrade header. attachRequest passes (no refusal written), Info returns attachable:true (a success is deliberately not audited), gorilla replies 400, the handler logs "attach upgrade refused" and returns → 0 ledger entries for an operator who asked for a shell. This is the one early return that skips the audit write; it contradicts the README's "Every refusal is a ledger entry" and P-ATT-005 ("an attempt that leaves no record is indistinguishable from an attempt that never happened"). Flag-off is unaffected (Info returns 503 first, audited), so inertness is intact — this is an enabled-mode completeness gap, not a safety regression.
  • [CONCERN] lab-director/cockpit/server/middleware.go:206-211 — the Hijack doc-comment's parenthetical "(the access log line this recorder produces is whatever status was written before the upgrade (101))" is false; the access log records 200 with 0 bytes for a successful attach handshake — evidence: gorilla v1.5.4's Upgrade takes http.NewResponseController(w).Hijack() and writes the 101 handshake straight to the hijacked bufio writer; it never calls w.WriteHeader, so statusRecorder (initialised status: http.StatusOK at middleware.go:83) keeps 200, and hijacked bytes never reach statusRecorder.Write — so bytes stays 0. End-to-end probe through the real Server.Handler(): client saw handshake status = 101, the cockpit logged msg=request ... path=/ws/attach/ld-light-1 status=200 bytes=0 duration_ms=0. Why it matters: the comment claims the log is trustworthy for the upgrade, when in fact the only access-log line an attach session produces is indistinguishable from an ordinary 200 and reports no byte count — a monitor/operator can't audit attach sessions from the access log. (I re-derived this twice: a standalone clone of statusRecorder + gorilla, and a temp probe against this diff's server package; both failed the 101 claim.)
  • [SUGGESTION] lab-director/cockpit/cmd/cockpit/main.go:324 — kube_client_constructed=%t is printed from checkBridge.Enabled(), i.e. it restates the flag, it does not measure construction. The real proof is the unit call-count; the operator-visible --check column is therefore weaker evidence than the README implies ("asserted by TestWireDisabledConstructsNoKubeClient" is true of the test, not of the printed column). A stronger column would print something observed (e.g. an Executable()/hasExecutor accessor on Bridge that is structurally nil on the disabled path).
  • [SUGGESTION] lab-director/cockpit/server/attach.go:65 — attachUpgrader leaves CheckOrigin nil, so same-origin enforcement is implicit (gorilla's checkSameOrigin default, confirmed in the vendored source at server.go:148-153). The comment is accurate today (and correctly discloses that a missing Origin is accepted, which is right for non-browser clients), but a future gorilla default change would silently remove the cross-origin guard with no test to catch it. Consider setting CheckOrigin explicitly to the documented policy.
  • [SUGGESTION] census withdrawal (item 6) — correct call, but note the operator-visible UI now declares nothing about attach; the strongest doc of that is the main.go:404 NOTE plus the README paragraph, both of which depend on a future slice actually landing the row.

Claim verification (author's claims vs what I found)

  • flag-off constructs no kube client: confirmed (strongly) — attach/wire.go:56 returns before touching opts.NewExecutor; the only production reach to NewKubeExecutor is the default closure at wire.go:53 (grep -rn NewKubeExecutor → no other production site); rest.InClusterConfig()/clientcmd.BuildConfigFromFlags exist only inside NewKubeExecutor (attach/kube.go:66,71). Counted, not hoped: TestWireDisabledConstructsNoKubeClient uses an atomic.Int64 and asserts 0, plus TestWireDisabledIgnoresAConfiguredKubeconfig (path configured, still 0 constructions). Both PASS. The disabled Bridge stores the kubeconfig string only (KubeConfigPath()), never opens the file. No path reads LD_ATTACH_KUBECONFIG's file or an in-cluster token while disabled; also the config layer refuses LD_ATTACH_KUBECONFIG + flag-off outright (config/load.go:134-141, FaultIncompleteAttach), so the ambiguous state can't even boot. LD_ATTACH appears nowhere outside lab-director/cockpit/ in the tree.
  • flag-off info endpoint 503s + audited: confirmed, with a precedence nuance — for an authenticated operator: 503, disabled:true, policy:P-ATT-001, Cache-Control: no-store, and exactly one attach_refused ledger entry (TestAttachInfoIsDisabledByDefaultAndAudited PASS; I read the handler: handleAttachInfo → Info → !b.opts.Enabled guard at bridge.go:277 → refuse). For an unauthenticated caller it is 401 (audited) and for a non-operator 403 (audited) — auth precedes the flag, which is the safer ordering and matches the README matrix rows. Both are still refusals before the flag is disclosed. Config.Attach.Enabled defaults false and = "1" is refused as a non-bool.
  • flag-off WS refuses before upgrade: confirmed — handler ordering is attachRequest (identity + term; attach.go:182) → Info (:192) → Upgrade (:210); with the flag off Info yields Status=503, so writeJSON + return at :196-201 executes and Upgrade is never reached. TestAttachWebSocketRefusesToUpgradeWhileDisabled PASS asserts conn == nil, resp.StatusCode == 503, and resp.Header.Get("Upgrade") == "" (no 101, no Upgrade header), plus one ledger entry. The attachable:false 200 case also returns 409 without upgrading (TestAttachWebSocketDoesNotUpgradeForANoTTYClass PASS). Auth is before the upgrade in the only WS ordering that exists.
  • every refusal audited: mostly confirmed — one gap — all bridge-side refusals go through refuseAbout → Auditor.Refused → Sink.Append (bridge.go:470-486), which sets AuditRecorded honestly and logs if the sink refuses; the transport reuses that same path via Bridge.Refuse (attach.go:147-161) so 401/403/400 are written, not swallowed. I enumerated every return in both handlers: the only early return that writes no ledger entry is the failed-Upgrade path (attach.go:213) — see finding 1. Also verified Info audits but never double-audits when the handler also logs a warn line (attach.go:194-208).
  • identity registry-side only: confirmed by construction — no code path can take identity from the request or the sandbox. attach.Request carries only {Sandbox, Caller, RequestID, Term}; the caller is built solely from the auth-resolved *auth.Identity (attach.go:88-101, attachCaller), and Sandbox.Identity is a field only a Registry can set — and the live registry is deliberately absent (only UnwiredRegistry + the tests' RecordingRegistry exist). The ld-<role> label is never read from a pod in this build, and HasTTY is a registry claim, not a pod label. A verified-but-unauthorized identity is recorded as PrincipalUnknown (attach.go:465-473), never as operator. Note this means the ratified "registry resolves ld-<role>" mechanism is a seam plus fixtures here, which the README states plainly — so the claim is honest, but it is not exercised against a real cluster.
  • scope == lab-director/cockpit only: confirmed — git diff --name-status origin/epic/LD3...HEAD → 27 paths, every one under lab-director/cockpit/ (25 code/docs + go.mod/go.sum). No web/**, no RBAC/CNP/manifest/prod/ci.yaml/dispatcher/herdr changes. The added config surface is exactly LD_ATTACH_ENABLED + LD_ATTACH_KUBECONFIG. go.mod adds only gorilla/websocket + k8s.io/client-go inside the cockpit module (the mechanism), not the grant. Any deployed env setting LD_ATTACH_* would have been a boot-regression risk from the new config refusal — I grepped the whole tree: nothing sets them.
  • e79ed6f census withdrawal: correct — the guard is real and would really fail. web/e2e/shell.spec.ts:77 declares sources = ['plane','gates','dispatcher','bus'] and :83 asserts getByTestId('source-row') toHaveCount(4); readmodel/readmodel.go:193 appends ExtraSources into snap.Sources, and web/src/routes/+page.svelte:83-84 renders one SourceRow per source — so the removed 5th row would have made the count 5 and failed the spec. That spec runs in CI (ci.yaml job cockpit-e2e → xvfb-run -a npx playwright test). The only alternative to withdrawing was editing web/e2e/shell.spec.ts, which is exactly the web/** scope violation this slice must not commit. The README's counter-claim ("the boot log, the --check OK line, the info endpoint, and the README") is accurate and I verified all three: wire.go:57-62 always logs "attach disabled: … NO kube client constructed" at boot, main.go:324 prints the OK attach enabled=false … line, and Info returns disabled:true + P-ATT-001. It is partially true that this hides the state from an operator who only reads the cockpit UI — but the README says so explicitly and the row has no UI consumer until the xterm.js pane slice lands, so carrying it would be a placeholder behind a failing-by-design guard. Withdrawing was the right scope-safe choice.

Verified by (commands you actually ran)

  • git diff --stat/--name-status origin/epic/LD3...origin/plane/HOMELAB-1683-attach-bridge → 27 files, +5659/-21, all under lab-director/cockpit/; no non-cockpit path.
  • git show origin/plane/…:{attach/{attach,bridge,wire,kube,registry,executor,auditor,protocol}.go,server/attach.go,server/middleware.go,server/server.go,audit/audit.go,config/{config,load,faults}.go,auth/middleware.go,cmd/cockpit/main.go} (read in full) → the guards/orderings quoted above.
  • git show e79ed6f + git show origin/epic/LD3:…/web/e2e/shell.spec.ts → the census row removal and the toHaveCount(sources.length) parity guard it cites.
  • grep -rn "source-row|data-source" web/src/ and grep -n source web/src/routes/+page.svelte → :83 {#each sources as source} → one row per source; grep -n ExtraSources readmodel/readmodel.go → :193 appended into snap.Sources.
  • grep -n "playwright|xvfb-run|cockpit-e2e" .forgejo/workflows/ci.yaml → the e2e job that runs shell.spec.ts.
  • git worktree add --detach … origin/plane/HOMELAB-1683-attach-bridge; GOFLAGS=-mod=mod GOPROXY=file:///home/aaron/go/pkg/mod/cache/download go build ./... → exit 0; go vet ./... → exit 0; go test ./attach/... ./server/... ./config/... ./audit/... → ok for all four packages.
  • go test ./attach/ -run TestWireDisabled -v → PASS ×2 (TestWireDisabledConstructsNoKubeClient, TestWireDisabledIgnoresAConfiguredKubeconfig).
  • go test ./server/ -run 'TestAttachInfoIsDisabledByDefaultAndAudited|TestAttachWebSocketRefusesToUpgradeWhileDisabled|TestAttachRequiresASessionAndRecordsTheAttempt|TestAttachRefusesANonOperatorAndRecordsWho' -v → PASS ×4.
  • Standalone repro (/tmp/wslog, clone of statusRecorder + gorilla, then deleted) → --- FAIL: RECORDED STATUS = 200 (comment claims 101); plus grep -n "Hijack\|checkSameOrigin" gorilla@…/server.go → Upgrade uses http.NewResponseController(w).Hijack() and checkOrigin = checkSameOrigin when nil.
  • End-to-end probe against the real server.New(...).Handler() in a scratch worktree (probe file removed afterwards) → handshake status = 101 while the cockpit logged msg=request method=GET path=/ws/attach/ld-light-1 status=200 bytes=0.
  • grep -rn "LD_ATTACH" (whole branch tree) → only lab-director/cockpit/{attach,config,cmd,README}.
  • grep -rn NewKubeExecutor|attach.Wire(|Enabled: *true --include=*.go → production construction sites: wire.go:53 only; attach.Wire called only at cmd/cockpit/main.go:159 and :314.
  • grep -n "Registry.Resolve|opts.Enabled" attach/bridge.go → Resolve only at :382, reachable only past the !b.opts.Enabled guards at :277 (Info) and :307 (Open).

VERDICT: APPROVE-WITH-CONCERNS

## Review 1/2 — safety & inertness (lens: provable inertness, flag-off) VERDICT: APPROVE-WITH-CONCERNS Inertness holds. I could not break it, and the central claim (disabled ⇒ no kube client, asserted as a *call count*) is real, not a hoped-for branch. Two enabled-mode findings (one unaudited refusal path, one demonstrably false code comment) keep this from a clean APPROVE; neither violates the flag-off property, scope, or identity rules, so neither is a blocker. ### Findings - [CONCERN] `lab-director/cockpit/server/attach.go:210-214` — a failed `Upgrade` is logged, never audited, so an authenticated attach attempt can leave zero ledger entries — repro: with `LD_ATTACH_ENABLED=true` and a resolvable sandbox, send `GET /ws/attach/ld-light-1` with a valid operator session but **no `Connection: Upgrade` header**. `attachRequest` passes (no refusal written), `Info` returns `attachable:true` (a success is deliberately not audited), gorilla replies `400`, the handler logs `"attach upgrade refused"` and returns → `0` ledger entries for an operator who asked for a shell. This is the one early return that skips the audit write; it contradicts the README's "**Every refusal is a ledger entry**" and P-ATT-005 ("an attempt that leaves no record is indistinguishable from an attempt that never happened"). Flag-off is unaffected (Info returns 503 first, audited), so inertness is intact — this is an enabled-mode completeness gap, not a safety regression. - [CONCERN] `lab-director/cockpit/server/middleware.go:206-211` — the `Hijack` doc-comment's parenthetical "(the access log line this recorder produces is whatever status was written before the upgrade (101))" is **false**; the access log records **200 with 0 bytes** for a successful attach handshake — evidence: gorilla v1.5.4's `Upgrade` takes `http.NewResponseController(w).Hijack()` and writes the `101` handshake straight to the hijacked `bufio` writer; it never calls `w.WriteHeader`, so `statusRecorder` (initialised `status: http.StatusOK` at `middleware.go:83`) keeps 200, and hijacked bytes never reach `statusRecorder.Write` — so `bytes` stays 0. End-to-end probe through the real `Server.Handler()`: client saw `handshake status = 101`, the cockpit logged `msg=request ... path=/ws/attach/ld-light-1 status=200 bytes=0 duration_ms=0`. Why it matters: the comment claims the log is trustworthy for the upgrade, when in fact the only access-log line an attach session produces is indistinguishable from an ordinary 200 and reports no byte count — a monitor/operator can't audit attach sessions from the access log. (I re-derived this twice: a standalone clone of `statusRecorder` + gorilla, and a temp probe against this diff's `server` package; both failed the `101` claim.) - [SUGGESTION] `lab-director/cockpit/cmd/cockpit/main.go:324` — `kube_client_constructed=%t` is printed from `checkBridge.Enabled()`, i.e. it restates the flag, it does not measure construction. The real proof is the unit call-count; the operator-visible `--check` column is therefore weaker evidence than the README implies ("asserted by `TestWireDisabledConstructsNoKubeClient`" is true of the test, not of the printed column). A stronger column would print something observed (e.g. an `Executable()`/`hasExecutor` accessor on `Bridge` that is structurally nil on the disabled path). - [SUGGESTION] `lab-director/cockpit/server/attach.go:65` — `attachUpgrader` leaves `CheckOrigin` nil, so same-origin enforcement is *implicit* (gorilla's `checkSameOrigin` default, confirmed in the vendored source at `server.go:148-153`). The comment is accurate today (and correctly discloses that a **missing** Origin is accepted, which is right for non-browser clients), but a future gorilla default change would silently remove the cross-origin guard with no test to catch it. Consider setting `CheckOrigin` explicitly to the documented policy. - [SUGGESTION] census withdrawal (item 6) — correct call, but note the operator-visible UI now declares nothing about attach; the strongest doc of that is the `main.go:404` NOTE plus the README paragraph, both of which depend on a future slice actually landing the row. ### Claim verification (author's claims vs what I found) - flag-off constructs no kube client: **confirmed (strongly)** — `attach/wire.go:56` returns before touching `opts.NewExecutor`; the *only* production reach to `NewKubeExecutor` is the default closure at `wire.go:53` (`grep -rn NewKubeExecutor` → no other production site); `rest.InClusterConfig()`/`clientcmd.BuildConfigFromFlags` exist only inside `NewKubeExecutor` (`attach/kube.go:66,71`). Counted, not hoped: `TestWireDisabledConstructsNoKubeClient` uses an `atomic.Int64` and asserts `0`, plus `TestWireDisabledIgnoresAConfiguredKubeconfig` (path configured, still 0 constructions). Both PASS. The disabled `Bridge` stores the kubeconfig **string** only (`KubeConfigPath()`), never opens the file. No path reads `LD_ATTACH_KUBECONFIG`'s file or an in-cluster token while disabled; also the config layer refuses `LD_ATTACH_KUBECONFIG` + flag-off outright (`config/load.go:134-141`, `FaultIncompleteAttach`), so the ambiguous state can't even boot. `LD_ATTACH` appears nowhere outside `lab-director/cockpit/` in the tree. - flag-off info endpoint 503s + audited: **confirmed, with a precedence nuance** — for an authenticated operator: `503`, `disabled:true`, `policy:P-ATT-001`, `Cache-Control: no-store`, and exactly one `attach_refused` ledger entry (`TestAttachInfoIsDisabledByDefaultAndAudited` PASS; I read the handler: `handleAttachInfo` → `Info` → `!b.opts.Enabled` guard at `bridge.go:277` → `refuse`). For an unauthenticated caller it is `401` (audited) and for a non-operator `403` (audited) — auth precedes the flag, which is the safer ordering and matches the README matrix rows. Both are still refusals before the flag is disclosed. `Config.Attach.Enabled` defaults false and `= "1"` is refused as a non-bool. - flag-off WS refuses before upgrade: **confirmed** — handler ordering is `attachRequest` (identity + term; `attach.go:182`) → `Info` (`:192`) → `Upgrade` (`:210`); with the flag off `Info` yields `Status=503`, so `writeJSON` + `return` at `:196-201` executes and `Upgrade` is never reached. `TestAttachWebSocketRefusesToUpgradeWhileDisabled` PASS asserts `conn == nil`, `resp.StatusCode == 503`, **and `resp.Header.Get("Upgrade") == ""`** (no 101, no Upgrade header), plus one ledger entry. The `attachable:false` 200 case also returns `409` without upgrading (`TestAttachWebSocketDoesNotUpgradeForANoTTYClass` PASS). Auth is before the upgrade in the only WS ordering that exists. - every refusal audited: **mostly confirmed — one gap** — all bridge-side refusals go through `refuseAbout` → `Auditor.Refused` → `Sink.Append` (`bridge.go:470-486`), which sets `AuditRecorded` honestly and logs if the sink refuses; the transport reuses that same path via `Bridge.Refuse` (`attach.go:147-161`) so 401/403/400 are written, not swallowed. I enumerated every return in both handlers: the only early return that writes no ledger entry is the failed-`Upgrade` path (`attach.go:213`) — see finding 1. Also verified `Info` audits but never double-audits when the handler also logs a warn line (`attach.go:194-208`). - identity registry-side only: **confirmed by construction** — no code path can take identity from the request or the sandbox. `attach.Request` carries only `{Sandbox, Caller, RequestID, Term}`; the caller is built solely from the auth-resolved `*auth.Identity` (`attach.go:88-101`, `attachCaller`), and `Sandbox.Identity` is a field only a `Registry` can set — and the live registry is deliberately absent (only `UnwiredRegistry` + the tests' `RecordingRegistry` exist). The `ld-<role>` label is never read from a pod in this build, and `HasTTY` is a registry claim, not a pod label. A verified-but-unauthorized identity is recorded as `PrincipalUnknown` (`attach.go:465-473`), never as operator. Note this means the ratified "registry resolves `ld-<role>`" mechanism is a **seam plus fixtures here**, which the README states plainly — so the claim is honest, but it is not exercised against a real cluster. - scope == lab-director/cockpit only: **confirmed** — `git diff --name-status origin/epic/LD3...HEAD` → 27 paths, every one under `lab-director/cockpit/` (25 code/docs + `go.mod`/`go.sum`). No `web/**`, no RBAC/CNP/manifest/prod/`ci.yaml`/dispatcher/herdr changes. The added config surface is exactly `LD_ATTACH_ENABLED` + `LD_ATTACH_KUBECONFIG`. `go.mod` adds only `gorilla/websocket` + `k8s.io/client-go` inside the cockpit module (the mechanism), not the grant. Any deployed env setting `LD_ATTACH_*` would have been a boot-regression risk from the new config refusal — I grepped the whole tree: nothing sets them. - e79ed6f census withdrawal: **correct** — the guard is real and would really fail. `web/e2e/shell.spec.ts:77` declares `sources = ['plane','gates','dispatcher','bus']` and `:83` asserts `getByTestId('source-row')` `toHaveCount(4)`; `readmodel/readmodel.go:193` appends `ExtraSources` into `snap.Sources`, and `web/src/routes/+page.svelte:83-84` renders one `SourceRow` per source — so the removed 5th row would have made the count 5 and failed the spec. That spec runs in CI (`ci.yaml` job `cockpit-e2e` → `xvfb-run -a npx playwright test`). The only alternative to withdrawing was editing `web/e2e/shell.spec.ts`, which is exactly the `web/**` scope violation this slice must not commit. The README's counter-claim ("the boot log, the `--check` OK line, the info endpoint, and the README") is accurate and I verified all three: `wire.go:57-62` always logs `"attach disabled: … NO kube client constructed"` at boot, `main.go:324` prints the `OK attach enabled=false …` line, and `Info` returns `disabled:true` + `P-ATT-001`. It is **partially** true that this hides the state from an operator who only reads the cockpit UI — but the README says so explicitly and the row has no UI consumer until the xterm.js pane slice lands, so carrying it would be a placeholder behind a failing-by-design guard. Withdrawing was the right scope-safe choice. ### Verified by (commands you actually ran) - `git diff --stat/--name-status origin/epic/LD3...origin/plane/HOMELAB-1683-attach-bridge` → 27 files, +5659/-21, all under `lab-director/cockpit/`; no non-cockpit path. - `git show origin/plane/…:{attach/{attach,bridge,wire,kube,registry,executor,auditor,protocol}.go,server/attach.go,server/middleware.go,server/server.go,audit/audit.go,config/{config,load,faults}.go,auth/middleware.go,cmd/cockpit/main.go}` (read in full) → the guards/orderings quoted above. - `git show e79ed6f` + `git show origin/epic/LD3:…/web/e2e/shell.spec.ts` → the census row removal and the `toHaveCount(sources.length)` parity guard it cites. - `grep -rn "source-row|data-source" web/src/` and `grep -n source web/src/routes/+page.svelte` → `:83 {#each sources as source}` → one row per source; `grep -n ExtraSources readmodel/readmodel.go` → `:193` appended into `snap.Sources`. - `grep -n "playwright|xvfb-run|cockpit-e2e" .forgejo/workflows/ci.yaml` → the e2e job that runs `shell.spec.ts`. - `git worktree add --detach … origin/plane/HOMELAB-1683-attach-bridge`; `GOFLAGS=-mod=mod GOPROXY=file:///home/aaron/go/pkg/mod/cache/download go build ./...` → exit 0; `go vet ./...` → exit 0; `go test ./attach/... ./server/... ./config/... ./audit/...` → **ok** for all four packages. - `go test ./attach/ -run TestWireDisabled -v` → `PASS` ×2 (`TestWireDisabledConstructsNoKubeClient`, `TestWireDisabledIgnoresAConfiguredKubeconfig`). - `go test ./server/ -run 'TestAttachInfoIsDisabledByDefaultAndAudited|TestAttachWebSocketRefusesToUpgradeWhileDisabled|TestAttachRequiresASessionAndRecordsTheAttempt|TestAttachRefusesANonOperatorAndRecordsWho' -v` → `PASS` ×4. - Standalone repro (`/tmp/wslog`, clone of `statusRecorder` + gorilla, then deleted) → `--- FAIL: RECORDED STATUS = 200 (comment claims 101)`; plus `grep -n "Hijack\|checkSameOrigin" gorilla@…/server.go` → `Upgrade` uses `http.NewResponseController(w).Hijack()` and `checkOrigin = checkSameOrigin` when nil. - End-to-end probe against the real `server.New(...).Handler()` in a scratch worktree (probe file removed afterwards) → `handshake status = 101` while the cockpit logged `msg=request method=GET path=/ws/attach/ld-light-1 status=200 bytes=0`. - `grep -rn "LD_ATTACH"` (whole branch tree) → only `lab-director/cockpit/{attach,config,cmd,README}`. - `grep -rn NewKubeExecutor|attach.Wire(|Enabled: *true --include=*.go` → production construction sites: `wire.go:53` only; `attach.Wire` called only at `cmd/cockpit/main.go:159` and `:314`. - `grep -n "Registry.Resolve|opts.Enabled" attach/bridge.go` → `Resolve` only at `:382`, reachable only past the `!b.opts.Enabled` guards at `:277` (Info) and `:307` (Open). VERDICT: APPROVE-WITH-CONCERNS
Author
Owner

Landing adjudication — PR #579 (LD3.3 attach bridge, code slice)

Head reviewed: ca9022f (verified unmerged). Combined CI on ca9022f: success, 8/8 contexts
(CI Review ×6 + Build Lab Director Cockpit Image ×2, runs #1719/#1720). Builder's "all 7 green"
is confirmed and one better: 8 contexts.

Two fresh-context reviews, both at head ca9022f, both APPROVE-WITH-CONCERNS, 0 blockers:

  • Review 1/2 (safety & inertness) — verdict above.
  • Review 2/2 (protocol & correctness) — verdict above.

Verified myself (spot-check against primary sources, not the reviewers' summaries):

  • Flag-off inertness holds: attach/wire.go returns New(Options{Enabled:false}) on the disabled
    path without ever calling opts.NewExecutor; NewKubeExecutor has exactly one production call
    site (the default closure). Config defaults LD_ATTACH_ENABLED=false, and LD_ATTACH_KUBECONFIG
    with the flag off is a boot fault (FaultIncompleteAttach), so the ambiguous state cannot start.
  • Auth/resolve precedes the upgrade and the flag: attachRequest → Info → Upgrade; flag-off
    Info is 503 + one audited attach_refused, no 101, no Upgrade header.
  • Identity is registry-side only by construction: the request carries no identity; the caller is built
    from *auth.Identity; the live registry is absent from this build.
  • Scope is exactly lab-director/cockpit/** (27 files). No RBAC/CNP/manifest/prod/web/ci.yaml/
    dispatcher/herdr — i.e. M1/S1/§4/§5 remain unexercised, as the ratification package requires.
  • e79ed6f census withdrawal: correct. web/e2e/shell.spec.ts asserts exactly 4 source rows and
    the spec runs in CI; the alternative was touching web/**, out of scope. Withdrawal is documented
    in the README and main.go's NOTE. I additionally corrected the stale PR-description sentence
    that still claimed a census row.

Classifications (non-blocking, follow-up): the 5 concerns are all enabled-mode or doc/test-fidelity
items — the failed-Upgrade path is unaudited (enabled + wired-registry only, unreachable today);
the statusRecorder.Hijack comment's "(101)" is false (probe: status=200 bytes=0); the unreachable
read-limit branch mislabels an oversize frame as client_eof/closed; writeAttachError sends no
close frame despite the grammar; the close-entry failure is log-only (needs a durable sink to be
reachable). None break flag-off inertness, scope, identity, or the audit-open path. They belong to the
xterm.js pane / durable-sink slices.

Decision: merge. Landing-lock legs: head-sha CI green ✅ · verdicts posted after ca9022f ✅ · no
open blocker ✅. GitOps: no image, no manifests, nothing to roll out (flag off).

## Landing adjudication — PR #579 (LD3.3 attach bridge, code slice) Head reviewed: `ca9022f` (verified unmerged). Combined CI on `ca9022f`: **success**, 8/8 contexts (`CI Review` ×6 + `Build Lab Director Cockpit Image` ×2, runs #1719/#1720). Builder's "all 7 green" is confirmed and one better: 8 contexts. Two fresh-context reviews, both at head `ca9022f`, both **APPROVE-WITH-CONCERNS, 0 blockers**: - Review 1/2 (safety & inertness) — verdict above. - Review 2/2 (protocol & correctness) — verdict above. **Verified myself (spot-check against primary sources, not the reviewers' summaries):** - Flag-off inertness holds: `attach/wire.go` returns `New(Options{Enabled:false})` on the disabled path without ever calling `opts.NewExecutor`; `NewKubeExecutor` has exactly one production call site (the default closure). Config defaults `LD_ATTACH_ENABLED=false`, and `LD_ATTACH_KUBECONFIG` with the flag off is a **boot fault** (`FaultIncompleteAttach`), so the ambiguous state cannot start. - Auth/resolve precedes the upgrade and the flag: `attachRequest` → `Info` → `Upgrade`; flag-off `Info` is `503` + one audited `attach_refused`, no `101`, no `Upgrade` header. - Identity is registry-side only by construction: the request carries no identity; the caller is built from `*auth.Identity`; the live registry is absent from this build. - Scope is exactly `lab-director/cockpit/**` (27 files). No RBAC/CNP/manifest/prod/web/ci.yaml/ dispatcher/herdr — i.e. M1/S1/§4/§5 remain unexercised, as the ratification package requires. - `e79ed6f` census withdrawal: **correct**. `web/e2e/shell.spec.ts` asserts exactly 4 source rows and the spec runs in CI; the alternative was touching `web/**`, out of scope. Withdrawal is documented in the README and `main.go`'s NOTE. I additionally **corrected the stale PR-description sentence** that still claimed a census row. **Classifications (non-blocking, follow-up):** the 5 concerns are all *enabled-mode* or doc/test-fidelity items — the failed-`Upgrade` path is unaudited (enabled + wired-registry only, unreachable today); the `statusRecorder.Hijack` comment's "(101)" is false (probe: `status=200 bytes=0`); the unreachable read-limit branch mislabels an oversize frame as `client_eof`/`closed`; `writeAttachError` sends no close frame despite the grammar; the close-entry failure is log-only (needs a durable sink to be reachable). None break flag-off inertness, scope, identity, or the audit-open path. They belong to the xterm.js pane / durable-sink slices. **Decision: merge.** Landing-lock legs: head-sha CI green ✅ · verdicts posted after `ca9022f` ✅ · no open blocker ✅. GitOps: no image, no manifests, nothing to roll out (flag off).
aaron merged commit fafd1caf5e into epic/LD3 2026-10-02 07:21:41 +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!579
No description provided.