From 3dd5294d8f167c09f26e49604fc4368f4f9541b8 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 21 Oct 2026 23:29:59 -0700 Subject: [PATCH 001/139] Add pane preview design spec --- .../specs/2026-10-21-pane-preview-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-21-pane-preview-design.md diff --git a/docs/superpowers/specs/2026-10-21-pane-preview-design.md b/docs/superpowers/specs/2026-10-21-pane-preview-design.md new file mode 100644 index 000000000..a559abc90 --- /dev/null +++ b/docs/superpowers/specs/2026-10-21-pane-preview-design.md @@ -0,0 +1,143 @@ +# Pane Preview: watching and opening what a pane's process shows + +Date: 2026-10-21. Status: draft for review. Branch: `feat/pane-preview`. +Implements one user ruling set: watch follows `view`, use follows `edit`; the full ladder (forward, CDP view, virtual display) ships in this arc; ports are auto-detected with an explicit pin beside them. + +## 1. Why + +A pane starts a dev server, an app, or a browser doing automation. Its terminal already streams, and that covers the typing and the logs. What it cannot cover is the *rendered* result: the page the server is serving, the page the automation is driving, the window the GUI app drew. Today a person reaches for a mesh plugin and a hand-run VNC server, or gives up. This spec adds a first-party **Preview** surface to the pane view: see the browser, open the web app, and (on Linux) watch or drive a pane's virtual screen. + +Two decisions from the idea phase are load-bearing: + +- **It is not a plugin.** Plugins load in the control-plane process only, and a node holds no plugin code (spec 2026-09-10 §inversion, root `AGENTS.md`). Anything that runs on a node is first-party daemon code. The plugin contract is left exactly as it is. +- **The transport reuses the relay lane's shape, not the SSH agent's semantics.** The `relay` link frame pairs two endpoints by an opaque ref and blind-routes opaque blobs between them (`ssh-relay.service.ts` `createRelayBroker` / `routeRelayFrame`; the frame grammar in `node-frames.ts:1446+`). Preview needs the same pairing with a *browser* on one end, which no existing lane carries (the relay pair is agent↔node by `isRelayDirection`, `node-frames.ts:1424`). + +## 2. The three surfaces, and the grant each one sits at + +| surface | what it is | grant | why | +|---|---|---|---| +| **Open** | a live proxy to a port the pane listens on: HTTP and websocket, rendered as an iframe or a new tab | `edit` | an HTTP forward is authority: it can POST anything as a trusted localhost client | +| **Watch** | a read-only live view of a browser the pane is driving, via the node speaking CDP to loopback and streaming `startScreencast` frames to the dashboard | `view` | it is exactly the read-only-terminal posture moved one layer up; observation, not input | +| **Screen** | a virtual display (Linux): the pane runs on an allocated `DISPLAY`, the node runs `x11vnc` on loopback, and the dashboard renders it with vendored noVNC over an Open lane | `edit` | RFB carries input whether or not the UI exposes it; it cannot honestly sit at `view` | + +The existing card/pane **screen preview** (tmux capture lines, `live-frames.ts` `preview` frame) keeps its name in code and prose as "screen preview"; it already covers TUI panes and is untouched by this design. + +## 3. Architecture + +``` + browser (dashboard SPA) control plane node (daemon) + ─────────────────────── ─────────────── ───────────── + iframe / new tab ─HTTP─────▶ /pv/:session/ ◀─pair by ref─▶ preview_data frames ▶ dial 127.0.0.1:PORT + app WS + HMR, noVNC RFB ─WS▶ /pv/:session/ (in-process (new link frame, loopback-only, capped + byte pump, capped blob) blob) + Watch canvas ◀──frames── /ws/live ◀── preview_stream event ── node CDP bridge: + subscribers fan-out /json/list, startScreencast, acks + Preview panel ◀── GET /api/subshells/:id/preview (ports list; plane RPC → node) + open/watch POSTs ──cookie──▶ gate check, signed command to node, session created under /pv/:session/ + (the 128-bit path secret IS the lane credential; there is no token to mint) +``` + +One lane mechanism, three consumers. The plane pairs each session by an opaque ref exactly as the ssh-relay broker does, pumps capped blobs both ways, and never parses the payload it moves (it does *see* proxied bytes in transit; that is accepted, §8). + +## 4. Protocol: bump 22 → 23, shipped together + +Doctrine copied from the bump history (`node-frames.ts:80-187`): additive in shape, breaking anyway because the gate is exact-match; server first; `MIN_NODE_VERSION` and `apps/node/agent/package.json` rise in the same commit (`docs/node-gates.md`). There is no feature flag and there will not be one: a lagging agent is held (update-only) until the desktop update path crosses it, the same doctrine `ssh_enabled` (16) and the agent relay (18) used, because a capability gate cannot live with "old agent ignored the frame" reading the same as "new agent answered no." + +Bump-history entry to land with the code: + +> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch; a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. + +New **signed commands** (`node-frames.ts` grammar + census test, `node-results.ts` validators for each answer): + +- `preview_open { ref, kind: "forward" | "screencast", paneId, port, fps?, lifetimeMs }` + forward: dial `127.0.0.1` / `::1` only, refuse anything else, answer `{ok, pending:true}` and connect off the exec chain (the `ssh-relay-exec.ts` A-branch precedent: a hung target must not park unrelated commands). screencast: start or attach the node-side CDP bridge for (paneId, port), answer `{ok, subscribers}`. +- `preview_close { ref, reason }` - never gated (teardown is not a preview act, the `set_ssh_enabled` doctrine); unknown ref answers `{ok, closed:false}` and tombstones briefly so a late ack cannot orphan a socket. +- `preview_ports_get { paneId }` - answer `{ ports: [{proto, port, pid}], cdpPort: number|null, screen: {display, rfbPort}|null }`. Linux: TCP listeners attributed by socket inode across the pane's tmux process tree (`TmuxRunner.panePid`, `tmux-runner.ts:447`). `cdpPort` is set when a listed port answers `GET /json/version`. Non-Linux: refuse by name; a hand-pinned port still opens (dialing loopback needs no detection). +- `set_preview_enabled { enabled }` - the owner switch, plane is sole author (the `set_ssh_enabled` shape exactly: mirror file, `ready.previewEnabled`, change event, fail-closed when the mirror is missing). + +New **link frames**: + +- `preview_data { ref, dir: "in" | "out", blob }` - base64 in-JSON like `RelayFrame`; cap `PREVIEW_FRAME_MAX_BYTES = 262_144` (constant beside the relay caps in `ssh-limits.ts`, which becomes the home of shared link-lane limits). A sender exceeding the cap gets the session closed with a named reason, not fragmentation, in v1. +- `preview_stream { ref, seq, w, h, ts, blob }` - event direction (node→plane). Screencast JPEG at quality 60, `everyNthFrame` tuned to ≤ 5 fps (bridge clamps `fps` to [1, 5]). Plane fans the identical blob out to that session's subscribers on their `/ws/live` sockets; it is the one plane-side broadcast that carries pane *content*, and the justification is §8. + +New **launch field**: `launch.screen?: { width, height }`. A node below the feature (never reached post-bump) or a machine with the switch off refuses the launch naming the remedy, before spawning anything. + +## 5. Node side + +Executor arms live beside the relay arms (`commands/ssh-relay-exec.ts` is the shape to copy, `commands/index.ts` the dispatch switch to extend) and honor the daemon's contract: verified command only, result before anything long-running (`pending:true` + off-chain connect), timers self-expiring so a forgotten session dies on the machine rather than parking, never `process.exit`, refusals that name ids and remedies. + +**Forward session.** Dial loopback; on connect, feed the existing deliver-or-throw pump (`relay-send.ts`) with `preview_data` frames. Node-side backpressure: bytes the link will not take accumulate to 1 MiB, then the session closes with reason `slow-link` (plane mirrors it to the browser). On socket drop or `preview_close`, the local socket is destroyed; the daemon's death takes every socket with it. + +**CDP bridge** (`preview_bridge.ts`, one per `(paneId, port)`): `GET /json/list` → newest `page` target → WebSocket connect → `Page.enable`, `Page.startScreencast`. Frames are acked immediately (`Page.screencastFrameAck`) whether or not any viewer is slow: this is observation, so the bridge's backpressure is *dropping*, never the browser's. Subscribers are refcounted plane-side: the node hears `preview_open` only on the zero→one transition and `preview_close` on one→zero, so a tenth watcher costs the node nothing. Idle 60 s with zero subscribers → bridge closes itself. A browser that quits mid-watch closes the bridge with a named reason the plane turns into `subshell-recheck`-shaped UI state ("browser gone"), not a dead panel. + +**Virtual display** (Linux only, and the spec says so out loud rather than degrading): display ids allocated `:90+N` from a per-node lockfile; per-pane state under `/displays//` (dir 0700, files 0600) following the `/ssh//` precedent including the hourly orphan sweep and byte-re-derived paths (`ssh-render.ts` doctrine: what the wire claims is checked against the machine's own derivation, never trusted). On a `screen` launch: start `Xvfb :N -auth ...`, write the pinned env, spawn the pane with `DISPLAY=:N`; start `x11vnc -display :N -localhost -rfbport ` bound to loopback, **no RFB password** (authentication is the lane and the plane's grant check; a password the plane would store adds nothing). Missing `Xvfb` or `x11vnc` refuses the launch/`preview_ports_get.screen` with the binary named; nothing else about the pane is held hostage (a `screen` pane whose server died keeps running; the Screen button reads why). Pane exit tears the display down (`reportDeath` / the plane's death hooks); the sweep catches what a crash orphaned. + +**Ports walk.** One pass per `preview_ports_get`, no standing watcher: read `netlink`/`/proc/net/tcp{,6}` once (plain `/proc` parsing, no new dependency), collect socket inodes, walk the pane pid's descendants via `/proc/*/stat` `PPid` fields, match inodes. Cache server-side (§6) instead of node-side; the node stays stateless here, which is the kind of thing that does not rot when a pane forks weirdly. + +## 6. Plane side + +**REST** (cookie-only routes under the subshells group, visibility resolved by the existing per-subshell gate; bearer actors get the same gate result they get for terminal input): + +- `GET /api/subshells/:id/preview` - `view`+. Plane RPCs `preview_ports_get` (cached 5 s per pane, one shared cache so ten tabs cost one walk) and answers `{ ports, cdpPort, screen, nodeEnabled }`. Node offline or too-old answers the same shape with `nodeEnabled: false` and a reason the button tooltips. +- `POST /api/subshells/:id/preview/open { port }` - `edit` only. Gate check, mint session (a fresh 128-bit path secret, bound to creator + pane + port + ref), send `preview_open`, await the delivered ack, return `{ proxyBase: "/pv/:session/" }`. The response is the only place the secret appears. Audits `node.preview_forward.open`. +- `POST /api/subshells/:id/preview/watch { port? }` (port defaults to `cdpPort`) - `view`+, idempotent (a second watcher joins the bridge, plane-side refcount). Sends `preview_open` kind `screencast` when the bridge is new. Audits once per bridge, not per watcher. +- `DELETE .../preview/watch` - unsubscribe; last one out closes the bridge with `preview_close`. + +**Session lifecycle, and why there is no token.** Creation is a cookie-authed REST call, so it carries the same check every attach token would re-ask at redemption; afterward, the 128-bit `:session` path secret is the lane credential, and an attacker who cannot read the JSON response cannot use the lane. A token store (and the `ws-token.ts` single-use machinery, cap, and disable-drop walk it needed) would guard nothing this model does not already guard at the door. What replaces the disable-drop: the plane indexes live sessions by creator, and **account disable closes them** with reason `revoked`, exactly the argument `dropUserTokensFor` makes for tokens (the socket could otherwise re-open the access the disable ended). Grant revocation closes the same way, riding the revocation cuts the relay broker already has; pane death, idle, and cap closes carry their own named reasons. + +**Browser lanes.** One new surface, mounted beside the SPA routes (not under `/api`, so HMR-relative clients stay sane): `/pv/:session/*`. `:session` is a fresh 128-bit path secret bound to the creating viewer. Rules: strip browser `Cookie` **inbound**; hold upstream `Set-Cookie` in an in-memory per-session jar, rewritten to `Path=/pv/:session/` and attached to lane requests only, so a node app can never touch a plane cookie and a plane cookie never leaves the plane origin. Rewrite `Host` to `127.0.0.1:port` (dev servers' host checks need this; a Vite-style app may still need `allowedHosts` - documented, not silently patched). Strip only the `frame-ancestors` directive from upstream CSP so the iframe can embed at all; everything else passes untouched, and the spec records that overriding an embedded app's framing policy is exactly what this feature promises, on a trusted network. **WS upgrades under `/pv/:session/` are the raw tunnel for everything websocket-shaped** (HMR, app sockets, noVNC's RFB): the session secret authorizes the upgrade, it is refused on a cross-site `Origin` (the rebinding-shaped check `attach` relies on for cookies has its analogue here in the unguessable path plus this Origin check), and bytes pump both directions blind. Before the node's connect ack lands, browser-side bytes buffer to the same 1 MiB cap, then the session closes `slow-link` the same way - no phantom success while a port is still dialing. + +**Screencast fan-out.** `/ws/live` gains frames (both ends share `live-frames.ts`, as designed): server `{ type: "preview-stream", id, seq, w, h, blob }`; client `{ type: "watch", id }` / `{ type: "unwatch", id }`. Subscribing re-checks visibility (the feed's own authorization path; **the §11.14 exhaustive diff test between the two paths grows preview rows as a required task, not a follow-up**). A `subshell-recheck`/`subshell-gone` for a watched pane unsubscribes and refcounts the bridge client-side, and the plane independently drops that viewer from fan-out on any revocation cut it already performs. + +**Limits and lifetimes** (constants beside the relay's in `ssh-limits.ts`, enforced plane-side where addressable and node-side where the socket lives): forward session idle 15 min (either side silent); max 4 concurrent forwards per pane, 16 per node; max 8 watch subscribers per bridge; every close carries a named reason (`viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`) in both the refusal copy and the audit row. All state is in-process, consistent with the relay registry and the accepted single-process posture (§11 bus caveat). + +**Audit** (names join the inventory in `apps/server/api/AGENTS.md`; metadata carries ids, port, kind, named reason, and counts, never byte content): `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, plus `node.preview_enabled.update` for the switch (mirrors `ssh_enabled.update`). + +**The owner switch.** Node Settings gains "Preview" (owner, admin on `local`, mirroring `ssh_enabled`'s gate): default **off**, fail-closed, plane pushes the mirror, refuses implicit-enablement (unlike SSH's launch-time implicit enable, preview has no launch-time reason to turn itself on except an explicit `screen` launch, which requires the switch already on). + +## 7. Dashboard + +Pane detail (`routes/subshells_.$id.tsx`, the same terminal-wrapper column) gains a **Preview** section under the terminal: + +- Port list from `GET /preview`, refreshed on pane focus and after any pane-output burst with visible throttling (the polling-routes exclusion keeps it out of request logs). Each row: port, detected kind (server / browser-debug / screen), **Open** (tooltip-disabled with the real reason when `access === "view"` or the node switch is off), **Watch** on the browser row. +- Open renders a collapsible side panel holding the `/pv/` iframe with an "open in new tab" affordance; Watch renders a canvas fed by `preview-stream` frames with an fps/age badge and an explicit "still there?" staleness rule (no frame for 5 s reads stale, matching the feed's posture rather than showing a frozen page as live). +- Screen opens the noVNC canvas over a forward session (`edit`-gated) pointed at the pane's `screen.rfbPort`, RFB bytes riding `/pv/:session/` upgrades. +- Gating reuses the existing trio: disabled control + `disabledReason` tooltip per the design system; `view` vs `edit` read from the row's resolved `access` exactly as `subshell-terminal.tsx` does for stdin. Disclosure: the foreign-node icon and banner machinery (`use-trust-notices`, `TrustIndicators`) is unchanged, but the Preview section raises the **shared-pane** banner variant once per device when first opened, because this surface shows more than the terminal does. + +**noVNC**: vendored `core/rfb.js` + `include/` subset at a pinned tag, MPL-2.0 notice files kept beside it, loaded lazily as a dynamic chunk. (Licensing: MPL at file level composes with the SPA's AGPL; `lint:licenses` sees no package edge because there is no package - the vendored LICENSEs are the record.) + +**Mobile**: Watch renders frames fine. Open/Screen are desktop-first: the panel simply does not render on phone layouts, the same way the dock already trades tiling for tabs. + +## 8. Security posture, stated plainly + +The delta this feature adds, against the threat model's own frame (trusted network, OS-user boundary is the trust boundary, `docs/security.md` §0-§1): + +1. **It exposes pane loopback ports through the plane.** Bounded: dial targets are only `127.0.0.1`/`::1`, gated per pane per viewer by the existing visibility model at `edit` (Open/Screen) or `view` (Watch), expiring, revoked with grants and with account disable, audited by name. It does not open any listener on the node beyond loopback, and nothing may bind non-loopback. +2. **The plane sees proxied bytes.** Unlike the ssh-agent relay's seal, the forward lane is not end-to-end encrypted through the plane. That is deliberate and cheap to say: a trusted-network posture already lets the plane read every pane's screen and every keystroke, and E2EE-ing RFB/HMR streams would buy nothing against the threat the seal was defending (the plane deciding what a key signs). A future plane-hostile deployment is §12 territory, not this feature's job. +3. **RFB streams ride `/ws/live`.** Screencast frames are pane content on the broadcast feed, so they get the feed's authorization machinery plus the diff test, or they do not ship. +4. **`/pv/:session/` is a same-origin proxy**, which is why cookies are stripped inbound and jarred per session: the point is that an embedded node app can never read plane credentials, and the secret path plus same-site posture stands in for what a separate origin would normally provide. The honest residual: an XSS in the *embedded app* runs in the plane's origin scoped to that iframe - on a network where the OS user already owns the machine and the plane already owns the machine's OS user, this is inside the model, not outside it. `frame-ancestors` on the plane's own SPA headers stays a separate decision this spec neither makes nor blocks (today there are no CSP headers at all; that gap gets its own note, not smuggled in here). +5. **The owner switch keeps a node owner in the loop** the way `ssh_enabled` does. Nobody's machine starts serving previews because a viewer wanted one. + +## 9. Failure modes worth naming + +Node offline / held / too-old: panel reads the reason (the gates doc's refusals already name remedies), controls stay inert. Pane exits: everything closes with `pane-gone`, screen teardown follows the death hook, the sweep catches the crash case. Viewer lag: plane-side per-socket byte debt closes that one viewer's lane with `slow-link` and tells it why; a laggy noVNC never degrades the pane's owner's own view. CDP port that is not CDP (pin mistake): bridge refuses within the handshake budget, `preview_watch.close` reason `not-cdp`. Xvfb or x11vnc vanishing mid-pane-life: Screen reads stale with the binary named; the terminal keeps streaming. Multi-tab: Open is per-tab session; Watch refcounts share one bridge. + +## 10. Testing + +- **Protocol**: parse/census tests for every new frame/command in `node-frames.test.ts` style, validators both-ends per `node-results.ts` header rule. +- **Node**: executor tests with fake sockets on the `__tests__/commands-ssh-relay.test.ts` precedent (dial refusal on non-loopback, backpressure close, bridge ack-while-unwatched, tombstoned close); ports walk against fixture `/proc` trees (inject the reader, Linux-gated test); display allocation/teardown against stub binaries; retention sweep reuse of the ssh-dir test pattern. +- **Plane**: session-lifecycle tests (creation gate, secret unguessability, disable closes live sessions `revoked`, grant-revocation cut reaches the lane); route tests on the auth-tables helpers (visibility matrix: owner/view/edit/admin/bearer × open/watch/ports); the **live-authorization diff test grows preview rows** (the exhaustive pairing is the control); proxy tests (cookie strip, jar path scoping, Host rewrite, frame-ancestors strip only, Origin refusal on upgrade, pre-ack buffer cap). +- **Client**: gating-matrix renders (disabled + reason per state), stale-frame rule, watch subscribe-on-visibility-change. +- **Live**: a Linux container (chromium + xvfb + x11vnc) enrolled as a real node against `e2e/stack.ts`'s backend, driving one end-to-end: launch screen pane, open port, watch cdp, screen canvas byte flow. Mac node covers the non-Linux refusals and forward lanes with a hand-pinned port. Neither live path gates the unit suite; they are the boundary check before hand-to-human testing. +- Verification at boundaries: `verify-types`, `lint:check`, `lint:prose`, `test` full, plus `rust:check` only if any Rust is touched (none expected). + +## 11. Out of scope, named so it stays out + +Non-loopback forwarding (never, per §8.1); any TLS or mesh assumption (transport is the operator's, §0); macOS/*BSD virtual display (the refusal is the answer); VNC to machines Subshell does not own; previewing *another* user's app over the mesh outside this feature's authz; E2EE preview (future, if the model ever changes); per-app URL path config, auth injection, cookies beyond the jar; renaming the existing screen-preview. + +## 12. Open choices for review + +1. Surface names in UI copy: "Open", "Watch", "Screen" - or keep "Preview" alone for all three? +2. Watch fps/quality defaults (proposal: 5 fps, q60, clamped) - fine as constants, but they set the wire cost: 5 fps × ~80 KiB ≈ 400 KiB/s per watched browser per *subscriber* (fan-out is per socket). +3. `/pv/` vs `/api/preview/...` proxy path prefix (short wins for iframe HMR noise, but the api prefix inherits route-mount habits). +4. The noVNC vendoring decision (this is the one dependency-shaped call the arc makes outside npm pinning; MPL-2.0, pinned tag, lazy chunk). From f5cce1fa51de4899cc7ff2f5a96d6d06a143a9cb Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 21 Oct 2026 23:32:53 -0700 Subject: [PATCH 002/139] Settle pane-preview spec review decisions --- .../specs/2026-10-21-pane-preview-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-21-pane-preview-design.md b/docs/superpowers/specs/2026-10-21-pane-preview-design.md index a559abc90..85dcebb81 100644 --- a/docs/superpowers/specs/2026-10-21-pane-preview-design.md +++ b/docs/superpowers/specs/2026-10-21-pane-preview-design.md @@ -1,6 +1,6 @@ # Pane Preview: watching and opening what a pane's process shows -Date: 2026-10-21. Status: draft for review. Branch: `feat/pane-preview`. +Date: 2026-10-21. Status: approved; §12 settled in review. Branch: `feat/pane-preview`. Implements one user ruling set: watch follows `view`, use follows `edit`; the full ladder (forward, CDP view, virtual display) ships in this arc; ports are auto-detected with an explicit pin beside them. ## 1. Why @@ -135,9 +135,9 @@ Node offline / held / too-old: panel reads the reason (the gates doc's refusals Non-loopback forwarding (never, per §8.1); any TLS or mesh assumption (transport is the operator's, §0); macOS/*BSD virtual display (the refusal is the answer); VNC to machines Subshell does not own; previewing *another* user's app over the mesh outside this feature's authz; E2EE preview (future, if the model ever changes); per-app URL path config, auth injection, cookies beyond the jar; renaming the existing screen-preview. -## 12. Open choices for review +## 12. Decisions from review (2026-10-21) -1. Surface names in UI copy: "Open", "Watch", "Screen" - or keep "Preview" alone for all three? -2. Watch fps/quality defaults (proposal: 5 fps, q60, clamped) - fine as constants, but they set the wire cost: 5 fps × ~80 KiB ≈ 400 KiB/s per watched browser per *subscriber* (fan-out is per socket). -3. `/pv/` vs `/api/preview/...` proxy path prefix (short wins for iframe HMR noise, but the api prefix inherits route-mount habits). -4. The noVNC vendoring decision (this is the one dependency-shaped call the arc makes outside npm pinning; MPL-2.0, pinned tag, lazy chunk). +1. UI surfaces are three named verbs: **Open**, **Watch**, **Screen**, in a Preview section; tooltips carry the grant reason. +2. Watch defaults: **5 fps, JPEG q60**, node-clamped. Accepted wire cost: ~400 KiB/s per watched browser per subscriber (fan-out is per socket). +3. Proxy path: **`/pv/:session/`**, mounted beside the SPA routes. +4. RFB client: **vendored noVNC `core/` at a pinned tag** with its MPL-2.0 license files beside it, lazy-loaded chunk. This is the arc's only dependency-shaped call outside npm pinning. From f79894f207eb2e70fa430fc026a254f9ea02b748 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Wed, 21 Oct 2026 23:42:46 -0700 Subject: [PATCH 003/139] Add pane preview implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 TDD tasks in dependency order: protocol 23 grammar, node switch, port probe, forward lanes, CDP bridge, virtual displays, plane broker, REST authz matrix, /pv proxy, live watch fan-out through the §11.14 control, dashboard surfaces, noVNC screen viewer, e2e and docs. Recorded decisions against the spec: @novnc/novnc npm dep replaces git vendoring (same pinned/MPL ruling, cleaner); forward lanes are per browser connection, not per session (dev servers mix keep-alive HTTP with a permanent HMR socket); close-reason set gains target-gone and a preview-closed node event because the relay precedent has no target-death signal. Route and command names follow the spec exactly (preview_open kinds forward/screencast, /preview/open). Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 484 ++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-21-pane-preview.md diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md new file mode 100644 index 000000000..63edc69ae --- /dev/null +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -0,0 +1,484 @@ +# Pane Preview Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a dashboard user see what a pane's process is showing: proxy a pane's loopback server (Open), screencast a pane's browser over CDP (Watch), and view a Linux pane's virtual display (Screen). + +**Architecture:** A preview lane generalizes the ssh relay lane: the plane brokers per-connection byte lanes to a node, the node dials `127.0.0.1:` only, and three surfaces ride that lane or the `/ws/live` feed. Protocol bumps 22 -> 23 (exact-match gate). See the spec for the full design and its security argument. + +**Tech Stack:** Bun, Elysia, TypeScript, React 19 (Vite SPA), tmux node agent, `@novnc/novnc` 1.7.0 (MPL-2.0), Playwright e2e. + +**Spec:** `docs/superpowers/specs/2026-10-21-pane-preview-design.md` (approved; §12 settled). Read it before any task; this plan argues from it. + +## Global Constraints + +- Protocol doctrine: `NODE_PROTOCOL_VERSION` 22 -> 23 this round, exact-match gate at connect (server ships first; a lagging agent is held with an update message, never feature-gated). `MIN_NODE_VERSION` (`packages/subshell-protocol/src/versions.ts:123`) rises to `"2.4.0"` and `apps/node/agent/package.json` version rises to `"2.4.0"` **in the same commit as the bump** (both are already higher than the old floor by one, this keeps the pattern). +- Node executor contract (`apps/node/agent/AGENTS.md`): act only on verified commands, closed grammar at both ends, result-before-teardown, `pending: true` ack for slow off-chain work, self-expiring timers, never `process.exit`, refusals name the id and the remedy. +- Dialing is loopback-only (`127.0.0.1` / `::1`), both forward and CDP. Non-loopback is out of scope forever (spec §11). +- Close reasons are a closed set: `viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `target-gone` (the last one is decision 3 above). +- No dynamic `import()` in anything the compiled binaries bundle (server, node). The SPA has a sanctioned lazy-feature precedent at `apps/server/web/src/lib/ssh.ts`; only the SPA may code-split. +- Pinned dependency versions: after `bun add`, run `syncpack fix` then `bun install`; check with `bun run lint:lockfile`. +- Authz: watch streams require `view`, every lane (proxy bytes, display) requires `edit`; a foreign pane answers 404, never 403. `/ws/live` remains the second authorization path: any new broadcast must pass the viewer-vs-row diff test (the control behind `docs/security.md` §11.14; see `apps/server/api/src/ws/__tests__/live-consistency.test.ts`). +- Voice: no U+2014 anywhere authored (`bun run lint:prose`); UI copy rules in `AGENTS.md`; design tokens only (`bun run lint:design`). +- Tests run via scripts (`bun run test --filter=`, never bare `bun test` for suites); invoke bash-shaped things with `env -u SHELLOPTS -u BASHOPTS`. +- Task boundary verification: `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, plus `bunx turbo build` whenever `packages/` changed. No PR at any point (user tests first, on `feat/pane-preview`). + +**Implementation decisions recorded against the spec (approve these as you review):** + +1. Spec §12 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, official repo's own package, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained, lazy chunk), fewer bytes and no drift. +2. Spec §6 implied one forward lane per session. A real dev server mixes keep-alive HTTP with a permanent HMR WebSocket, and one TCP lane serializes both, so the proxy opens **one node forward lane per inbound browser connection** (each gets its own fresh node ref; the session secret authorizes many lanes). Spec quotas stay, and bursts beyond them queue rather than fail. +3. The spec's named close-reason set has no member for "the proxied service itself hung up" (a dev server that exits while the pane lives). The set gains `target-gone`, and the node tells the plane about lane EOF with a new node->plane event `preview-closed { ref, reason }`: the ssh relay precedent has no such signal because its targets are agent-managed processes, while a preview lane's target dies freely. + +--- + +## File Structure + +**`packages/subshell-protocol/src/`** +- `node-frames.ts` (modify): version 23 + bump-history entry, five new commands, `PreviewDataFrame` link frame + `parsePreviewFrame`, `NodeEvent` members `preview-data` and `preview-stream`, `ready.previewEnabled`, `create_session` gains `screen`, new wire type `NodePreviewEnabledWire`. +- `live-frames.ts` (modify): server member `preview-stream`, client members `watch` / `unwatch`, parse arms. +- `ssh-limits.ts` (modify): `PREVIEW_*` constants. +- `versions.ts` (modify): floor 2.4.0. +- `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): census auto-extends; add explicit boundary cases. + +**`apps/node/agent/src/`** +- `preview/flags.ts` (new): the `previewEnabled` mirror, cloned from wherever `sshEnabled` persists (find with `grep -rn sshEnabled apps/node/agent/src`). +- `preview/ports.ts`, `preview/forward.ts`, `preview/cdp-bridge.ts`, `preview/display.ts` (new): the four mechanisms. +- `commands/set-preview-enabled-exec.ts`, `commands/preview-open-exec.ts` (one arm, both `kind` branches), `commands/preview-close-exec.ts`, `commands/preview-ports-exec.ts` (new arms), `commands/index.ts` (dispatch), `commands/context.ts` (registry handles, mirroring the relay ones). +- `daemon.ts` (modify): `ready.previewEnabled`, inbound `preview_data` arm beside the relay arm, display orphan sweep timer. +- `tmux-runner.ts` / pane-create command (modify): inject `DISPLAY` when `screen` is set; pane-death hook calls `display.closeFor(paneId)`. +- `__tests__/preview-*.test.ts` (new). + +**`apps/server/api/src/`** +- `services/nodes/preview-enabled.ts` (new, clones `nodes/ssh-enabled.ts` shape incl. its storage), route beside the ssh-enabled route. +- `services/preview-broker.ts` + `services/__tests__/preview-broker.test.ts` (new). +- `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-close.route.ts`, `preview-watch-start.route.ts`, `preview-watch-stop.route.ts`, `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts`. +- `preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts` + `preview/__tests__/` (new): the `/pv` proxy. +- `ws/live-ws.ts` (modify): `watch`/`unwatch` client frames; `ws/live-registry.ts` (modify): targeted send; `ws/__tests__/live-ws.test.ts`, `live-consistency.test.ts` (modify). +- `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts`, shares route (modify): revoke hook beside the existing `dropLiveSocketsFor` calls. +- Node link intake (the call site that feeds `routeRelayFrame` in `services/ssh-relay.service.ts:512`): route `preview-data`/`preview-stream` events to the broker beside it. + +**`apps/server/web/src/`** +- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx`, `preview-toggle.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` to the bus). + +**Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview rules), `docs/security.md` (preview lane paragraph), `apps/node/agent/AGENTS.md` (executor note), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset for `@internal/server` + `@internal/node`). + +--- + +### Task 1: Protocol contract (frames, commands, limits, version floor) + +**Files:** +- Modify: `packages/subshell-protocol/src/node-frames.ts` (version at :187, bump history near :80, command union and `parseNodeCommandBody`, `NodeEvent` at :1540, `parseNodeEvent` at :2048, `RelayFrame` region :1451 as the shape to clone, ready member region, `create_session` command) +- Modify: `packages/subshell-protocol/src/ssh-limits.ts` +- Modify: `packages/subshell-protocol/src/versions.ts:123` +- Modify: `apps/node/agent/package.json` (`"version": "2.3.0"` -> `"2.4.0"`) +- Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` + +**Interfaces:** +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `SetPreviewEnabledCommand`, `PreviewOpenCommand` (one command, kinds `forward` | `screencast`, exactly as spec §4 defines), `PreviewPortsGetCommand`, `PreviewCloseCommand`, `PreviewCloseReason`; link frame `PreviewDataFrame` + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview-data`, `preview-stream`, and `preview-closed` (node -> plane lane EOF: `{ type: "preview-closed"; ref: string; reason: PreviewCloseReason }`, decision 3); ready member `previewEnabled?: NodePreviewEnabledWire`; `create_session` optional `screen?: { width: number; height: number }`; constants `PREVIEW_DATA_MAX_BYTES`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_REF_RE`. +- Consumes: nothing. + +- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts` (mirror how it tests `relay` frames and the command census): + +```ts +describe("preview commands", () => { + test("parses preview_open round trip", () => { + const cmd = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p1", port: 5173, lifetimeMs: 3_600_000 }; + expect(parseNodeCommandBody(JSON.stringify(cmd))).toEqual(cmd); + }); + test("refuses a port out of range", () => { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 70000, lifetimeMs: 60_000 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 1.5, lifetimeMs: 60_000 }))).toBeNull(); + }); + test("refuses a short or long ref", () => { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "user" }))).toBeNull(); + }); + test("the kind decides whether fps may appear", () => { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 99, lifetimeMs: 60_000 }))).toBeNull(); // over PREVIEW_WATCH_FPS_MAX + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, lifetimeMs: 60_000 }))).toBeNull(); // screencast requires fps + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 8080, fps: 5, lifetimeMs: 60_000 }))).toBeNull(); // forward forbids fps + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 5, lifetimeMs: 60_000 }))).toEqual({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 5, lifetimeMs: 60_000 }); + }); + test("refuses a lifetime outside the window", () => { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 80, lifetimeMs: 1000 }))).toBeNull(); + }); + test("create_session accepts a screen size and bounds it", () => { + // clone the existing create_session parse test; add screen {width:1280,height:720} passes, width 100 fails + }); +}); +describe("preview link frames", () => { + const frame = { type: "preview_data", ref: "a".repeat(32), dir: "in", seq: 7, blob: Buffer.alloc(10).toString("base64") }; + test("round trips and enforces the raw blob cap", () => { + expect(parsePreviewFrame(frame)).toEqual(frame); + const tooBig = { ...frame, blob: Buffer.alloc(PREVIEW_DATA_MAX_BYTES + 1).toString("base64") }; + expect(parsePreviewFrame(tooBig)).toBeNull(); + const exactlyCap = { ...frame, blob: Buffer.alloc(PREVIEW_DATA_MAX_BYTES).toString("base64") }; + expect(parsePreviewFrame(exactlyCap)).not.toBeNull(); // cap measured on RAW bytes, like RelayFrame's base64RawLength + }); + test("preview-stream node event parses", () => { + const ev = { type: "preview-stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" }; + expect(parseNodeEvent(ev)).toEqual(ev); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails:** `env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/node-frames.test.ts` (expect parse results `null` / unknown command). +- [ ] **Step 3: Add constants** to `ssh-limits.ts`: + +```ts +/** Byte cap on a preview lane frame's RAW payload, base64 aside. Shared by both ends. */ +export const PREVIEW_DATA_MAX_BYTES = 262_144; +/** Screencast defaults and ceilings for the CDP watch bridge. */ +export const PREVIEW_WATCH_FPS_DEFAULT = 5; +export const PREVIEW_WATCH_FPS_MAX = 15; +export const PREVIEW_WATCH_JPEG_QUALITY = 60; +/** Frames the bridge may have in flight toward the plane before it starts dropping. */ +export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; +/** Lane quotas: per pane, per node, and per watch bridge. */ +export const PREVIEW_FORWARD_SESSIONS_PER_PANE = 4; +export const PREVIEW_SESSIONS_PER_NODE = 16; +export const PREVIEW_WATCH_SUBSCRIBERS_MAX = 8; +/** A forward lane with no bytes both ways for this long self-closes. */ +export const PREVIEW_IDLE_MS = 900_000; +/** Lanes are minted with a lifetime inside this window; the command refuses outside it. */ +export const PREVIEW_LIFETIME_MAX_MS = 4 * 3_600_000; +export const PREVIEW_LIFETIME_MIN_MS = 60_000; +/** Lane refs double as proxy path secrets: 128 bits of hex. */ +export const PREVIEW_REF_RE = /^[0-9a-f]{32}$/; +``` + +- [ ] **Step 4: Add the grammar** to `node-frames.ts`. Clone the `RelayFrame` validation style exactly (strict base64 via `base64RawLength`, ref via `PREVIEW_REF_RE`): + +```ts +/** What a `preview_open` asks for: the raw byte lane, or the read-only CDP screencast. Spec §4's kind union. */ +export type PreviewLaneKind = "forward" | "screencast"; + +/** + * Opens one preview lane inside the pane's node. For "forward" the node dials + * 127.0.0.1:; for "screencast" is the CDP port and `fps` is + * required and bounded. The node never reaches beyond loopback either way. + */ +export interface PreviewOpenCommand { + type: "preview_open"; + ref: string; + kind: PreviewLaneKind; + paneId: string; + /** Dial target (forward) or devtools port (screencast). Loopback only. */ + port: number; + /** Screencast only: frames per second the bridge emits; it drops extras before acking. */ + fps?: number; + /** Self-expiry. The node closes the lane by this deadline whatever the plane does. */ + lifetimeMs: number; +} + +/** Asks what the pane's process tree is listening on, plus the CDP port and display if any. */ +export interface PreviewPortsGetCommand { type: "preview_ports_get"; paneId: string } + +/** The per-node preview switch. Cloned from set_ssh_enabled, same mirror semantics. */ +export interface SetPreviewEnabledCommand { type: "set_preview_enabled"; on: boolean; changedAt: string } + +/** Why a lane ended. Closed set; spec §6 names them for the UI. */ +export type PreviewCloseReason = "viewer-left" | "pane-gone" | "node-dropped" | "slow-link" | "over-cap" | "idle" | "revoked"; + +/** Plane -> node teardown. Never access-gated: an unknown ref answers closed:false. */ +export interface PreviewCloseCommand { type: "preview_close"; ref: string; reason: PreviewCloseReason } +``` + +Plus, in `NodeCommand`: add all four (`preview_open`, `preview_close`, `preview_ports_get`, `set_preview_enabled`). In `NodeEvent`: add + +```ts +| { type: "preview-data"; ref: string; dir: "in" | "out"; seq: number; blob: string } +| { type: "preview-stream"; ref: string; seq: number; w: number; h: number; ts: number; blob: string } +| { type: "preview-closed"; ref: string; reason: PreviewCloseReason } +``` + +and in the `ready` member block: `previewEnabled?: NodePreviewEnabledWire` (clone how `sshEnabled` rides ready, `daemon.ts:416` sends it). `export interface NodePreviewEnabledWire { on: boolean; changedAt: string }` beside `NodeSshEnabledWire` in the same file. In `create_session`: `/** When set the node allocates a virtual display and exports DISPLAY for the pane's shell. */ screen?: { width: number; height: number }`, bounded 320..4096 both. + +Export `PreviewLaneKind` etc. from `src/index.ts`. Do NOT touch `src/index.ts` in a way that pulls a `node:*` builtin into the barrel (mobile Metro rule, `.claude/rules/verification.md`); these files are pure. + +- [ ] **Step 5: Wire parses and bump.** Add the four command arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, bounded), the three arms (`preview-data`, `preview-stream`, `preview-closed`) to `parseNodeEvent` (clone the relay arm), export `parsePreviewFrame` cloning `parseRelayFrame` verbatim with `PREVIEW_DATA_MAX_BYTES`. Then: `NODE_PROTOCOL_VERSION = 23` with this bump-history entry appended where the history lists 21->22: + +> **22 -> 23:** the preview surface. Four new commands (`preview_open` with its `forward`/`screencast` kinds, `preview_close`, `preview_ports_get`, `set_preview_enabled`), the `preview_data` link frame and the `preview-data` / `preview-stream` / `preview-closed` node events, the `previewEnabled` ready field, and `create_session`'s optional `screen`. Additive, and breaking anyway: an agent that has not rebuilt never sends the new events, but the gate is exact-match, so it is refused at connect and told to update. Server ships first. + +Then `MIN_NODE_VERSION = "2.4.0"` (`versions.ts:123`) and `apps/node/agent/package.json` `"version": "2.4.0"`. + +- [ ] **Step 6: Run the census + whole package:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/subshell-protocol` (the census test auto-iterates commands; fix every fixture that pins version 22 or the old floor - `bun run test` for `@internal/server` and `@internal/node` will surface them; update in this commit, per doctrine). +- [ ] **Step 7: Build + commit:** + +```bash +bunx turbo build --filter=@internal/subshell-protocol +git add packages/subshell-protocol apps/node/agent/package.json +git commit -m "feat(protocol): preview lanes, watches, and displays (protocol 23, floor 2.4.0)" +``` + +--- + +### Task 2: Live feed grammar (watch subscribe frames + stream frame) + +**Files:** +- Modify: `packages/subshell-protocol/src/live-frames.ts` +- Test: `packages/subshell-protocol/src/__tests__/live-frames.test.ts` + +**Interfaces:** +- Produces: `LiveServerFrame` member `{ type: "preview-stream"; id: string; ref: string; seq: number; w: number; h: number; blob: string }` (`id` is the subshell id; a viewer subscribes by pane, never by ref); `LiveClientFrame` members `{ type: "watch"; id: string }`, `{ type: "unwatch"; id: string }`; `parseLiveClientFrame` arms. +- Consumes: Task 1 for the blob concept only. + +- [ ] **Step 1: Failing tests** in `live-frames.test.ts`: `parseLiveClientFrame` accepts `{"type":"watch","id":"s1"}` and `{"type":"unwatch","id":"s1"}`, rejects a missing `id`, rejects an extra key, and the file's existing exact-shape style rejects `preview-stream` on the CLIENT leg. Assert the server-frame serializer carries `blob` untouched. +- [ ] **Step 2: Run, watch them fail** (`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/live-frames.test.ts`). +- [ ] **Step 3: Implement** the three union members and the two parse arms. Add JSDoc: the stream frame is only ever published to sockets whose holder has `view` on the row (broker enforces; the frame is not addressed). +- [ ] **Step 4: Rerun the package tests, turbo build the protocol package, commit** `git commit -m "feat(protocol): live watch and preview-stream frames"`. + +--- + +### Task 3: The per-node preview switch, end to end + +**Files:** +- Read first (the clone source, mirror it exactly including storage): `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route (`grep -rn "set_ssh_enabled" apps/server/api/src/api/`), its route test; node side: `grep -rn "sshEnabled" apps/node/agent/src` and the set_ssh_enabled executor in `apps/node/agent/src/commands/`. +- Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one. +- Modify: `apps/node/agent/src/commands/index.ts` (arm), `apps/node/agent/src/daemon.ts` (ready field), `apps/server/api/src/api/index.ts` or wherever the ssh toggle route mounts. +- Test: `apps/server/api/src/api/nodes/__tests__/preview-enabled-route.test.ts` (clone the ssh-enabled route test), `apps/node/agent/src/__tests__/preview-flags.test.ts`. + +**Interfaces:** +- Consumes: `SetPreviewEnabledCommand`, `NodePreviewEnabledWire` (Task 1). +- Produces: node `preview/flags.ts`: `getPreviewEnabled(): { on: boolean; changedAt: string } | undefined`, `setPreviewEnabled(state)`, `previewEnabledWire(): NodePreviewEnabledWire | undefined`; plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise<{ on: boolean }>` (row absent = OFF), `setPreviewEnabled(nodeId, userId, on): Promise`. + +- [ ] **Step 1: Read the clone source** (`ssh-enabled.ts` both halves and both tests). Note how it persists plane-side state, how it sends the command, how it audits `node.ssh_enabled.update`, and how the node mirror survives restart and drift. +- [ ] **Step 2: Failing route test** cloning the ssh one: cookie actor flips the switch -> 200 + audit row `node.preview_enabled.update` + `sendCommand` (from `@/services/nodes/node-rpc.js`) called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; GET answers `{on:false}` with no row (fail-closed default). +- [ ] **Step 3: Implement the plane half** (clone storage shape verbatim - same table/column kind the ssh switch uses, named `preview_enabled`), then the node half: executor gate-first pattern from the ssh executor (verified cmd only; store mirror file with mode 0600 exactly where the ssh mirror lives; result-before-teardown), `flags.ts` owning the in-process copy, `daemon.ts` ready frame gains `...(previewEnabledWire() ? { previewEnabled: previewEnabledWire() } : {})`. +- [ ] **Step 4: Mirror-drift test** (node side): an external edit to the mirror file between commands is re-read and re-reported, same as the ssh mirror test asserts. +- [ ] **Step 5: Focused runs then commit:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/server --filter=@internal/node` (or the two files), `git commit -m "feat: per-node preview switch mirroring the ssh enable posture"`. + +--- + +### Task 4: Port discovery (node) + +**Files:** +- Create: `apps/node/agent/src/preview/ports.ts`, `apps/node/agent/src/commands/preview-ports-exec.ts` +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/commands/context.ts` +- Test: `apps/node/agent/src/__tests__/preview-ports.test.ts` + +**Interfaces:** +- Consumes: pane pid from the tmux runner (`TmuxRunner.panePid`, `apps/node/agent/src/tmux-runner.ts:447`), display registry (Task 7; before it exists the `screen` field is `null`). +- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number })` -> `{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }`; refuses on non-Linux with error id `preview-ports-unsupported`. The `preview_ports_get` command result carries exactly that object. Plane Task 9 caches it 5 s per pane. + +- [ ] **Step 1: Failing unit tests** over a fixture `/proc` written into a temp dir: + +```ts +// fixture: root//stat "pid (name) S ppid ...", root//cmdline, root//fd/N -> "socket:[INODE]" (symlink target need not exist for readlink), root/net/tcp with "state" column 0A LISTEN rows and the inode column. +test("attributes a listening socket to the deepest descendant that owns it", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100 }); + expect(r.ports).toEqual([{ proto: "tcp", port: 3000, pid: 102 }]); +}); +test("finds the CDP port from a descendant's --remote-debugging-port flag", async () => { + // fixture: pid 102's cmdline is "chromium --remote-debugging-port=9222" and 9222 is LISTEN + expect((await scanPanePorts({ procRoot, panePid: 100 })).cdpPort).toBe(9222); +}); +test("a socket owned by an outsider never appears", async () => { + // fixture: socket:[8888] is LISTEN on port 8080 but owned by pid 999, a sibling of the pane subtree + const r = await scanPanePorts({ procRoot, panePid: 100 }); + expect(r.ports.some((p) => p.port === 8080)).toBe(false); +}); +test("no listeners answers with empty arrays, not an error", async () => { + const r = await scanPanePorts({ procRoot: emptyProcRoot, panePid: 100 }); + expect(r.ports).toEqual([]); + expect(r.cdpPort).toBeNull(); + expect(r.screen).toBeNull(); +}); +test("a listening IPv6 loopback row parses", async () => { + // net/tcp6 fixture row with "::1" local address, state 0A, owned by a descendant + const r = await scanPanePorts({ procRoot, panePid: 100 }); + expect(r.ports).toContainEqual({ proto: "tcp6", port: 4321, pid: 102 }); +}); +``` + +- [ ] **Step 2: Run, fail.** +- [ ] **Step 3: Implement `ports.ts`:** parse `net/tcp` + `net/tcp6` (`state === "0A"`, `local_address` hex `port:ip`; only accept ip `0100007F`/`00000000...1` loopback AND `00000000` - an all-zeros bind still counts, the node will dial loopback regardless), build ppid map from `*/stat`, BFS descendants of `panePid`, read each pid's `fd/*` readlinks, intersect inodes, take the deepest pid per inode. `cdpPort`: descendant `cmdline` containing `--remote-debugging-port=N` AND N in the port list. `screen`: `display.getFor(paneId)` (returns null until Task 7). Non-Linux: throw the named refusal before touching fs. `procRoot` exists only so tests can fake the tree; default `"/proc"`. +- [ ] **Step 4: Executor arm:** gate-first (preview switch ON else refuse naming remedy "turn on Preview for this node"; pane must have a live pid - `preview-ports-pane-gone`), result = `scanPanePorts` output. Slow-work rule: keep it under the chain budget - one pass reading only the pane subtree, `pending: true` ack NOT needed (it is fast), but cap the fd walk (`ports.ts` bounds: max 512 pids scanned; over-cap answers `preview-ports-overcap`). +- [ ] **Step 5: Green + commit** `feat(node): attribute pane subtrees' listening sockets, with CDP and display discovery`. + +--- + +### Task 5: Forward lanes (node executor) + +**Files:** +- Read first: `apps/node/agent/src/commands/ssh-relay-exec.ts` + `apps/node/agent/src/relay-send.ts` (the pump, gate-first, pending-ack, close-not-gated, tombstone shapes to clone). +- Create: `apps/node/agent/src/preview/forward.ts`, `apps/node/agent/src/commands/preview-open-exec.ts` (dispatches on `kind`; this task lands the `forward` branch, Task 6 adds `screencast`), `apps/node/agent/src/commands/preview-close-exec.ts` +- Modify: `commands/index.ts`, `commands/context.ts` (a `previewForward` registry in ctx), `daemon.ts` (inbound `preview_data` frame arm beside the relay intake; node->plane `preview-data` emission uses the same sender as relay emission). +- Test: `apps/node/agent/src/__tests__/preview-forward.test.ts` (the `commands-ssh-relay.test.ts` precedent; find with `ls apps/node/agent/src/__tests__/ | grep relay`). + +**Interfaces:** +- Consumes: `PreviewOpenCommand`, `PreviewCloseCommand`, `PreviewDataFrame`, `preview-data` NodeEvent, limits (Task 1). +- Produces: `forward.ts`: `openLane({ref, port, paneId, lifetimeMs}): Promise` (dials and registers), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame: PreviewDataFrame): void`, `laneCount(paneId?)`, `closeAllForNode(reason)` (daemon link-drop hook). Result of `preview_open`: `{ ok: true }` after dial connects, or refusal ids `preview-target-refused`, `preview-node-off` (switch), `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. + +- [ ] **Step 1: Failing tests.** Start a real in-test echo server (`Bun.serve` or `net.createServer` on an ephemeral loopback port). Build a fake command context capturing emitted `preview-data` events. Assert: (a) open echoes inbound bytes to the target and target bytes back as `dir:"out"` with monotonic `seq`; (b) dial to a closed port answers the refusal, registers nothing; (c) `closeLane` on an unknown ref answers `closed:false` (tombstone) and `preview_close` is NOT access-gated (assert no enabled check on the close path); (d) node quota: the 17th open refuses with the named id, per-pane the 5th; (e) EOF: when the target socket ends, the lane is deregistered and the node emits `{ type: "preview-closed", ref, reason: "target-gone" }`; lifetime expiry emits `preview-closed` with `idle` when no bytes flowed (and no event at all if `preview_close` already landed first); (f) slow-link: with `PREVIEW_*` overridden low, an overflowing outbound pump closes the lane and emits `preview-closed` with `slow-link`. +- [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane; dial `net.connect({ host: "127.0.0.1", port })`, on error retry `host:"::1"` once, both failures = refused (loopback-only: never accept a host from the wire, port comes from the verified command). Socket data -> base64 -> emit `preview-data` event, seq per lane monotonic; if the link's buffered amount crosses `PREVIEW_DATA_MAX_BYTES * PREVIEW_WATCH_FRAMES_BUFFERED`, close `slow-link`. `ingestInboundFrame` (dir "in") writes the socket; writes count as activity; idle timer `PREVIEW_IDLE_MS` self-closes with `preview-closed` reason `idle`. Every lane death (socket end/error, idle, slow-link, forced close) deregisters first, then emits exactly one `preview-closed` event with its reason. Result-before-teardown and pending-ack exactly as `ssh-relay-exec.ts` does them. Wire `preview_open` result as the post-connect ack (the dial is the chain's work; it is fast, no pending needed, matching relay's connect behavior - read ssh-relay-exec and copy its verdict). +- [ ] **Step 4: Wire intake:** daemon inbound parser: where relay frames route (`parseRelayFrame` call site in `daemon.ts`), add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` command arm calls `closeLane` unconditionally. +- [ ] **Step 5: Green + commit** `feat(node): forward preview lanes over loopback sockets with named slow-link teardown`. + +--- + +### Task 6: CDP watch bridge (node executor) + +**Files:** +- Create: `apps/node/agent/src/preview/cdp-bridge.ts` +- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (add the `screencast` kind branch) +- Modify: `commands/index.ts`, `daemon.ts` (emit `preview-stream` with the same sender as Task 5's events) +- Test: `apps/node/agent/src/__tests__/preview-watch-exec.test.ts` + +**Interfaces:** +- Consumes: `PreviewOpenCommand` (`kind: "screencast"`, `port` is the CDP port, `fps` from the wire), `preview-stream` NodeEvent, `PREVIEW_WATCH_*` (Task 1). +- Produces: `startBridge({ref, cdpPort, fps, lifetimeMs}): Promise`, `stopBridge(ref)`, `bridgeCount()`; refusals `preview-cdp-unreachable` (remedy text names launching chromium with `--remote-debugging-port`), switch-off and per-node cap reuse Task 5's ids (bridges count toward `PREVIEW_SESSIONS_PER_NODE`). +- Emission contract (Task 11 depends on it): one `preview-stream` per delivered frame with `seq` monotonic per ref, `w/h` from the CDP frame metadata, `ts = Date.now()`, `blob` the JPEG base64. + +- [ ] **Step 1: Failing test with a fake CDP endpoint.** In-test: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl: wsUrl }]` and a `Bun.serve({ websocket: ... })` handler that records received messages and pushes three `Page.screencastFrame` notifications with tiny JPEG payloads. Assert: start sends `Page.startScreencast` with `{ format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY }` and `everyNthFrame: 1`; each frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) even when its `preview-stream` emission is dropped by the fps throttle (fps 1, three frames in quick succession -> exactly one emission, three acks); socket close mid-stream stops the bridge and (clone Task 5's death-notification mechanism) tells the plane; `stopBridge` closes cleanly. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement.** Global `fetch` + global `WebSocket` (both exist under bun on the node; no new deps). Sequence: GET `http://127.0.0.1:${cdpPort}/json/list` (loopback literal, never the wire value), prefer `type === "page"`, else first target, none -> `preview-cdp-unreachable`. Open WS to `webSocketDebuggerUrl` (validate it starts `ws://127.0.0.1:` or `ws://localhost:` before dialing - the port came from /proc, the URL comes from a socket answer; distrust it). Then: `Page.enable`, `Page.startScreencast {format:"jpeg", quality, everyNthFrame: 1}`. On `Page.screencastFrame` notification: ack immediately (always ack, even when dropping), throttle emissions to `fps` (interval floor `Math.ceil(1000 / fps)`), enforce `PREVIEW_WATCH_FRAMES_BUFFERED` in flight. Idle: no frame for 60s -> self-close (browser closed). Lifetime timer. Refusals/teardown exactly the executor contract. +- [ ] **Step 4: Executor branch** (`preview-open-exec.ts`, `kind === "screencast"`): gate-first, pane pid check, counts toward node caps. Add one unit test that the same open refuses a screencast whose `fps` is absent (grammar already rejects it; assert the branch's own bounds message is never reached with junk). +- [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge with always-ack and fps throttle`. + +--- + +### Task 7: Virtual displays (node) and launch-time `screen` + +**Files:** +- Create: `apps/node/agent/src/preview/display.ts` (no new command arm: displays come up through `create_session.screen`, and `preview_ports_get` reports one via its `screen` field, both spec-shaped) +- Modify: `commands/index.ts`, pane-create path in tmux-runner (find: `grep -rn "DISPLAY\|create_session" apps/node/agent/src/tmux-runner.ts` and the command that owns pane creation), daemon startup (hourly orphan sweep), pane-death hook (grep the exit-status path in tmux-runner). +- Test: `apps/node/agent/src/__tests__/preview-display.test.ts` + +**Interfaces:** +- Consumes: `create_session.screen`, `dataDir` (the daemon's data dir - same root `/ssh//` uses; grep its derivation once and reuse the helper). +- Produces: `display.openFor(paneId, {width,height}, deps?): Promise<{display: string; rfbPort: number}>`, `display.getFor(paneId)`, `display.closeFor(paneId, reason)`, `display.sweepOrphans(alivePaneIds)`; `deps` injects `{ spawn, which, sleep, now }` so unit tests fake Xvfb. + +- [ ] **Step 1: Failing unit tests** (fake `which`/`spawn`): allocation picks `:90` then the next free (a lockfile that exists is taken); state dir is created 0700 with log files 0600; missing `Xvfb` -> refusal `preview-display-missing-binaries` whose message names the remedy (install `xvfb` and `x11vnc`) and touches nothing; non-Linux -> `preview-display-unsupported` by name; readiness waits for the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; `sweepOrphans` tears down and releases every display whose pane id is absent from the alive list; rfb port chooser re-tries on a collision. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement:** numbers `:90..:199`, lock `/displays/.display-NN.lock` opened `wx` (EEXIST = taken); state `/displays//`; spawn `Xvfb :NN -screen 0 WxHx24 -nolisten tcp` (logfile inside state dir), poll `/tmp/.X11-unix/XNN`; pick rfb port from 5910+ by `net.listen` probe, then spawn `x11vnc -display :NN -rfbport P -localhost -forever -shared -bg`-less (keep the child, `-forever`, no `-auth`, logfile); `Bun.which` both first. `closeFor`: SIGTERM both, unlink lock, drop the dir's logs. Register in the in-process map AND return `screen` to `ports.ts` (Task 4's `screen` field goes live here). +- [ ] **Step 4: `create_session.screen`:** bounded in grammar already (Task 1). In the pane-create path: if `screen` present, `openFor` BEFORE spawn and inject `DISPLAY=:NN` into the pane env beside the SUBSHELL_* credentials (same env builder); if `openFor` refuses, fail the create with its named reason; pane-exit path calls `closeFor(paneId)`. Non-Linux + screen set: create refuses with `preview-display-unsupported` (never silently drop it). +- [ ] **Step 5: Sweep timer** in daemon startup: hourly, `sweepOrphans(live pane ids)` (clone the ssh per-pane-dir retention sweep's shape if one exists; else an interval beside the other self-timers). +- [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays per pane, DISPLAY injected at launch`. + +--- + +### Task 8: The preview broker (plane) + +**Files:** +- Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/__tests__/preview-broker.test.ts` +- Modify: the node-link event intake (the caller that feeds `routeRelayFrame`, `ssh-relay.service.ts:512`; put `preview-stream` -> `broker.onStreamEvent`, `preview-data` -> the pv proxy's lane pump (Task 10 consumes `broker.subscribeLane`); link-drop hook -> `broker.onNodeDropped(nodeId)`), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (add `closeAllForUser(userId, "revoked")` beside their `dropLiveSocketsFor` calls), the shares route likewise. +- Test helper: `@/__tests__/helpers/test-database.js` for audit rows. + +**Interfaces:** +- Produces (Tasks 9-11): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (edit-gated by the route, not the broker), `openLaneForConnection(ref): Promise<{ laneRef }>` + `subscribeLane(ref, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` (the broker maps node `preview-closed` events to `closed`; lane bytes arrive as `bytes`) + `sendLaneBytes(laneRef, bytes)`, `closeForward(ref, viewerUserId, reason)`, `startWatch({ viewerUserId, paneId, nodeId, cdpPort }): Promise<{ ref }>` (dedupes per pane+cdpPort; joins the viewer set, cap `PREVIEW_WATCH_SUBSCRIBERS_MAX` -> refusal `preview-watch-over-cap`), `stopWatch(viewerUserId, ref)`, `dropViewer(viewerUserId)` (socket close), `sessionFor(ref)`, `getPorts(nodeId, paneId)` (5 s cache, RPC via `sendCommand` from `@/services/nodes/node-rpc.js`), `closeAllForNode(nodeId, reason)`, `closeAllForUser(userId, reason)`, `onStreamEvent(ev)`; `PreviewSession { ref, kind, paneId, nodeId, port, createdBy, createdAt, viewers: Set, lastActivity }`. +- Consumes: `sendCommand`, Task 1 grammar, audit pattern from `ssh-relay.service.ts:437-455` (actorUserId null, metadata `{ ref, paneId, nodeId, viewerUserId, lifetimeMs }`). + +- [ ] **Step 1: Failing tests** (fake `sendCommand`, real test DB for audit): open sends `preview_open` with a fresh 32-hex ref, mints session, audits `node.preview_forward.open`; refusal propagates the node's id (no session recorded); per-pane cap 4 / node cap 16 (counting lanes too - a lane beyond cap queues up to 8 waiters then answers `preview-lane-over-cap`); watch dedupe: second viewer does NOT re-send `preview_open` (kind `screencast`, `port: cdpPort`, `fps: PREVIEW_WATCH_FPS_DEFAULT`), its join returns the existing ref; last viewer leaving sends `preview_close` reason `viewer-left` and audits `node.preview_watch.close`; stream fan-out: `onStreamEvent` delivers `{id: paneId, ref, seq, w, h, blob}` to each viewer's injected sender exactly once and counts activity; idle sweep (injected clock) closes at `PREVIEW_IDLE_MS` with reason `idle`; `closeAllForUser` audits closes with `revoked`; `closeAllForNode` does the same with `node-dropped`; `getPorts` caches for 5 s then re-RPCs. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement** with module-singleton pattern + `resetForTests` per `.claude/rules/code-style.md`; lane machinery: a per-ref emitter the proxy subscribes to; every node command via `sendCommand`; every close path audits the pair (`node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`); timers self-expire like the ssh broker's do (read `ssh-relay.service.ts` broker for the exact habit). +- [ ] **Step 4: Green + commit** `feat(server): preview broker with quotas, refcounted watches, and revocation`. + +--- + +### Task 9: REST surface and authz matrix + +**Files:** +- Create: `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-close.route.ts`, `preview-watch-start.route.ts`, `preview-watch-stop.route.ts`, `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts` +- Modify: `api/subshells/create-subshell.route.ts` (+ `screen` body field -> service -> `create_session` command; find where the service builds the command: `grep -rn "create_session" apps/server/api/src/services/ | head`) +- Test: the auth matrix file (clone the actor-table shape from `__tests__/subshell-input-ssh.test.ts` and `subshell-view-ssh.test.ts`). + +**Interfaces:** +- Consumes: broker (Task 8), `loadSubshellAccess` + `accessAtLeast` (`src/lib/subshell-access.ts:32/:72`), disabledReason from `getPreviewEnabled` (Task 3) + node online + ready `previewEnabled` presence (older-but-23 agents that predate the field don't exist; absence means an agent that never flipped - treat missing as OFF). +- Produces (spec §6's exact paths): `GET /api/subshells/:id/preview` -> `{ disabledReason: "preview-disabled" | "node-offline" | "pane-gone" | null, ports, cdpPort, screen, access }`; `POST /:id/preview/open` body `{port}` -> `{ proxyBase: "/pv//" }`; `DELETE /:id/preview/open/:ref` -> `{ closed: boolean }`; `POST /:id/preview/watch` body `{cdpPort}` -> `{ ref }`; `DELETE /:id/preview/watch/:ref`; create-subshell body gains optional `screen: {width,height}`. + +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine). For each route row: owner/`edit` grant -> 200 (POST/DELETE as specified); `view` grant -> 200 on GET and POST/DELETE watch, 403 on forward; foreign account -> 404 on ALL five; another pane's own subshell token -> resolves as its OWNER with shares off (the §4 sibling posture): 200 on rows for the OWNER's own panes, 404 on foreign ones; pin both halves and cite `docs/security.md` §4 in the test comment; node key -> 403 (permanent guard rejection §5.5); system key -> the full human gate as the `system` service user: nowhere without a deliberate grant, and a granted `edit` row DOES reach it (spec §6; assert the positive too, so the rule is pinned in both directions). Forward body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema (Elysia `t.Integer({ minimum: 1, maximum: 65535, description: ... })`, every property described per code-style rule). +- [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (each its own file, schemas as named constants, `detail: { operationId, tags: ["subshells"] }`); disabledReason computed BEFORE any node RPC (offline pane = cheap 404s first). Mount into the already-mounted subshells instance in `index.ts` (avoid the Elysia `App` TS2589 trap; new route files folded into an existing mounted group are the sanctioned shape). +- [ ] **Step 4: create flow:** body schema optional `screen` with 320..4096 bounds on both fields, passed through to `create_session`; refusal from the node surfaces as the create error verbatim. +- [ ] **Step 5: Green + commit** `feat(api): preview REST surface with the view/edit authz split`. + +--- + +### Task 10: The /pv proxy + +**Files:** +- Create: `apps/server/api/src/preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts`, `preview/__tests__/pv-proxy.test.ts` +- Modify: server bootstrap (mount HTTP `.all("/pv/:session/*")` route and a ws handler on the same wildcard path beside the ws plugin's routes), `package.json` (add `ws@8.22.0` - current latest, then `syncpack fix`; used ONLY with its documented stream override so it rides a lane duplex: `new WebSocket(url, { net: { createConnection: () => laneDuplex } })`; verify that override reads in `node_modules/ws/lib/websocket.js` before coding against it - the option name is the public one, the file is the proof). + +**Interfaces:** +- Consumes: `broker.sessionFor(ref)`, `broker.openLaneForConnection(refSession): Promise<{ laneRef }>` + `broker.subscribeLane(refSession, cb)` where cb receives `{ bytes: Uint8Array } | { closed: PreviewCloseReason }` (broker maps the node's `preview-closed` onto it), `broker.sendLaneBytes(laneRef, bytes)`, `PreviewDataFrame` (Task 1). +- Produces: `lane-duplex.ts`: `class LaneDuplex extends Duplex` (constructor takes `{ pushOut(bytes), onBytes(cb), onClose(cb), pause(): void }`; `_write` frames through `sendLaneBytes`; inbound lane bytes `push`). + +- [ ] **Step 1: Failing unit tests** for the pieces that carry security meaning (run over a real in-proc `Bun.serve` target and a fake "node" = a helper that pumps `preview_data` frames to a loopback socket, ~25 lines of the Task 5 pump repeated in-test): + - outbound `Set-Cookie` gets `Path=/pv//` forced, `Domain` and `Secure` flags preserved otherwise untouched; + - inbound request carries a `Cookie` header rebuilt from the per-session jar only; the browser's own `Cookie` header NEVER reaches the target (send `Cookie: session=` and assert the target saw only jar contents); + - `Host` arrives at the target as `127.0.0.1:`; + - an upstream `Content-Security-Policy` keeps every directive EXCEPT `frame-ancestors` which is stripped (a test CSP with three directives -> two survive); + - a relative `Location: /foo` comes back prefixed `/pv//foo`; + - a WS upgrade carrying binary frames round-trips a payload byte-exact (fake target echoes); + - an upgrade whose `Origin` differs from the plane's own origin is refused (and same-origin is not); + - an unknown `/pv//` answers 404 with nothing leaked. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (404 if unknown ref); `openLaneForConnection` (cap/queue rules live in the broker - proxy just awaits); build `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: strippedPath + query, headers: transformed })`; transform: strip `cookie`, set `host` literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per connection, the lane closes after the response completes - the browser reconnects per request, the jar carries state); response: strip hop-by-hops, rewrite `set-cookie` Path, rewrite `location`, strip `frame-ancestors` from both `content-security-policy` and `content-security-policy-report-only`; stream body straight through; on lane `closed` before the body completes -> 502 with a plain-text line naming the reason (do NOT retry mid-stream: a POST may have landed). +- [ ] **Step 4: Implement `pv-ws.ts`:** ws handler on `/pv/:session/*` (Elysia `.ws` on the same wildcard, beside the ws plugin's registrations; the upgrade path check must accept any suffix - noVNC and Vite HMR each dial their own path): check `Origin` against the plane's own origin (same rule `/ws/live` uses), resolve session, open a dedicated lane, dial `new WebSocket(wsUrl, { net: { createConnection: () => laneDuplex } })` with `wsUrl = ws://127.0.0.1:${port}/${restPathFromUpgradeUrl}`; pump both ways with the duplex backpressure (buffer beyond `PREVIEW_DATA_MAX_BYTES * 4` -> close both `slow-link`); close either side -> close both, reason `viewer-left`. +- [ ] **Step 5: Green; `bun run lint:lockfile` if the dependency was added; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunneling`. + +--- + +### Task 11: Live feed plumbing and the §11.14 control + +**Files:** +- Modify: `ws/live-ws.ts` (client frame switch), `ws/live-registry.ts` (add `sendToUsers(userIds: Iterable, frame: LiveServerFrame)` iterating the existing userId->sockets map), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `services/live-bus.ts` ONLY if the publish path needs the frame type in its union. +- Test: extend the two files. + +**Interfaces:** +- Consumes: Tasks 2 (frames), 8 (`startWatch/stopWatch/dropViewer`), `loadSubshellAccess`. +- Produces: for a socket holder with `view`+ access, `{"type":"watch","id":paneId}` -> broker dedupe + later frames arrive as `{"type":"preview-stream","id","ref","seq","w","h","blob"}` via `sendToUsers`; socket close -> `dropViewer`. + +- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a view grant delivers a stream frame whose `blob` passes untouched; watch from a foreign pane never reaches the broker (spy) and the frame is ignored without dropping the socket; unwatch stops delivery; close drops the viewer. +- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control; read its current matrix first, do not weaken any row):** add, for every (viewer, access) pair already in it, a watch case asserting the set of sockets that receive `preview-stream` for pane P is EXACTLY the set that has `view`-+ access to P through the REST path's own resolver - no more (not admins! not the owner's other sockets that never sent `watch`), no less. A revocation mid-stream (grant removed) must reach the already-open live socket as a stream-stop: reuse whatever mechanism the matrix uses for access changes; the pane row itself surviving is the rule (the socket stays open; frames just stop). +- [ ] **Step 3: Implement** (small): two arms in live-ws's parsed-frame switch calling broker + `sendToUsers`; the diff-test rows drive any broker-side leak fixes. +- [ ] **Step 4: Green + commit** `feat(server): /ws/live watch subscriptions gated through the live authorization control`. + +--- + +### Task 12: Dashboard preview section, hook, and Watch viewer + +**Files:** +- Create: `apps/server/web/src/hooks/use-subshell-preview.ts`, `lib/preview-bus.ts`, `components/preview/preview-section.tsx`, `components/preview/port-list.tsx`, `components/preview/watch-viewer.tsx`, `components/preview/preview-toggle.tsx` +- Modify: `routes/subshells_.$id.tsx` (compose the section), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` frames into the bus), node settings page (find the ssh toggle's component via `grep -rln ssh apps/server/web/src/components | head`; the preview toggle sits beside it, same disabled/tooltip posture), create/launch dialog (optional virtual display checkbox -> `screen` body field). +- Test: `components/preview/__tests__/preview-section.test.tsx` (clone whatever render harness `routes/__tests__/setup.test.tsx` uses; if the SPA has a lighter component-test precedent, follow it - list `find apps/server/web/src -name "*.test.ts*"` and imitate). + +**Interfaces:** +- Consumes: Task 9 routes (via the Eden treaty client - after adding routes, `bunx turbo build --filter=@internal/server` so types flow, per `.claude/rules/build.md`), Task 11 frames via the bus. +- Produces: bus API `subscribePreview(paneId, cb): () => void`; hook API `{ info, isLoading, openPort, closePort, startWatch, stopWatch }`. + +- [ ] **Step 1: Tests** (render-level): a `view`-access render shows Watch toggles and NO Open buttons; `edit` adds Open (it opens `info.proxyBase` in a new tab via `window.open`, assert the spy); `disabledReason` renders the actual reason string as a disabled tooltip, not a mystery (`"preview-disabled"` -> the switch's remedy wording, per AGENTS disabled-control rule); a stale `ports` list older than 5 s (fake clock + fake fetch) re-requests on section focus; Watch viewer swaps its `src` to the newest frame blob's `data:image/jpeg;base64,${blob}` and ignores a lower `seq` (out-of-order arrival). +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement.** Port rows are `label` over `detail` per the design system; badges `server` / `browser-debug` / `screen`; the section header follows the short-fixed-title rule ("Preview" is fine as a section heading; per-action copy names the act: "Open in a new tab", "Watch", "Stop watching"). `watch-viewer.tsx` renders an `` sized to the latest `{w,h}`, with the fps and pane name in `detail`. No em dashes in copy. +- [ ] **Step 4: `bunx turbo build` then green tests + commit** `feat(web): preview section with port list, watch viewer, and the node switch`. + +--- + +### Task 13: Screen viewer with @novnc/novnc + +**Files:** +- Create: `apps/server/web/src/components/preview/screen-viewer.tsx` +- Modify: `apps/server/web/package.json` (`bun add @novnc/novnc` then `syncpack fix` && `bun install`; pin exact `1.7.0`), `preview-section.tsx` (Screen action when `info.screen` and `edit`: open a dialog hosting the viewer) +- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the lazy import; assert connect URL is `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render: `slow-link`, `revoked`, `viewer-left` each with a human line and the retry that is safe to offer). + +**Interfaces:** Consumes: forward lane (`proxyBase` from the existing forward-open call for `info.screen.rfbPort` - Screen REUSES the forward lane, zero new protocol); produces `screen-viewer.tsx` component. + +- [ ] **Step 1: Read the library's shape:** `sed -n 1,80p node_modules/@novnc/novnc/core/rfb.js` (constructor `(target, url, options)`; check what option names it takes for a raw path). The connect URL is the lane's WS tunnel root: `new RFB(el, \`wss?://host/pv//\`)`, `scaleViewport = true`, `focusOnClick = true`, shared-viewer default. +- [ ] **Step 2: Failing test** (mock `import("@novnc/novnc")`; the component calls it only on mount). +- [ ] **Step 3: Implement** with `const { default: RFB } = await import("@novnc/novnc")` inside the dialog mount effect (SPA precedent `lib/ssh.ts` does exactly this for its lazy feature; the no-dynamic-import rule governs the bun-compiled binaries, and Vite chunks it - state that reason in the component's JSDoc). Cleanup disconnects RFB (lane `viewer-left`). +- [ ] **Step 4: MPL attribution:** the package ships `LICENSE.txt`; check whether the repo keeps a third-party notices file (`grep -rn -il "third.?party\|THIRD_PARTY" apps/website apps/docs docs | head`) - if one exists, append the `@novnc/novnc` MPL-2.0 entry; if not, the package.json license field plus `bun run lint:licenses` is the record. Say which in the commit body. +- [ ] **Step 5: Green + commit** `feat(web): noVNC-backed screen viewer over the forward lane`. + +--- + +### Task 14: e2e, docs, changeset, boundary verification + +**Files:** +- Create: `e2e/preview.spec.ts` (gated like the suite's existing skip rules; `e2e/AGENTS.md` governs), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser, open a server, screen on Linux), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor, one file, describes the feature once; never hand-edit CHANGELOGs). +- Modify: `apps/server/api/AGENTS.md` (audit inventory gains the six `node.preview_*` actions + a preview-lane rules paragraph: plane sees proxied bytes deliberately, spec §8; the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback-only invariant; Xvfb/x11vnc as optional runtime deps with named refusals). + +- [ ] **Step 1: e2e, API-level through `e2e/stack.ts` (async helpers only - the sync-spawn starvation trap):** launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response("ok")})'`; poll `GET /api/subshells/:id/preview` (as owner cookie) until `ports` includes 4123; `POST forward {port:4123}`; fetch `http://localhost:3199/pv//` and assert body `ok` and the proxied response's `set-cookie` path rewrite by setting a cookie from the target (extend the fake? no: the e2e target can just `Set-Cookie: t=1`); watch/unwatch round trip asserts the live socket receives one `preview-stream` when a fake stream event is injected through the broker seam (a real CDP chromium leg is the container run below, not the shared CI container); clean up every session at the end. +- [ ] **Step 2: Real-leg container run (manual, record results in the PR-free branch log):** on this host or a Linux container with `xvfb`, `x11vnc`, and `chromium`: launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` in it, verify Watch frames arrive (screenshot diff), Screen lane connects a noVNC RFB handshake (assert `RFB 003.008` greeting through `/pv/`), ports list shows `{4123, 9222, screen}`. On the mac node: verify every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`) - never a hang. +- [ ] **Step 3: Docs paragraphs** in the four files; voice rules; no em dashes (`bun run lint:prose`). +- [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` (then `bunx turbo build` once more; if `bun run test` surfaces the protocol-23 fixtures nobody updated, fix and re-run). +- [ ] **Step 5: Commit** `docs+test: preview e2e, security accounting, and the user guide`, then push `feat/pane-preview`. **No PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, screen on Linux, refusals on mac). From 2ceb743f096c2b9c11c4b2d74306a561d57cf90e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 00:00:21 -0700 Subject: [PATCH 004/139] Revise pane-preview plan after review, fix two spec errata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer findings folded in: pending-ack off-chain dial for forward lanes (ssh-relay-exec A-branch precedent), the real launch command and pane-runtime tmux-runner paths, launch.screen switch gate before spawn, fps clamp [1,5] with optional wire field, snake_case NodeEvent members plus a preview_closed event and target-gone/not-cdp reasons, node 1 MiB slow-link constant, Xvfb -auth pinned env, census tests are hand-pinned, node-results validators, five spec §7 dashboard behaviors, version-floor wording, and the static noVNC import (no dynamic import rule has one named exception). Spec fixes: the nonexistent polling-routes request-log exclusion, the dead docs/node-gates.md pointer. Branch rebuilt on current main: the docs commits had landed on the dropdown branch after #422 merged. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 485 +++++++++--------- .../specs/2026-10-21-pane-preview-design.md | 4 +- 2 files changed, 255 insertions(+), 234 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 63edc69ae..7e3f59880 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -4,132 +4,144 @@ **Goal:** Let a dashboard user see what a pane's process is showing: proxy a pane's loopback server (Open), screencast a pane's browser over CDP (Watch), and view a Linux pane's virtual display (Screen). -**Architecture:** A preview lane generalizes the ssh relay lane: the plane brokers per-connection byte lanes to a node, the node dials `127.0.0.1:` only, and three surfaces ride that lane or the `/ws/live` feed. Protocol bumps 22 -> 23 (exact-match gate). See the spec for the full design and its security argument. +**Architecture:** A preview lane generalizes the ssh relay lane: the plane brokers byte lanes keyed by opaque ref, the node dials `127.0.0.1:` only, and three surfaces ride that lane or the `/ws/live` feed. Protocol bumps 22 -> 23 (exact-match gate). See the spec for the full design and its security argument. **Tech Stack:** Bun, Elysia, TypeScript, React 19 (Vite SPA), tmux node agent, `@novnc/novnc` 1.7.0 (MPL-2.0), Playwright e2e. -**Spec:** `docs/superpowers/specs/2026-10-21-pane-preview-design.md` (approved; §12 settled). Read it before any task; this plan argues from it. +**Spec:** `docs/superpowers/specs/2026-10-21-pane-preview-design.md` (approved; §12 settled). Read it before any task; this plan argues from it. The pane-launch command on disk is `launch` (`node-frames.ts:784`), so where the spec's §12 summary or older notes say "create_session", read `launch`. ## Global Constraints -- Protocol doctrine: `NODE_PROTOCOL_VERSION` 22 -> 23 this round, exact-match gate at connect (server ships first; a lagging agent is held with an update message, never feature-gated). `MIN_NODE_VERSION` (`packages/subshell-protocol/src/versions.ts:123`) rises to `"2.4.0"` and `apps/node/agent/package.json` version rises to `"2.4.0"` **in the same commit as the bump** (both are already higher than the old floor by one, this keeps the pattern). -- Node executor contract (`apps/node/agent/AGENTS.md`): act only on verified commands, closed grammar at both ends, result-before-teardown, `pending: true` ack for slow off-chain work, self-expiring timers, never `process.exit`, refusals name the id and the remedy. -- Dialing is loopback-only (`127.0.0.1` / `::1`), both forward and CDP. Non-loopback is out of scope forever (spec §11). -- Close reasons are a closed set: `viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `target-gone` (the last one is decision 3 above). -- No dynamic `import()` in anything the compiled binaries bundle (server, node). The SPA has a sanctioned lazy-feature precedent at `apps/server/web/src/lib/ssh.ts`; only the SPA may code-split. +- Protocol doctrine (spec §4): `NODE_PROTOCOL_VERSION` 22 -> 23 this round, exact-match gate at connect (server ships first; a lagging agent is held with an update message, never feature-gated). `MIN_NODE_VERSION` (`packages/subshell-protocol/src/versions.ts:123`, today `"2.2.0"`) and `apps/node/agent/package.json` (today `"2.3.0"`) rise to `"2.4.0"` **in the same commit as the bump**; after this the floor equals the shipping version, which is how the floor crosses an exact-match protocol. +- Node executor contract (spec §5 + `apps/node/agent/AGENTS.md`): act only on verified commands, result before anything long-running (`pending: true` + off-chain work), self-expiring timers, never `process.exit`, refusals name ids and remedies. The forward lane's connect MUST follow the `ssh-relay-exec.ts` A-branch (`:101` returns `{ ok: true, data: { ..., pending: true } }` and connects off the exec chain): a SYN-blackholed target must not park unrelated commands. +- Dialing is loopback-only (`127.0.0.1` / `::1`), both forward and CDP; the bridge also distrusts any `webSocketDebuggerUrl` not aimed at loopback. Non-loopback is out of scope forever (spec §11). +- Close reasons are a closed set (spec §6 plus decisions 3): `viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `not-cdp`, `target-gone`. +- Watch fps is clamped node-side to `[PREVIEW_WATCH_FPS_MIN, PREVIEW_WATCH_FPS_MAX]` = [1, 5] (spec §4/§12.2); `fps` is OPTIONAL on the wire and defaults to 5. +- NO dynamic `import()` anywhere in the repo except `packages/pane-runtime/src/plugin-runtime.ts` (the one named exception in `.claude/rules/code-style.md`). The SPA has no code-splitting precedent on disk; noVNC ships as a static import (decision 4). - Pinned dependency versions: after `bun add`, run `syncpack fix` then `bun install`; check with `bun run lint:lockfile`. -- Authz: watch streams require `view`, every lane (proxy bytes, display) requires `edit`; a foreign pane answers 404, never 403. `/ws/live` remains the second authorization path: any new broadcast must pass the viewer-vs-row diff test (the control behind `docs/security.md` §11.14; see `apps/server/api/src/ws/__tests__/live-consistency.test.ts`). +- Authz: watch streams require `view`, every lane (proxy bytes, display) requires `edit`; a foreign pane answers 404, never 403. `/ws/live` remains the second authorization path: any new broadcast must pass the viewer-vs-row diff control in `apps/server/api/src/ws/__tests__/live-consistency.test.ts` (spec §6: "grows preview rows as a required task, not a follow-up"; `docs/security.md` §11.14). - Voice: no U+2014 anywhere authored (`bun run lint:prose`); UI copy rules in `AGENTS.md`; design tokens only (`bun run lint:design`). - Tests run via scripts (`bun run test --filter=`, never bare `bun test` for suites); invoke bash-shaped things with `env -u SHELLOPTS -u BASHOPTS`. - Task boundary verification: `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, plus `bunx turbo build` whenever `packages/` changed. No PR at any point (user tests first, on `feat/pane-preview`). **Implementation decisions recorded against the spec (approve these as you review):** -1. Spec §12 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, official repo's own package, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained, lazy chunk), fewer bytes and no drift. -2. Spec §6 implied one forward lane per session. A real dev server mixes keep-alive HTTP with a permanent HMR WebSocket, and one TCP lane serializes both, so the proxy opens **one node forward lane per inbound browser connection** (each gets its own fresh node ref; the session secret authorizes many lanes). Spec quotas stay, and bursts beyond them queue rather than fail. -3. The spec's named close-reason set has no member for "the proxied service itself hung up" (a dev server that exits while the pane lives). The set gains `target-gone`, and the node tells the plane about lane EOF with a new node->plane event `preview-closed { ref, reason }`: the ssh relay precedent has no such signal because its targets are agent-managed processes, while a preview lane's target dies freely. +1. Spec §12.4 / §7 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, the official repo's own package: `type: module`, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside, all internal imports relative). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained), fewer bytes and no drift. +2. Spec §6 describes `POST /preview/open` sending `preview_open` and awaiting the ack (kept, as the dialability probe). One TCP lane cannot carry a dev server's keep-alive HTTP and its permanent HMR WebSocket at once, so **browser connections beyond the probe each open their own node lane** (`preview_open` with a fresh 32-hex ref registered under the same session; the session secret authorizes many lanes; the probe lane closes as soon as it is acked). Spec quotas still count lanes; a burst beyond the cap queues up to 8 waiters instead of failing. +3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`pane_size`, `ssh_enabled`, `subshells_report`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a request-log mechanism that does not exist on disk; the spec erratum was fixed separately, and the plan implements the visible throttling the same sentence asks for. +4. Spec §12.4 asked for noVNC as a "lazy-loaded chunk". `.claude/rules/code-style.md` forbids dynamic import with one named exception and says a second exception is a design question, so RFB is a STATIC import. Bundle cost (~250 KB pre-gzip, one page's worth) is accepted; if a chunk boundary is wanted later it is a design question for the rules file, not this plan's call. +5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. --- ## File Structure **`packages/subshell-protocol/src/`** -- `node-frames.ts` (modify): version 23 + bump-history entry, five new commands, `PreviewDataFrame` link frame + `parsePreviewFrame`, `NodeEvent` members `preview-data` and `preview-stream`, `ready.previewEnabled`, `create_session` gains `screen`, new wire type `NodePreviewEnabledWire`. +- `node-frames.ts` (modify): version 23 + bump-history entry, four new commands into `NodeCommandBody` (:781), `PreviewDataFrame` + `parsePreviewFrame`, `NodeEvent` members `preview_data` / `preview_stream` / `preview_closed`, `launch` gains `screen`, `ready.previewEnabled`, `NodePreviewEnabledWire`. +- `node-results.ts` (modify): answer validators for the four new commands (file header rule: both sides validate `data`) and the `launch` result's optional `screen` echo. - `live-frames.ts` (modify): server member `preview-stream`, client members `watch` / `unwatch`, parse arms. -- `ssh-limits.ts` (modify): `PREVIEW_*` constants. +- `ssh-limits.ts` (modify): `PREVIEW_*` constants (spec §4 makes this file the home of shared link-lane limits). - `versions.ts` (modify): floor 2.4.0. -- `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): census auto-extends; add explicit boundary cases. +- `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): new hand-pinned census + boundary cases (the census tests in this file enumerate by hand and the count is the tripwire, `node-frames.test.ts:1105-1118`). **`apps/node/agent/src/`** -- `preview/flags.ts` (new): the `previewEnabled` mirror, cloned from wherever `sshEnabled` persists (find with `grep -rn sshEnabled apps/node/agent/src`). -- `preview/ports.ts`, `preview/forward.ts`, `preview/cdp-bridge.ts`, `preview/display.ts` (new): the four mechanisms. -- `commands/set-preview-enabled-exec.ts`, `commands/preview-open-exec.ts` (one arm, both `kind` branches), `commands/preview-close-exec.ts`, `commands/preview-ports-exec.ts` (new arms), `commands/index.ts` (dispatch), `commands/context.ts` (registry handles, mirroring the relay ones). -- `daemon.ts` (modify): `ready.previewEnabled`, inbound `preview_data` arm beside the relay arm, display orphan sweep timer. -- `tmux-runner.ts` / pane-create command (modify): inject `DISPLAY` when `screen` is set; pane-death hook calls `display.closeFor(paneId)`. +- `preview/flags.ts`, `preview/ports.ts`, `preview/forward.ts`, `preview/cdp-bridge.ts`, `preview/display.ts` (new): the four mechanisms plus the mirror. +- `commands/set-preview-enabled-exec.ts`, `commands/preview-open-exec.ts` (one arm, both `kind` branches), `commands/preview-close-exec.ts`, `commands/preview-ports-exec.ts` (new arms); `commands/index.ts` (dispatch); `commands/context.ts` (registry handles, mirroring the relay pair `ssh-relay.ts` + `ssh-relay-exec.ts`). +- `commands/launch.ts` (modify): `screen` handling, switch gate, DISPLAY/XAUTHORITY env injection, result echo. +- `daemon.ts` (modify): `ready.previewEnabled`, inbound `preview_data` arm beside the relay intake, display orphan sweep timer. - `__tests__/preview-*.test.ts` (new). **`apps/server/api/src/`** -- `services/nodes/preview-enabled.ts` (new, clones `nodes/ssh-enabled.ts` shape incl. its storage), route beside the ssh-enabled route. +- `services/nodes/preview-enabled.ts` (new, clones `nodes/ssh-enabled.ts` shape incl. its storage) + route file beside the ssh-enabled route. - `services/preview-broker.ts` + `services/__tests__/preview-broker.test.ts` (new). -- `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-close.route.ts`, `preview-watch-start.route.ts`, `preview-watch-stop.route.ts`, `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts`. +- `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-watch.route.ts` (POST + DELETE in one route module, or two files - follow the single-flat-route rule: it is one resource, the directory exists), `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts`. +- `api/subshells/create-subshell.route.ts` + `services/nodes/remote-launcher.ts` (modify): `screen` body field into the `launch` command, display audit on ack, refusal surfaced. +- `api/subshells/subshell-exit.route.ts` (modify): display close audit beside the death handling. - `preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts` + `preview/__tests__/` (new): the `/pv` proxy. -- `ws/live-ws.ts` (modify): `watch`/`unwatch` client frames; `ws/live-registry.ts` (modify): targeted send; `ws/__tests__/live-ws.test.ts`, `live-consistency.test.ts` (modify). -- `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts`, shares route (modify): revoke hook beside the existing `dropLiveSocketsFor` calls. -- Node link intake (the call site that feeds `routeRelayFrame` in `services/ssh-relay.service.ts:512`): route `preview-data`/`preview-stream` events to the broker beside it. +- `ws/live-ws.ts` (modify): `watch`/`unwatch` client frames; `ws/live-registry.ts` (modify): targeted send; `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts` (modify). +- `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts`, `api/subshells/subshell-shares.route.ts` (modify): revoke hook beside the existing `dropLiveSocketsFor` calls. +- `services/nodes/relay-frames.ts` (modify): the node-link intake that calls `getRelayBroker().routeRelayFrame(nodeId, frame)` grows a preview sibling routing `preview_data` / `preview_stream` / `preview_closed` to the preview broker. **`apps/server/web/src/`** -- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx`, `preview-toggle.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` to the bus). +- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus). -**Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview rules), `docs/security.md` (preview lane paragraph), `apps/node/agent/AGENTS.md` (executor note), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset for `@internal/server` + `@internal/node`). +**Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview-lane rules), `docs/security.md` (preview-lane paragraph), `apps/node/agent/AGENTS.md` (preview executors join the inventory; loopback invariant; xvfb/x11vnc/xauth optional runtime deps with named refusals), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset: `@internal/server` minor + `@internal/node` minor). --- -### Task 1: Protocol contract (frames, commands, limits, version floor) +### Task 1: Protocol contract (frames, commands, result validators, limits, version floor) **Files:** -- Modify: `packages/subshell-protocol/src/node-frames.ts` (version at :187, bump history near :80, command union and `parseNodeCommandBody`, `NodeEvent` at :1540, `parseNodeEvent` at :2048, `RelayFrame` region :1451 as the shape to clone, ready member region, `create_session` command) +- Modify: `packages/subshell-protocol/src/node-frames.ts` (version at :187, bump history near :80, `NodeCommandBody` at :781 and `parseNodeCommandBody`, `NodeEvent` at :1540, `parseNodeEvent` at :2048, `RelayFrame` region :1446 as the shape to clone, ready member region beside `sshEnabled`, the `launch` command member at :784) +- Modify: `packages/subshell-protocol/src/node-results.ts` (validators; read its header + the `launch` and `set_ssh_enabled` entries first) - Modify: `packages/subshell-protocol/src/ssh-limits.ts` -- Modify: `packages/subshell-protocol/src/versions.ts:123` +- Modify: `packages/subshell-protocol/src/versions.ts:123` (and its "Raised to..." comment) - Modify: `apps/node/agent/package.json` (`"version": "2.3.0"` -> `"2.4.0"`) - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` **Interfaces:** -- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `SetPreviewEnabledCommand`, `PreviewOpenCommand` (one command, kinds `forward` | `screencast`, exactly as spec §4 defines), `PreviewPortsGetCommand`, `PreviewCloseCommand`, `PreviewCloseReason`; link frame `PreviewDataFrame` + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview-data`, `preview-stream`, and `preview-closed` (node -> plane lane EOF: `{ type: "preview-closed"; ref: string; reason: PreviewCloseReason }`, decision 3); ready member `previewEnabled?: NodePreviewEnabledWire`; `create_session` optional `screen?: { width: number; height: number }`; constants `PREVIEW_DATA_MAX_BYTES`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_REF_RE`. +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_REF_RE`. - Consumes: nothing. -- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts` (mirror how it tests `relay` frames and the command census): +- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim): ```ts describe("preview commands", () => { - test("parses preview_open round trip", () => { - const cmd = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p1", port: 5173, lifetimeMs: 3_600_000 }; - expect(parseNodeCommandBody(JSON.stringify(cmd))).toEqual(cmd); - }); - test("refuses a port out of range", () => { - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 70000, lifetimeMs: 60_000 }))).toBeNull(); - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 1.5, lifetimeMs: 60_000 }))).toBeNull(); - }); - test("refuses a short or long ref", () => { - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "user" }))).toBeNull(); + test("preview_open round trips, both kinds", () => { + const fwd = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p1", port: 5173, lifetimeMs: 3_600_000 }; + expect(parseNodeCommandBody(JSON.stringify(fwd))).toEqual(fwd); + const sc = { type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p1", port: 9222, fps: 5, lifetimeMs: 3_600_000 }; + expect(parseNodeCommandBody(JSON.stringify(sc))).toEqual(sc); }); test("the kind decides whether fps may appear", () => { - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 99, lifetimeMs: 60_000 }))).toBeNull(); // over PREVIEW_WATCH_FPS_MAX - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, lifetimeMs: 60_000 }))).toBeNull(); // screencast requires fps - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 8080, fps: 5, lifetimeMs: 60_000 }))).toBeNull(); // forward forbids fps - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 5, lifetimeMs: 60_000 }))).toEqual({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: 5, lifetimeMs: 60_000 }); + const noFpsScreencast = { type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, lifetimeMs: 60_000 }; + expect(parseNodeCommandBody(JSON.stringify(noFpsScreencast))).toEqual(noFpsScreencast); // fps optional, spec §4; the arm defaults it + const fpsOnForward = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 8080, fps: 5, lifetimeMs: 60_000 }; + expect(parseNodeCommandBody(JSON.stringify(fpsOnForward))).toBeNull(); + }); + test("refuses out-of-range numbers", () => { + const base = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", lifetimeMs: 60_000 }; + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 70_000 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 1.5 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: PREVIEW_FPS_GRAMMAR_MAX + 1, lifetimeMs: 60_000 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, lifetimeMs: PREVIEW_LIFETIME_MIN_MS - 1 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, lifetimeMs: PREVIEW_LIFETIME_MAX_MS + 1 }))).toBeNull(); }); - test("refuses a lifetime outside the window", () => { - expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 80, lifetimeMs: 1000 }))).toBeNull(); + test("refuses a short or malformed ref", () => { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "viewer-left" }))).toBeNull(); }); - test("create_session accepts a screen size and bounds it", () => { - // clone the existing create_session parse test; add screen {width:1280,height:720} passes, width 100 fails + test("launch accepts a screen size and bounds it", () => { + // clone the existing launch parse test; add screen {width:1280,height:720} round-trips, width 100 -> null, height 5000 -> null }); }); -describe("preview link frames", () => { - const frame = { type: "preview_data", ref: "a".repeat(32), dir: "in", seq: 7, blob: Buffer.alloc(10).toString("base64") }; +describe("preview link frames and events", () => { + const frame = { type: "preview_data", ref: "a".repeat(32), dir: "in", blob: Buffer.alloc(10).toString("base64") }; test("round trips and enforces the raw blob cap", () => { expect(parsePreviewFrame(frame)).toEqual(frame); - const tooBig = { ...frame, blob: Buffer.alloc(PREVIEW_DATA_MAX_BYTES + 1).toString("base64") }; - expect(parsePreviewFrame(tooBig)).toBeNull(); - const exactlyCap = { ...frame, blob: Buffer.alloc(PREVIEW_DATA_MAX_BYTES).toString("base64") }; - expect(parsePreviewFrame(exactlyCap)).not.toBeNull(); // cap measured on RAW bytes, like RelayFrame's base64RawLength + expect(parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES + 1).toString("base64") })).toBeNull(); + expect(parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES).toString("base64") })).not.toBeNull(); // cap on RAW bytes, like RelayFrame's base64RawLength }); - test("preview-stream node event parses", () => { - const ev = { type: "preview-stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" }; - expect(parseNodeEvent(ev)).toEqual(ev); + test("the three node events parse", () => { + expect(parseNodeEvent({ ...frame })).toEqual(frame); + expect(parseNodeEvent({ type: "preview_stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" })).toEqual({ type: "preview_stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" }); + expect(parseNodeEvent({ type: "preview_closed", ref: "a".repeat(32), reason: "target-gone" })).toEqual({ type: "preview_closed", ref: "a".repeat(32), reason: "target-gone" }); }); }); ``` -- [ ] **Step 2: Run to verify it fails:** `env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/node-frames.test.ts` (expect parse results `null` / unknown command). +- [ ] **Step 2: Run to verify it fails:** `env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/node-frames.test.ts`. - [ ] **Step 3: Add constants** to `ssh-limits.ts`: ```ts -/** Byte cap on a preview lane frame's RAW payload, base64 aside. Shared by both ends. */ -export const PREVIEW_DATA_MAX_BYTES = 262_144; -/** Screencast defaults and ceilings for the CDP watch bridge. */ +/** Byte cap on a preview lane frame's RAW payload, base64 aside (spec §4). Shared by both ends. */ +export const PREVIEW_FRAME_MAX_BYTES = 262_144; +/** Bytes the link will not take before a lane calls it slow-link (spec §5: 1 MiB, both sides). */ +export const PREVIEW_LANE_BUFFER_MAX_BYTES = 1_048_576; +/** Screencast rates. The bridge CLAMPS the wire fps into [MIN, MAX]; the grammar is looser. */ +export const PREVIEW_WATCH_FPS_MIN = 1; export const PREVIEW_WATCH_FPS_DEFAULT = 5; -export const PREVIEW_WATCH_FPS_MAX = 15; +export const PREVIEW_WATCH_FPS_MAX = 5; +export const PREVIEW_FPS_GRAMMAR_MAX = 60; export const PREVIEW_WATCH_JPEG_QUALITY = 60; /** Frames the bridge may have in flight toward the plane before it starts dropping. */ export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; @@ -137,77 +149,83 @@ export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; export const PREVIEW_FORWARD_SESSIONS_PER_PANE = 4; export const PREVIEW_SESSIONS_PER_NODE = 16; export const PREVIEW_WATCH_SUBSCRIBERS_MAX = 8; -/** A forward lane with no bytes both ways for this long self-closes. */ +/** A forward lane with no bytes either way for this long self-closes (spec §6: 15 min). */ export const PREVIEW_IDLE_MS = 900_000; -/** Lanes are minted with a lifetime inside this window; the command refuses outside it. */ -export const PREVIEW_LIFETIME_MAX_MS = 4 * 3_600_000; +/** Lanes ride a lifetime inside this window; the command refuses outside it. */ export const PREVIEW_LIFETIME_MIN_MS = 60_000; -/** Lane refs double as proxy path secrets: 128 bits of hex. */ +export const PREVIEW_LIFETIME_MAX_MS = 4 * 3_600_000; +/** Lane refs double as proxy path secrets: 128 bits of lowercase hex. */ export const PREVIEW_REF_RE = /^[0-9a-f]{32}$/; ``` -- [ ] **Step 4: Add the grammar** to `node-frames.ts`. Clone the `RelayFrame` validation style exactly (strict base64 via `base64RawLength`, ref via `PREVIEW_REF_RE`): +- [ ] **Step 4: Add the command and frame grammar** to `node-frames.ts`, cloning the `RelayFrame` validation style exactly (strict base64 via `base64RawLength`, ref via `PREVIEW_REF_RE`): ```ts -/** What a `preview_open` asks for: the raw byte lane, or the read-only CDP screencast. Spec §4's kind union. */ +/** What a preview_open asks for: the raw loopback lane, or the read-only CDP screencast (spec §4). */ export type PreviewLaneKind = "forward" | "screencast"; -/** - * Opens one preview lane inside the pane's node. For "forward" the node dials - * 127.0.0.1:; for "screencast" is the CDP port and `fps` is - * required and bounded. The node never reaches beyond loopback either way. - */ +/** Opens one preview lane inside the pane's node. */ export interface PreviewOpenCommand { type: "preview_open"; + /** Opaque lane ref: 32 lowercase hex, also the proxy path secret family. */ ref: string; kind: PreviewLaneKind; + /** Attribution + liveness check on the node, never a routing target. */ paneId: string; - /** Dial target (forward) or devtools port (screencast). Loopback only. */ + /** Dial target (forward) or devtools port (screencast). The node dials loopback only. */ port: number; - /** Screencast only: frames per second the bridge emits; it drops extras before acking. */ + /** Screencast only; the bridge clamps it to [1, 5] and defaults it. Forward forbids it. */ fps?: number; - /** Self-expiry. The node closes the lane by this deadline whatever the plane does. */ + /** Self-expiry: the node closes the lane by this deadline whatever the plane does. */ lifetimeMs: number; } -/** Asks what the pane's process tree is listening on, plus the CDP port and display if any. */ +/** Why a lane or bridge ended. Closed set; the UI maps them (spec §6/§9). */ +export type PreviewCloseReason = + | "viewer-left" | "pane-gone" | "node-dropped" | "slow-link" | "over-cap" | "idle" | "revoked" + | "not-cdp" | "target-gone"; + +/** Lane teardown. Never access-gated (the set_ssh_enabled doctrine); an unknown ref answers closed:false. */ +export interface PreviewCloseCommand { type: "preview_close"; ref: string; reason: PreviewCloseReason } + +/** One pass: what the pane's process tree is listening on, plus the CDP and display hints (spec §4). */ export interface PreviewPortsGetCommand { type: "preview_ports_get"; paneId: string } -/** The per-node preview switch. Cloned from set_ssh_enabled, same mirror semantics. */ +/** The per-machine owner switch, plane sole author. Mirrors set_ssh_enabled's shape (decision 5). */ export interface SetPreviewEnabledCommand { type: "set_preview_enabled"; on: boolean; changedAt: string } -/** Why a lane ended. Closed set; spec §6 names them for the UI. */ -export type PreviewCloseReason = "viewer-left" | "pane-gone" | "node-dropped" | "slow-link" | "over-cap" | "idle" | "revoked"; - -/** Plane -> node teardown. Never access-gated: an unknown ref answers closed:false. */ -export interface PreviewCloseCommand { type: "preview_close"; ref: string; reason: PreviewCloseReason } +/** The preview lane frame: one capped base64 blob, both legs, parsed like RelayFrame (no seq; order is the link's). */ +export interface PreviewDataFrame { + type: "preview_data"; + ref: string; + /** in = toward the node's dialled socket, out = back toward the plane. */ + dir: "in" | "out"; + blob: string; +} ``` -Plus, in `NodeCommand`: add all four (`preview_open`, `preview_close`, `preview_ports_get`, `set_preview_enabled`). In `NodeEvent`: add +Into `NodeCommandBody` (:781) add all four; add `screen?: { width: number; height: number }` to the `launch` member with JSDoc "Allocates a virtual display and starts the pane on it. Linux only; a machine with the switch off refuses before spawning (spec §4)." Into `NodeEvent` (:1540) add `PreviewDataFrame` (the `preview_data` member, exactly as `relay` joins it) plus ```ts -| { type: "preview-data"; ref: string; dir: "in" | "out"; seq: number; blob: string } -| { type: "preview-stream"; ref: string; seq: number; w: number; h: number; ts: number; blob: string } -| { type: "preview-closed"; ref: string; reason: PreviewCloseReason } +| { type: "preview_stream"; ref: string; seq: number; w: number; h: number; ts: number; blob: string } +| { type: "preview_closed"; ref: string; reason: PreviewCloseReason } ``` -and in the `ready` member block: `previewEnabled?: NodePreviewEnabledWire` (clone how `sshEnabled` rides ready, `daemon.ts:416` sends it). `export interface NodePreviewEnabledWire { on: boolean; changedAt: string }` beside `NodeSshEnabledWire` in the same file. In `create_session`: `/** When set the node allocates a virtual display and exports DISPLAY for the pane's shell. */ screen?: { width: number; height: number }`, bounded 320..4096 both. +and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside `sshEnabled` (`daemon.ts:416` sends the latter). `export interface NodePreviewEnabledWire { on: boolean; changedAt: string }` beside `NodeSshEnabledWire` (:1294). Export the new symbols from `src/index.ts`; touch nothing that pulls a `node:*` builtin into the barrel (the mobile Metro rule in `.claude/rules/verification.md`). -Export `PreviewLaneKind` etc. from `src/index.ts`. Do NOT touch `src/index.ts` in a way that pulls a `node:*` builtin into the barrel (mobile Metro rule, `.claude/rules/verification.md`); these files are pure. +- [ ] **Step 5: Wire parses, bump, results.** Add the four arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, integer 1..`PREVIEW_FPS_GRAMMAR_MAX`), the three arms to `parseNodeEvent` (clone the `relay` arm at :2163), export `parsePreviewFrame` cloning `parseRelayFrame` with `PREVIEW_FRAME_MAX_BYTES`. Then `NODE_PROTOCOL_VERSION = 23` with the spec §4 bump entry VERBATIM: -- [ ] **Step 5: Wire parses and bump.** Add the four command arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, bounded), the three arms (`preview-data`, `preview-stream`, `preview-closed`) to `parseNodeEvent` (clone the relay arm), export `parsePreviewFrame` cloning `parseRelayFrame` verbatim with `PREVIEW_DATA_MAX_BYTES`. Then: `NODE_PROTOCOL_VERSION = 23` with this bump-history entry appended where the history lists 21->22: +> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch; a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. -> **22 -> 23:** the preview surface. Four new commands (`preview_open` with its `forward`/`screencast` kinds, `preview_close`, `preview_ports_get`, `set_preview_enabled`), the `preview_data` link frame and the `preview-data` / `preview-stream` / `preview-closed` node events, the `previewEnabled` ready field, and `create_session`'s optional `screen`. Additive, and breaking anyway: an agent that has not rebuilt never sends the new events, but the gate is exact-match, so it is refused at connect and told to update. Server ships first. +(Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (`{ ports, cdpPort, screen }` exactly as spec §4's answer), `set_preview_enabled` (follow the `set_ssh_enabled` entry's shape), and `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it). -Then `MIN_NODE_VERSION = "2.4.0"` (`versions.ts:123`) and `apps/node/agent/package.json` `"version": "2.4.0"`. - -- [ ] **Step 6: Run the census + whole package:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/subshell-protocol` (the census test auto-iterates commands; fix every fixture that pins version 22 or the old floor - `bun run test` for `@internal/server` and `@internal/node` will surface them; update in this commit, per doctrine). +- [ ] **Step 6: Run the whole protocol package, then the suites that pin the version:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/subshell-protocol`; then `bun run test` for `@internal/server` and `@internal/node` and fix every fixture that pins protocol 22 or the old floor, IN THIS COMMIT (bump doctrine). - [ ] **Step 7: Build + commit:** ```bash bunx turbo build --filter=@internal/subshell-protocol git add packages/subshell-protocol apps/node/agent/package.json -git commit -m "feat(protocol): preview lanes, watches, and displays (protocol 23, floor 2.4.0)" +git commit -m "feat(protocol): preview lanes, screencast events, and the owner switch (protocol 23, floor 2.4.0)" ``` --- @@ -215,37 +233,37 @@ git commit -m "feat(protocol): preview lanes, watches, and displays (protocol 23 ### Task 2: Live feed grammar (watch subscribe frames + stream frame) **Files:** -- Modify: `packages/subshell-protocol/src/live-frames.ts` +- Modify: `packages/subshell-protocol/src/live-frames.ts` (server union near `preview`/`subshell-recheck` at :43/:63, client union, `parseLiveClientFrame`) - Test: `packages/subshell-protocol/src/__tests__/live-frames.test.ts` **Interfaces:** -- Produces: `LiveServerFrame` member `{ type: "preview-stream"; id: string; ref: string; seq: number; w: number; h: number; blob: string }` (`id` is the subshell id; a viewer subscribes by pane, never by ref); `LiveClientFrame` members `{ type: "watch"; id: string }`, `{ type: "unwatch"; id: string }`; `parseLiveClientFrame` arms. -- Consumes: Task 1 for the blob concept only. +- Produces: `LiveServerFrame` member `{ type: "preview-stream"; id: string; seq: number; w: number; h: number; blob: string }` (`id` is the subshell id; per spec §6 the live frame carries NO ref - subscribers address panes, refs stay plane-side); `LiveClientFrame` members `{ type: "watch"; id: string }`, `{ type: "unwatch"; id: string }`; `parseLiveClientFrame` arms. +- Consumes: nothing (the blob is a string). -- [ ] **Step 1: Failing tests** in `live-frames.test.ts`: `parseLiveClientFrame` accepts `{"type":"watch","id":"s1"}` and `{"type":"unwatch","id":"s1"}`, rejects a missing `id`, rejects an extra key, and the file's existing exact-shape style rejects `preview-stream` on the CLIENT leg. Assert the server-frame serializer carries `blob` untouched. +- [ ] **Step 1: Failing tests** in `live-frames.test.ts`: `parseLiveClientFrame` accepts `{"type":"watch","id":"s1"}` and `{"type":"unwatch","id":"s1"}`; rejects a missing or non-string `id`; rejects unknown client types exactly as its current cases do (read the file's existing style first and clone it). - [ ] **Step 2: Run, watch them fail** (`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/live-frames.test.ts`). -- [ ] **Step 3: Implement** the three union members and the two parse arms. Add JSDoc: the stream frame is only ever published to sockets whose holder has `view` on the row (broker enforces; the frame is not addressed). -- [ ] **Step 4: Rerun the package tests, turbo build the protocol package, commit** `git commit -m "feat(protocol): live watch and preview-stream frames"`. +- [ ] **Step 3: Implement** the union members and parse arms. JSDoc: the stream frame is published only to sockets the broker has verified hold `view` on the row; it carries no `ref` on purpose. +- [ ] **Step 4: Package tests, turbo build, commit** `feat(protocol): live watch subscriptions and preview-stream frames`. --- ### Task 3: The per-node preview switch, end to end -**Files:** -- Read first (the clone source, mirror it exactly including storage): `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route (`grep -rn "set_ssh_enabled" apps/server/api/src/api/`), its route test; node side: `grep -rn "sshEnabled" apps/node/agent/src` and the set_ssh_enabled executor in `apps/node/agent/src/commands/`. +**Files (read the clone source first, mirror it exactly including its storage):** +- Read: `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route and test (`grep -rln "set_ssh_enabled" apps/server/api/src/api apps/server/api/src/services`), node half: `grep -rn "sshEnabled" apps/node/agent/src`, the `set_ssh_enabled` executor in `apps/node/agent/src/commands/`. - Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one. -- Modify: `apps/node/agent/src/commands/index.ts` (arm), `apps/node/agent/src/daemon.ts` (ready field), `apps/server/api/src/api/index.ts` or wherever the ssh toggle route mounts. -- Test: `apps/server/api/src/api/nodes/__tests__/preview-enabled-route.test.ts` (clone the ssh-enabled route test), `apps/node/agent/src/__tests__/preview-flags.test.ts`. +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416), the route mount. +- Test: plane route test + `apps/node/agent/src/__tests__/preview-flags.test.ts`. **Interfaces:** - Consumes: `SetPreviewEnabledCommand`, `NodePreviewEnabledWire` (Task 1). -- Produces: node `preview/flags.ts`: `getPreviewEnabled(): { on: boolean; changedAt: string } | undefined`, `setPreviewEnabled(state)`, `previewEnabledWire(): NodePreviewEnabledWire | undefined`; plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise<{ on: boolean }>` (row absent = OFF), `setPreviewEnabled(nodeId, userId, on): Promise`. +- Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. -- [ ] **Step 1: Read the clone source** (`ssh-enabled.ts` both halves and both tests). Note how it persists plane-side state, how it sends the command, how it audits `node.ssh_enabled.update`, and how the node mirror survives restart and drift. -- [ ] **Step 2: Failing route test** cloning the ssh one: cookie actor flips the switch -> 200 + audit row `node.preview_enabled.update` + `sendCommand` (from `@/services/nodes/node-rpc.js`) called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; GET answers `{on:false}` with no row (fail-closed default). -- [ ] **Step 3: Implement the plane half** (clone storage shape verbatim - same table/column kind the ssh switch uses, named `preview_enabled`), then the node half: executor gate-first pattern from the ssh executor (verified cmd only; store mirror file with mode 0600 exactly where the ssh mirror lives; result-before-teardown), `flags.ts` owning the in-process copy, `daemon.ts` ready frame gains `...(previewEnabledWire() ? { previewEnabled: previewEnabledWire() } : {})`. -- [ ] **Step 4: Mirror-drift test** (node side): an external edit to the mirror file between commands is re-read and re-reported, same as the ssh mirror test asserts. -- [ ] **Step 5: Focused runs then commit:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/server --filter=@internal/node` (or the two files), `git commit -m "feat: per-node preview switch mirroring the ssh enable posture"`. +- [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. +- [ ] **Step 2: Failing plane route test** cloning the ssh one: cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. +- [ ] **Step 3: Implement plane half** (clone the ssh switch's storage mechanism, name it `preview_enabled`), then node half: executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. +- [ ] **Step 4: Mirror-drift test** (node): an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. --- @@ -253,107 +271,107 @@ git commit -m "feat(protocol): preview lanes, watches, and displays (protocol 23 **Files:** - Create: `apps/node/agent/src/preview/ports.ts`, `apps/node/agent/src/commands/preview-ports-exec.ts` -- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/commands/context.ts` +- Modify: `apps/node/agent/src/commands/index.ts`, `commands/context.ts` (pane registry handle - read how `commands/launch.ts` reaches the tmux runner; `TmuxRunner.panePid(socket, subshellName)` is at `packages/pane-runtime/src/tmux-runner.ts:447`) - Test: `apps/node/agent/src/__tests__/preview-ports.test.ts` **Interfaces:** -- Consumes: pane pid from the tmux runner (`TmuxRunner.panePid`, `apps/node/agent/src/tmux-runner.ts:447`), display registry (Task 7; before it exists the `screen` field is `null`). -- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number })` -> `{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }`; refuses on non-Linux with error id `preview-ports-unsupported`. The `preview_ports_get` command result carries exactly that object. Plane Task 9 caches it 5 s per pane. +- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported`; command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. +- Consumes: pane pid (above), display registry (Task 7; before it exists, `screen` is null). -- [ ] **Step 1: Failing unit tests** over a fixture `/proc` written into a temp dir: +- [ ] **Step 1: Failing unit tests** over a fixture `/proc` in a temp dir (write `root//stat` with the ppid field, `root//cmdline`, `root//fd/N` symlinks whose target is the string `socket:[INODE]`, and `root/net/tcp` + `net/tcp6` LISTEN rows with the inode column): ```ts -// fixture: root//stat "pid (name) S ppid ...", root//cmdline, root//fd/N -> "socket:[INODE]" (symlink target need not exist for readlink), root/net/tcp with "state" column 0A LISTEN rows and the inode column. test("attributes a listening socket to the deepest descendant that owns it", async () => { - const r = await scanPanePorts({ procRoot, panePid: 100 }); + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); expect(r.ports).toEqual([{ proto: "tcp", port: 3000, pid: 102 }]); }); -test("finds the CDP port from a descendant's --remote-debugging-port flag", async () => { - // fixture: pid 102's cmdline is "chromium --remote-debugging-port=9222" and 9222 is LISTEN - expect((await scanPanePorts({ procRoot, panePid: 100 })).cdpPort).toBe(9222); +test("cdpPort is the LISTEN port whose /json/version probe answers", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async (p) => p === 9222 }); + expect(r.cdpPort).toBe(9222); }); test("a socket owned by an outsider never appears", async () => { - // fixture: socket:[8888] is LISTEN on port 8080 but owned by pid 999, a sibling of the pane subtree - const r = await scanPanePorts({ procRoot, panePid: 100 }); + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); expect(r.ports.some((p) => p.port === 8080)).toBe(false); }); test("no listeners answers with empty arrays, not an error", async () => { - const r = await scanPanePorts({ procRoot: emptyProcRoot, panePid: 100 }); - expect(r.ports).toEqual([]); - expect(r.cdpPort).toBeNull(); - expect(r.screen).toBeNull(); + const r = await scanPanePorts({ procRoot: emptyProcRoot, panePid: 100, probe: async () => false }); + expect(r.ports).toEqual([]); expect(r.cdpPort).toBeNull(); expect(r.screen).toBeNull(); }); -test("a listening IPv6 loopback row parses", async () => { - // net/tcp6 fixture row with "::1" local address, state 0A, owned by a descendant - const r = await scanPanePorts({ procRoot, panePid: 100 }); +test("an IPv6 loopback listener parses", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); expect(r.ports).toContainEqual({ proto: "tcp6", port: 4321, pid: 102 }); }); ``` -- [ ] **Step 2: Run, fail.** -- [ ] **Step 3: Implement `ports.ts`:** parse `net/tcp` + `net/tcp6` (`state === "0A"`, `local_address` hex `port:ip`; only accept ip `0100007F`/`00000000...1` loopback AND `00000000` - an all-zeros bind still counts, the node will dial loopback regardless), build ppid map from `*/stat`, BFS descendants of `panePid`, read each pid's `fd/*` readlinks, intersect inodes, take the deepest pid per inode. `cdpPort`: descendant `cmdline` containing `--remote-debugging-port=N` AND N in the port list. `screen`: `display.getFor(paneId)` (returns null until Task 7). Non-Linux: throw the named refusal before touching fs. `procRoot` exists only so tests can fake the tree; default `"/proc"`. -- [ ] **Step 4: Executor arm:** gate-first (preview switch ON else refuse naming remedy "turn on Preview for this node"; pane must have a live pid - `preview-ports-pane-gone`), result = `scanPanePorts` output. Slow-work rule: keep it under the chain budget - one pass reading only the pane subtree, `pending: true` ack NOT needed (it is fast), but cap the fd walk (`ports.ts` bounds: max 512 pids scanned; over-cap answers `preview-ports-overcap`). -- [ ] **Step 5: Green + commit** `feat(node): attribute pane subtrees' listening sockets, with CDP and display discovery`. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement `ports.ts`:** one pass (spec §5: stateless), parse `net/tcp`/`net/tcp6` state `0A`, hex `local_address:port`, collect inodes; ppid map from `/stat`; BFS descendants of `panePid` (cap: 512 pids, refusal `preview-ports-overcap`); readlink `fd/*`, intersect, deepest descendant per inode; CDP probe each listed port with the injected default `GET http://127.0.0.1:/json/version` (400 ms, `AbortSignal.timeout`, accept when the JSON has a `Browser` field); non-Linux refuses BEFORE touching fs. `procRoot` and `probe` exist only for tests. +- [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with the display registry's `screen` for that pane. +- [ ] **Step 5: Green + commit** `feat(node): attribute pane subtrees' listening sockets, probing CDP per spec`. --- ### Task 5: Forward lanes (node executor) **Files:** -- Read first: `apps/node/agent/src/commands/ssh-relay-exec.ts` + `apps/node/agent/src/relay-send.ts` (the pump, gate-first, pending-ack, close-not-gated, tombstone shapes to clone). -- Create: `apps/node/agent/src/preview/forward.ts`, `apps/node/agent/src/commands/preview-open-exec.ts` (dispatches on `kind`; this task lands the `forward` branch, Task 6 adds `screencast`), `apps/node/agent/src/commands/preview-close-exec.ts` -- Modify: `commands/index.ts`, `commands/context.ts` (a `previewForward` registry in ctx), `daemon.ts` (inbound `preview_data` frame arm beside the relay intake; node->plane `preview-data` emission uses the same sender as relay emission). -- Test: `apps/node/agent/src/__tests__/preview-forward.test.ts` (the `commands-ssh-relay.test.ts` precedent; find with `ls apps/node/agent/src/__tests__/ | grep relay`). +- Read first: `apps/node/agent/src/commands/ssh-relay-exec.ts` (the A-branch: gate-first, `pending: true`, off-chain connect, close-not-gated, tombstone) and `apps/node/agent/src/relay-send.ts` (the deliver-or-throw pump). +- Create: `apps/node/agent/src/preview/forward.ts`, `apps/node/agent/src/commands/preview-open-exec.ts` (this task lands the `forward` branch; Task 6 adds `screencast`), `apps/node/agent/src/commands/preview-close-exec.ts` +- Modify: `commands/index.ts`, `commands/context.ts` (a `previewForward` registry), `daemon.ts` (inbound `preview_data` arm beside the relay intake; emissions use the same sender as relay events) +- Test: `apps/node/agent/src/__tests__/preview-forward.test.ts` (the `__tests__/commands-ssh-relay.test.ts` precedent) **Interfaces:** -- Consumes: `PreviewOpenCommand`, `PreviewCloseCommand`, `PreviewDataFrame`, `preview-data` NodeEvent, limits (Task 1). -- Produces: `forward.ts`: `openLane({ref, port, paneId, lifetimeMs}): Promise` (dials and registers), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame: PreviewDataFrame): void`, `laneCount(paneId?)`, `closeAllForNode(reason)` (daemon link-drop hook). Result of `preview_open`: `{ ok: true }` after dial connects, or refusal ids `preview-target-refused`, `preview-node-off` (switch), `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. - -- [ ] **Step 1: Failing tests.** Start a real in-test echo server (`Bun.serve` or `net.createServer` on an ephemeral loopback port). Build a fake command context capturing emitted `preview-data` events. Assert: (a) open echoes inbound bytes to the target and target bytes back as `dir:"out"` with monotonic `seq`; (b) dial to a closed port answers the refusal, registers nothing; (c) `closeLane` on an unknown ref answers `closed:false` (tombstone) and `preview_close` is NOT access-gated (assert no enabled check on the close path); (d) node quota: the 17th open refuses with the named id, per-pane the 5th; (e) EOF: when the target socket ends, the lane is deregistered and the node emits `{ type: "preview-closed", ref, reason: "target-gone" }`; lifetime expiry emits `preview-closed` with `idle` when no bytes flowed (and no event at all if `preview_close` already landed first); (f) slow-link: with `PREVIEW_*` overridden low, an overflowing outbound pump closes the lane and emits `preview-closed` with `slow-link`. -- [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane; dial `net.connect({ host: "127.0.0.1", port })`, on error retry `host:"::1"` once, both failures = refused (loopback-only: never accept a host from the wire, port comes from the verified command). Socket data -> base64 -> emit `preview-data` event, seq per lane monotonic; if the link's buffered amount crosses `PREVIEW_DATA_MAX_BYTES * PREVIEW_WATCH_FRAMES_BUFFERED`, close `slow-link`. `ingestInboundFrame` (dir "in") writes the socket; writes count as activity; idle timer `PREVIEW_IDLE_MS` self-closes with `preview-closed` reason `idle`. Every lane death (socket end/error, idle, slow-link, forced close) deregisters first, then emits exactly one `preview-closed` event with its reason. Result-before-teardown and pending-ack exactly as `ssh-relay-exec.ts` does them. Wire `preview_open` result as the post-connect ack (the dial is the chain's work; it is fast, no pending needed, matching relay's connect behavior - read ssh-relay-exec and copy its verdict). -- [ ] **Step 4: Wire intake:** daemon inbound parser: where relay frames route (`parseRelayFrame` call site in `daemon.ts`), add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` command arm calls `closeLane` unconditionally. -- [ ] **Step 5: Green + commit** `feat(node): forward preview lanes over loopback sockets with named slow-link teardown`. +- Consumes: `PreviewOpenCommand` (`kind: "forward"`), `PreviewCloseCommand`, `PreviewDataFrame` + `preview_data` NodeEvent, `preview_closed` NodeEvent, limits. +- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-target-refused`, `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. + +- [ ] **Step 1: Failing tests** (real in-test echo server on an ephemeral loopback port; fake context capturing emitted events; tiny injected idle/lifetime budgets for the timer cases): + - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. + - (b) connected lane echoes: inbound `preview_data` dir "in" reaches the socket, socket bytes leave as `preview_data` dir "out" events. + - (c) `preview_close` is NOT access-gated (assert no switch check on the close path) and an unknown ref answers `{ closed: false }` (tombstone). + - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap`. + - (e) idle: injected small idle budget self-closes with `preview_closed` reason `idle`; target socket end emits `preview_closed` `target-gone` exactly once per lane; a `preview_close` that lands first suppresses the later EOF event (assert one emission total). + - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. +- [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. +- [ ] **Step 5: Green + commit** `feat(node): pending-ack forward lanes with off-chain dial and 1 MiB slow-link pump`. --- -### Task 6: CDP watch bridge (node executor) +### Task 6: CDP watch bridge (node side) **Files:** - Create: `apps/node/agent/src/preview/cdp-bridge.ts` -- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (add the `screencast` kind branch) -- Modify: `commands/index.ts`, `daemon.ts` (emit `preview-stream` with the same sender as Task 5's events) -- Test: `apps/node/agent/src/__tests__/preview-watch-exec.test.ts` +- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender) +- Test: `apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts` **Interfaces:** -- Consumes: `PreviewOpenCommand` (`kind: "screencast"`, `port` is the CDP port, `fps` from the wire), `preview-stream` NodeEvent, `PREVIEW_WATCH_*` (Task 1). -- Produces: `startBridge({ref, cdpPort, fps, lifetimeMs}): Promise`, `stopBridge(ref)`, `bridgeCount()`; refusals `preview-cdp-unreachable` (remedy text names launching chromium with `--remote-debugging-port`), switch-off and per-node cap reuse Task 5's ids (bridges count toward `PREVIEW_SESSIONS_PER_NODE`). -- Emission contract (Task 11 depends on it): one `preview-stream` per delivered frame with `seq` monotonic per ref, `w/h` from the CDP frame metadata, `ts = Date.now()`, `blob` the JPEG base64. +- Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. +- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed }): Promise<{ subscribers: number }>`; `stopBridge(ref, reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing test with a fake CDP endpoint.** In-test: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl: wsUrl }]` and a `Bun.serve({ websocket: ... })` handler that records received messages and pushes three `Page.screencastFrame` notifications with tiny JPEG payloads. Assert: start sends `Page.startScreencast` with `{ format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY }` and `everyNthFrame: 1`; each frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) even when its `preview-stream` emission is dropped by the fps throttle (fps 1, three frames in quick succession -> exactly one emission, three acks); socket close mid-stream stops the bridge and (clone Task 5's death-notification mechanism) tells the plane; `stopBridge` closes cleanly. +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement.** Global `fetch` + global `WebSocket` (both exist under bun on the node; no new deps). Sequence: GET `http://127.0.0.1:${cdpPort}/json/list` (loopback literal, never the wire value), prefer `type === "page"`, else first target, none -> `preview-cdp-unreachable`. Open WS to `webSocketDebuggerUrl` (validate it starts `ws://127.0.0.1:` or `ws://localhost:` before dialing - the port came from /proc, the URL comes from a socket answer; distrust it). Then: `Page.enable`, `Page.startScreencast {format:"jpeg", quality, everyNthFrame: 1}`. On `Page.screencastFrame` notification: ack immediately (always ack, even when dropping), throttle emissions to `fps` (interval floor `Math.ceil(1000 / fps)`), enforce `PREVIEW_WATCH_FRAMES_BUFFERED` in flight. Idle: no frame for 60s -> self-close (browser closed). Lifetime timer. Refusals/teardown exactly the executor contract. -- [ ] **Step 4: Executor branch** (`preview-open-exec.ts`, `kind === "screencast"`): gate-first, pane pid check, counts toward node caps. Add one unit test that the same open refuses a screencast whose `fps` is absent (grammar already rejects it; assert the branch's own bounds message is never reached with junk). -- [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge with always-ack and fps throttle`. +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is `127.0.0.1`/`localhost` -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). No stream traffic for 60 s -> self-close `idle`. Lifetime timer. +- [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, counts toward `PREVIEW_SESSIONS_PER_NODE`, result `{ ref, subscribers: 1 }` per Task 1's validator. +- [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. --- -### Task 7: Virtual displays (node) and launch-time `screen` +### Task 7: Virtual displays (node) and the gated `launch.screen` **Files:** -- Create: `apps/node/agent/src/preview/display.ts` (no new command arm: displays come up through `create_session.screen`, and `preview_ports_get` reports one via its `screen` field, both spec-shaped) -- Modify: `commands/index.ts`, pane-create path in tmux-runner (find: `grep -rn "DISPLAY\|create_session" apps/node/agent/src/tmux-runner.ts` and the command that owns pane creation), daemon startup (hourly orphan sweep), pane-death hook (grep the exit-status path in tmux-runner). +- Create: `apps/node/agent/src/preview/display.ts` +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) - Test: `apps/node/agent/src/__tests__/preview-display.test.ts` **Interfaces:** -- Consumes: `create_session.screen`, `dataDir` (the daemon's data dir - same root `/ssh//` uses; grep its derivation once and reuse the helper). -- Produces: `display.openFor(paneId, {width,height}, deps?): Promise<{display: string; rfbPort: number}>`, `display.getFor(paneId)`, `display.closeFor(paneId, reason)`, `display.sweepOrphans(alivePaneIds)`; `deps` injects `{ spawn, which, sleep, now }` so unit tests fake Xvfb. +- Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. +- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, Bun.which }` for fakes), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (fake `which`/`spawn`): allocation picks `:90` then the next free (a lockfile that exists is taken); state dir is created 0700 with log files 0600; missing `Xvfb` -> refusal `preview-display-missing-binaries` whose message names the remedy (install `xvfb` and `x11vnc`) and touches nothing; non-Linux -> `preview-display-unsupported` by name; readiness waits for the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; `sweepOrphans` tears down and releases every display whose pane id is absent from the alive list; rfb port chooser re-tries on a collision. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement:** numbers `:90..:199`, lock `/displays/.display-NN.lock` opened `wx` (EEXIST = taken); state `/displays//`; spawn `Xvfb :NN -screen 0 WxHx24 -nolisten tcp` (logfile inside state dir), poll `/tmp/.X11-unix/XNN`; pick rfb port from 5910+ by `net.listen` probe, then spawn `x11vnc -display :NN -rfbport P -localhost -forever -shared -bg`-less (keep the child, `-forever`, no `-auth`, logfile); `Bun.which` both first. `closeFor`: SIGTERM both, unlink lock, drop the dir's logs. Register in the in-process map AND return `screen` to `ports.ts` (Task 4's `screen` field goes live here). -- [ ] **Step 4: `create_session.screen`:** bounded in grammar already (Task 1). In the pane-create path: if `screen` present, `openFor` BEFORE spawn and inject `DISPLAY=:NN` into the pane env beside the SUBSHELL_* credentials (same env builder); if `openFor` refuses, fail the create with its named reason; pane-exit path calls `closeFor(paneId)`. Non-Linux + screen set: create refuses with `preview-display-unsupported` (never silently drop it). -- [ ] **Step 5: Sweep timer** in daemon startup: hourly, `sweepOrphans(live pane ids)` (clone the ssh per-pane-dir retention sweep's shape if one exists; else an interval beside the other self-timers). -- [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays per pane, DISPLAY injected at launch`. +- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; generate the auth file with `xauth generate -f :NN .` and start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp`; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). +- [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env beside the SUBSHELL_* credentials (same env builder launch.ts already passes to pane-runtime's `tmux-runner.ts` `opts.env` at :192/:267) -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. +- [ ] **Step 5: Sweep timer** beside daemon's other self-timers: hourly `display.sweepOrphans(alive pane ids)`, cloned from the ssh per-pane-dir retention pattern (`ssh-dir-retention` precedent; read it first). +- [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. --- @@ -361,35 +379,36 @@ test("a listening IPv6 loopback row parses", async () => { **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: the node-link event intake (the caller that feeds `routeRelayFrame`, `ssh-relay.service.ts:512`; put `preview-stream` -> `broker.onStreamEvent`, `preview-data` -> the pv proxy's lane pump (Task 10 consumes `broker.subscribeLane`); link-drop hook -> `broker.onNodeDropped(nodeId)`), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (add `closeAllForUser(userId, "revoked")` beside their `dropLiveSocketsFor` calls), the shares route likewise. +- Modify: `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` + `api/subshells/subshell-shares.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (edit-gated by the route, not the broker), `openLaneForConnection(ref): Promise<{ laneRef }>` + `subscribeLane(ref, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` (the broker maps node `preview-closed` events to `closed`; lane bytes arrive as `bytes`) + `sendLaneBytes(laneRef, bytes)`, `closeForward(ref, viewerUserId, reason)`, `startWatch({ viewerUserId, paneId, nodeId, cdpPort }): Promise<{ ref }>` (dedupes per pane+cdpPort; joins the viewer set, cap `PREVIEW_WATCH_SUBSCRIBERS_MAX` -> refusal `preview-watch-over-cap`), `stopWatch(viewerUserId, ref)`, `dropViewer(viewerUserId)` (socket close), `sessionFor(ref)`, `getPorts(nodeId, paneId)` (5 s cache, RPC via `sendCommand` from `@/services/nodes/node-rpc.js`), `closeAllForNode(nodeId, reason)`, `closeAllForUser(userId, reason)`, `onStreamEvent(ev)`; `PreviewSession { ref, kind, paneId, nodeId, port, createdBy, createdAt, viewers: Set, lastActivity }`. -- Consumes: `sendCommand`, Task 1 grammar, audit pattern from `ssh-relay.service.ts:437-455` (actorUserId null, metadata `{ ref, paneId, nodeId, viewerUserId, lifetimeMs }`). +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(sessionRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void`; `sendLaneBytes(laneRef, bytes)`; `closeSession(ref, actorUserId, reason)` (session + all its lanes); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (dedupe per pane+port, join semantics, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, ref)`; `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)`; `PreviewSession { ref, kind: "forward" | "screencast", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }`. +- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`), Task 1 grammar, audit metadata shape from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content - spec §6). -- [ ] **Step 1: Failing tests** (fake `sendCommand`, real test DB for audit): open sends `preview_open` with a fresh 32-hex ref, mints session, audits `node.preview_forward.open`; refusal propagates the node's id (no session recorded); per-pane cap 4 / node cap 16 (counting lanes too - a lane beyond cap queues up to 8 waiters then answers `preview-lane-over-cap`); watch dedupe: second viewer does NOT re-send `preview_open` (kind `screencast`, `port: cdpPort`, `fps: PREVIEW_WATCH_FPS_DEFAULT`), its join returns the existing ref; last viewer leaving sends `preview_close` reason `viewer-left` and audits `node.preview_watch.close`; stream fan-out: `onStreamEvent` delivers `{id: paneId, ref, seq, w, h, blob}` to each viewer's injected sender exactly once and counts activity; idle sweep (injected clock) closes at `PREVIEW_IDLE_MS` with reason `idle`; `closeAllForUser` audits closes with `revoked`; `closeAllForNode` does the same with `node-dropped`; `getPorts` caches for 5 s then re-RPCs. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per viewer via an injected `sendToUsers` and bumps activity; per-viewer byte debt closes THAT viewer only (`slow-link`), never the lane (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with module-singleton pattern + `resetForTests` per `.claude/rules/code-style.md`; lane machinery: a per-ref emitter the proxy subscribes to; every node command via `sendCommand`; every close path audits the pair (`node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`); timers self-expire like the ssh broker's do (read `ssh-relay.service.ts` broker for the exact habit). -- [ ] **Step 4: Green + commit** `feat(server): preview broker with quotas, refcounted watches, and revocation`. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests` per `.claude/rules/code-style.md`; timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. +- [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- -### Task 9: REST surface and authz matrix +### Task 9: REST surface, the launch `screen` field, and the authz matrix **Files:** -- Create: `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-close.route.ts`, `preview-watch-start.route.ts`, `preview-watch-stop.route.ts`, `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts` -- Modify: `api/subshells/create-subshell.route.ts` (+ `screen` body field -> service -> `create_session` command; find where the service builds the command: `grep -rn "create_session" apps/server/api/src/services/ | head`) -- Test: the auth matrix file (clone the actor-table shape from `__tests__/subshell-input-ssh.test.ts` and `subshell-view-ssh.test.ts`). +- Create: `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-watch.route.ts` (POST + DELETE on one path, the directory rule says one resource one file until it earns a split - these are two handlers, keep two files `preview-watch-start.route.ts` / `preview-watch-stop.route.ts` if the file-size rule bites), `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts` +- Modify: `api/subshells/create-subshell.route.ts` (body `screen`), `services/nodes/remote-launcher.ts` (find: `grep -rln "type: \"launch\"" apps/server/api/src` - pass `screen` into the `launch` command, surface node refusals, call `onDisplayOpened` on ack), `api/subshells/subshell-exit.route.ts` (call `onDisplayClosed(paneId, "pane-gone")` beside the death handling) +- Test: the auth matrix file (clone the actor-table shape from `__tests__/subshell-input-ssh.route.test.ts` and `subshell-view-ssh.test.ts`). **Interfaces:** -- Consumes: broker (Task 8), `loadSubshellAccess` + `accessAtLeast` (`src/lib/subshell-access.ts:32/:72`), disabledReason from `getPreviewEnabled` (Task 3) + node online + ready `previewEnabled` presence (older-but-23 agents that predate the field don't exist; absence means an agent that never flipped - treat missing as OFF). -- Produces (spec §6's exact paths): `GET /api/subshells/:id/preview` -> `{ disabledReason: "preview-disabled" | "node-offline" | "pane-gone" | null, ports, cdpPort, screen, access }`; `POST /:id/preview/open` body `{port}` -> `{ proxyBase: "/pv//" }`; `DELETE /:id/preview/open/:ref` -> `{ closed: boolean }`; `POST /:id/preview/watch` body `{cdpPort}` -> `{ ref }`; `DELETE /:id/preview/watch/:ref`; create-subshell body gains optional `screen: {width,height}`. +- Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. +- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine). For each route row: owner/`edit` grant -> 200 (POST/DELETE as specified); `view` grant -> 200 on GET and POST/DELETE watch, 403 on forward; foreign account -> 404 on ALL five; another pane's own subshell token -> resolves as its OWNER with shares off (the §4 sibling posture): 200 on rows for the OWNER's own panes, 404 on foreign ones; pin both halves and cite `docs/security.md` §4 in the test comment; node key -> 403 (permanent guard rejection §5.5); system key -> the full human gate as the `system` service user: nowhere without a deliberate grant, and a granted `edit` row DOES reach it (spec §6; assert the positive too, so the rule is pinned in both directions). Forward body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema (Elysia `t.Integer({ minimum: 1, maximum: 65535, description: ... })`, every property described per code-style rule). -- [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (each its own file, schemas as named constants, `detail: { operationId, tags: ["subshells"] }`); disabledReason computed BEFORE any node RPC (offline pane = cheap 404s first). Mount into the already-mounted subshells instance in `index.ts` (avoid the Elysia `App` TS2589 trap; new route files folded into an existing mounted group are the sanctioned shape). -- [ ] **Step 4: create flow:** body schema optional `screen` with 320..4096 bounds on both fields, passed through to `create_session`; refusal from the node surfaces as the create error verbatim. -- [ ] **Step 5: Green + commit** `feat(api): preview REST surface with the view/edit authz split`. +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 403 (guard rejection, §5.5); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (spec §3; pin the positive too). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 2: Fail.** +- [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). +- [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.onDisplayClosed(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). +- [ ] **Step 5: `bunx turbo build --filter=@internal/server` (Eden types), green, commit** `feat(api): preview REST surface, gated screen launches, and the view/edit authz matrix`. --- @@ -397,88 +416,90 @@ test("a listening IPv6 loopback row parses", async () => { **Files:** - Create: `apps/server/api/src/preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts`, `preview/__tests__/pv-proxy.test.ts` -- Modify: server bootstrap (mount HTTP `.all("/pv/:session/*")` route and a ws handler on the same wildcard path beside the ws plugin's routes), `package.json` (add `ws@8.22.0` - current latest, then `syncpack fix`; used ONLY with its documented stream override so it rides a lane duplex: `new WebSocket(url, { net: { createConnection: () => laneDuplex } })`; verify that override reads in `node_modules/ws/lib/websocket.js` before coding against it - the option name is the public one, the file is the proof). +- Modify: server bootstrap (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` beside the SPA routes - NOT under `/api` per spec §6 - and the WS upgrade handler on the same wildcard beside the ws plugin's registrations), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). **Interfaces:** -- Consumes: `broker.sessionFor(ref)`, `broker.openLaneForConnection(refSession): Promise<{ laneRef }>` + `broker.subscribeLane(refSession, cb)` where cb receives `{ bytes: Uint8Array } | { closed: PreviewCloseReason }` (broker maps the node's `preview-closed` onto it), `broker.sendLaneBytes(laneRef, bytes)`, `PreviewDataFrame` (Task 1). -- Produces: `lane-duplex.ts`: `class LaneDuplex extends Duplex` (constructor takes `{ pushOut(bytes), onBytes(cb), onClose(cb), pause(): void }`; `_write` frames through `sendLaneBytes`; inbound lane bytes `push`). - -- [ ] **Step 1: Failing unit tests** for the pieces that carry security meaning (run over a real in-proc `Bun.serve` target and a fake "node" = a helper that pumps `preview_data` frames to a loopback socket, ~25 lines of the Task 5 pump repeated in-test): - - outbound `Set-Cookie` gets `Path=/pv//` forced, `Domain` and `Secure` flags preserved otherwise untouched; - - inbound request carries a `Cookie` header rebuilt from the per-session jar only; the browser's own `Cookie` header NEVER reaches the target (send `Cookie: session=` and assert the target saw only jar contents); - - `Host` arrives at the target as `127.0.0.1:`; - - an upstream `Content-Security-Policy` keeps every directive EXCEPT `frame-ancestors` which is stripped (a test CSP with three directives -> two survive); - - a relative `Location: /foo` comes back prefixed `/pv//foo`; - - a WS upgrade carrying binary frames round-trips a payload byte-exact (fake target echoes); - - an upgrade whose `Origin` differs from the plane's own origin is refused (and same-origin is not); - - an unknown `/pv//` answers 404 with nothing leaked. +- Consumes: `broker.sessionFor/openLaneForConnection/subscribeLane/sendLaneBytes/closeSession`, Task 1 grammar. +- Produces: `lane-duplex.ts`: `class LaneDuplex extends Duplex` - constructor `({ writeOut(bytes): void, onLane(cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void })`; `_write` calls `writeOut`; lane bytes `push`; lane `closed` ends the duplex. + +- [ ] **Step 1: Failing unit tests** (real in-proc `Bun.serve` target + a 25-line in-test fake node that pumps `preview_data` to a loopback socket): + - outbound `Set-Cookie` forced to `Path=/pv//`, other attributes preserved; + - the browser's `Cookie` header NEVER reaches the target (send `Cookie: plane=`, assert the target saw no `plane=`, and later requests rebuilt from the jar alone); + - `Host` arrives as `127.0.0.1:`; + - upstream `Content-Security-Policy` with three directives survives as two, `frame-ancestors` gone; the report-only variant gets the same carve-out; + - relative `Location: /foo` returns prefixed `/pv//foo`; + - an upgrade with a cross-site `Origin` is refused; same-origin is not; + - binary WS round trip byte-exact; + - unknown `/pv//` -> 404, body leaks nothing; + - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (404 if unknown ref); `openLaneForConnection` (cap/queue rules live in the broker - proxy just awaits); build `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: strippedPath + query, headers: transformed })`; transform: strip `cookie`, set `host` literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per connection, the lane closes after the response completes - the browser reconnects per request, the jar carries state); response: strip hop-by-hops, rewrite `set-cookie` Path, rewrite `location`, strip `frame-ancestors` from both `content-security-policy` and `content-security-policy-report-only`; stream body straight through; on lane `closed` before the body completes -> 502 with a plain-text line naming the reason (do NOT retry mid-stream: a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** ws handler on `/pv/:session/*` (Elysia `.ws` on the same wildcard, beside the ws plugin's registrations; the upgrade path check must accept any suffix - noVNC and Vite HMR each dial their own path): check `Origin` against the plane's own origin (same rule `/ws/live` uses), resolve session, open a dedicated lane, dial `new WebSocket(wsUrl, { net: { createConnection: () => laneDuplex } })` with `wsUrl = ws://127.0.0.1:${port}/${restPathFromUpgradeUrl}`; pump both ways with the duplex backpressure (buffer beyond `PREVIEW_DATA_MAX_BYTES * 4` -> close both `slow-link`); close either side -> close both, reason `viewer-left`. -- [ ] **Step 5: Green; `bun run lint:lockfile` if the dependency was added; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunneling`. +- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): `Origin` must equal the plane's own origin (refuse cross-site), open a dedicated lane, dial `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })`, pump both directions (beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered -> close both `slow-link`); either side closing closes both and the lane, reason `viewer-left`. +- [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client frame switch), `ws/live-registry.ts` (add `sendToUsers(userIds: Iterable, frame: LiveServerFrame)` iterating the existing userId->sockets map), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `services/live-bus.ts` ONLY if the publish path needs the frame type in its union. -- Test: extend the two files. +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.startWatch/stopWatch` after a `loadSubshellAccess` >= view check against the socket's holder; socket close -> `broker.dropViewer`), `ws/live-registry.ts` (add `sendToUsers(userIds: Iterable, frame: LiveServerFrame)` over the existing byUser map), `services/nodes/relay-frames.ts` or the broker (whichever owns the socket-debt counter per viewer: a laggy viewer's byte debt drops THAT viewer with a `slow-link` close, per spec §9), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. +- Test: extend both files. **Interfaces:** -- Consumes: Tasks 2 (frames), 8 (`startWatch/stopWatch/dropViewer`), `loadSubshellAccess`. -- Produces: for a socket holder with `view`+ access, `{"type":"watch","id":paneId}` -> broker dedupe + later frames arrive as `{"type":"preview-stream","id","ref","seq","w","h","blob"}` via `sendToUsers`; socket close -> `dropViewer`. +- Consumes: Task 2 frames, Task 8 broker, `loadSubshellAccess` (:72). +- Produces: `sendToUsers`; for a holder with `view`+, `{"type":"watch","id":paneId}` later delivers `{"type":"preview-stream","id","seq","w","h","blob"}`. -- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a view grant delivers a stream frame whose `blob` passes untouched; watch from a foreign pane never reaches the broker (spy) and the frame is ignored without dropping the socket; unwatch stops delivery; close drops the viewer. -- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control; read its current matrix first, do not weaken any row):** add, for every (viewer, access) pair already in it, a watch case asserting the set of sockets that receive `preview-stream` for pane P is EXACTLY the set that has `view`-+ access to P through the REST path's own resolver - no more (not admins! not the owner's other sockets that never sent `watch`), no less. A revocation mid-stream (grant removed) must reach the already-open live socket as a stream-stop: reuse whatever mechanism the matrix uses for access changes; the pane row itself surviving is the rule (the socket stays open; frames just stop). -- [ ] **Step 3: Implement** (small): two arms in live-ws's parsed-frame switch calling broker + `sendToUsers`; the diff-test rows drive any broker-side leak fixes. -- [ ] **Step 4: Green + commit** `feat(server): /ws/live watch subscriptions gated through the live authorization control`. +- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant delivers a stream frame with `blob` untouched; watch on a foreign pane reaches the broker not at all (spy) and the socket stays open; `unwatch` stops delivery; socket close calls `dropViewer`; byte debt from one lagging socket drops only that viewer, `slow-link`. +- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives - compare, do not guess, against how the matrix handles revocations today). +- [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round. +- [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. --- -### Task 12: Dashboard preview section, hook, and Watch viewer +### Task 12: Dashboard preview section, hook, and the three surfaces **Files:** -- Create: `apps/server/web/src/hooks/use-subshell-preview.ts`, `lib/preview-bus.ts`, `components/preview/preview-section.tsx`, `components/preview/port-list.tsx`, `components/preview/watch-viewer.tsx`, `components/preview/preview-toggle.tsx` -- Modify: `routes/subshells_.$id.tsx` (compose the section), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` frames into the bus), node settings page (find the ssh toggle's component via `grep -rln ssh apps/server/web/src/components | head`; the preview toggle sits beside it, same disabled/tooltip posture), create/launch dialog (optional virtual display checkbox -> `screen` body field). -- Test: `components/preview/__tests__/preview-section.test.tsx` (clone whatever render harness `routes/__tests__/setup.test.tsx` uses; if the SPA has a lighter component-test precedent, follow it - list `find apps/server/web/src -name "*.test.ts*"` and imitate). +- Create: `apps/server/web/src/hooks/use-subshell-preview.ts`, `lib/preview-bus.ts`, `components/preview/preview-section.tsx`, `components/preview/port-list.tsx`, `components/preview/open-panel.tsx`, `components/preview/watch-viewer.tsx`, `components/preview/screen-launch-hint.tsx` +- Modify: `routes/subshells_.$id.tsx` (compose under the terminal, spec §7's column), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` to the bus), node settings page (the Preview toggle beside the ssh one, same gate/tooltip posture - read the ssh toggle's component first), create dialog (optional "Virtual display" -> `screen` body field), phone layout check (grep how the dock trades tiling for tabs and reuse the same layout predicate). +- Test: `components/preview/__tests__/` cloning whatever render harness `routes/__tests__/setup.test.tsx` uses (check `find apps/server/web/src -name "*.test.ts*"` and imitate the lighter precedent if one exists). **Interfaces:** -- Consumes: Task 9 routes (via the Eden treaty client - after adding routes, `bunx turbo build --filter=@internal/server` so types flow, per `.claude/rules/build.md`), Task 11 frames via the bus. -- Produces: bus API `subscribePreview(paneId, cb): () => void`; hook API `{ info, isLoading, openPort, closePort, startWatch, stopWatch }`. +- Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. +- Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }`. -- [ ] **Step 1: Tests** (render-level): a `view`-access render shows Watch toggles and NO Open buttons; `edit` adds Open (it opens `info.proxyBase` in a new tab via `window.open`, assert the spy); `disabledReason` renders the actual reason string as a disabled tooltip, not a mystery (`"preview-disabled"` -> the switch's remedy wording, per AGENTS disabled-control rule); a stale `ports` list older than 5 s (fake clock + fake fetch) re-requests on section focus; Watch viewer swaps its `src` to the newest frame blob's `data:image/jpeg;base64,${blob}` and ignores a lower `seq` (out-of-order arrival). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement.** Port rows are `label` over `detail` per the design system; badges `server` / `browser-debug` / `screen`; the section header follows the short-fixed-title rule ("Preview" is fine as a section heading; per-action copy names the act: "Open in a new tab", "Watch", "Stop watching"). `watch-viewer.tsx` renders an `` sized to the latest `{w,h}`, with the fps and pane name in `detail`. No em dashes in copy. -- [ ] **Step 4: `bunx turbo build` then green tests + commit** `feat(web): preview section with port list, watch viewer, and the node switch`. +- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the Screen action and the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit`; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). +- [ ] **Step 4: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. --- -### Task 13: Screen viewer with @novnc/novnc +### Task 13: Screen viewer with @novnc/novnc (static import) **Files:** +- Modify: `apps/server/web/package.json` (`bun add @novnc/novnc` -> `1.7.0`, `syncpack fix`, `bun install`) - Create: `apps/server/web/src/components/preview/screen-viewer.tsx` -- Modify: `apps/server/web/package.json` (`bun add @novnc/novnc` then `syncpack fix` && `bun install`; pin exact `1.7.0`), `preview-section.tsx` (Screen action when `info.screen` and `edit`: open a dialog hosting the viewer) -- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the lazy import; assert connect URL is `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render: `slow-link`, `revoked`, `viewer-left` each with a human line and the retry that is safe to offer). +- Modify: `preview-section.tsx` (Screen action when `info.screen` and `edit`: a forward-open against `info.screen.rfbPort`, then the viewer in the panel) +- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the `@novnc/novnc` module: `mock.module` per bun; assert the constructed URL is the session's WS tunnel root `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render a human line each: `slow-link`, `revoked`, `viewer-left`, `target-gone`). -**Interfaces:** Consumes: forward lane (`proxyBase` from the existing forward-open call for `info.screen.rfbPort` - Screen REUSES the forward lane, zero new protocol); produces `screen-viewer.tsx` component. +**Interfaces:** Consumes: the forward lane (Screen REUSES it - zero new protocol); produces `screen-viewer.tsx`. -- [ ] **Step 1: Read the library's shape:** `sed -n 1,80p node_modules/@novnc/novnc/core/rfb.js` (constructor `(target, url, options)`; check what option names it takes for a raw path). The connect URL is the lane's WS tunnel root: `new RFB(el, \`wss?://host/pv//\`)`, `scaleViewport = true`, `focusOnClick = true`, shared-viewer default. -- [ ] **Step 2: Failing test** (mock `import("@novnc/novnc")`; the component calls it only on mount). -- [ ] **Step 3: Implement** with `const { default: RFB } = await import("@novnc/novnc")` inside the dialog mount effect (SPA precedent `lib/ssh.ts` does exactly this for its lazy feature; the no-dynamic-import rule governs the bun-compiled binaries, and Vite chunks it - state that reason in the component's JSDoc). Cleanup disconnects RFB (lane `viewer-left`). -- [ ] **Step 4: MPL attribution:** the package ships `LICENSE.txt`; check whether the repo keeps a third-party notices file (`grep -rn -il "third.?party\|THIRD_PARTY" apps/website apps/docs docs | head`) - if one exists, append the `@novnc/novnc` MPL-2.0 entry; if not, the package.json license field plus `bun run lint:licenses` is the record. Say which in the commit body. -- [ ] **Step 5: Green + commit** `feat(web): noVNC-backed screen viewer over the forward lane`. +- [ ] **Step 1: Read the library's real shape** (`sed -n '1,80p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. +- [ ] **Step 2: Failing test** (mocked module; the component constructs on mount, disconnects on unmount - assert the lane's `viewer-left` path). +- [ ] **Step 3: Implement:** `new RFB(el, wsUrl)` with `scaleViewport = true`, `focusOnClick = true`; no credentials (spec §5: the lane and the grant are the authentication); `on disconnect` render the reason, `clipViewport` false. +- [ ] **Step 4: License record:** the package ships its MPL-2.0 LICENSE and `bun run lint:licenses` governs package fields; if the repo has a third-party notices list (`grep -riE "third[-_ ]?party" apps/website apps/docs docs | head`) add an entry, else say in the commit body that the package's own license file is the record (decision 1). +- [ ] **Step 5: Green + commit** `feat(web): noVNC screen viewer over the forward lane`. --- ### Task 14: e2e, docs, changeset, boundary verification **Files:** -- Create: `e2e/preview.spec.ts` (gated like the suite's existing skip rules; `e2e/AGENTS.md` governs), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser, open a server, screen on Linux), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor, one file, describes the feature once; never hand-edit CHANGELOGs). -- Modify: `apps/server/api/AGENTS.md` (audit inventory gains the six `node.preview_*` actions + a preview-lane rules paragraph: plane sees proxied bytes deliberately, spec §8; the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback-only invariant; Xvfb/x11vnc as optional runtime deps with named refusals). - -- [ ] **Step 1: e2e, API-level through `e2e/stack.ts` (async helpers only - the sync-spawn starvation trap):** launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response("ok")})'`; poll `GET /api/subshells/:id/preview` (as owner cookie) until `ports` includes 4123; `POST forward {port:4123}`; fetch `http://localhost:3199/pv//` and assert body `ok` and the proxied response's `set-cookie` path rewrite by setting a cookie from the target (extend the fake? no: the e2e target can just `Set-Cookie: t=1`); watch/unwatch round trip asserts the live socket receives one `preview-stream` when a fake stream event is injected through the broker seam (a real CDP chromium leg is the container run below, not the shared CI container); clean up every session at the end. -- [ ] **Step 2: Real-leg container run (manual, record results in the PR-free branch log):** on this host or a Linux container with `xvfb`, `x11vnc`, and `chromium`: launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` in it, verify Watch frames arrive (screenshot diff), Screen lane connects a noVNC RFB handshake (assert `RFB 003.008` greeting through `/pv/`), ports list shows `{4123, 9222, screen}`. On the mac node: verify every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`) - never a hang. -- [ ] **Step 3: Docs paragraphs** in the four files; voice rules; no em dashes (`bun run lint:prose`). -- [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` (then `bunx turbo build` once more; if `bun run test` surfaces the protocol-23 fixtures nobody updated, fix and re-run). -- [ ] **Step 5: Commit** `docs+test: preview e2e, security accounting, and the user guide`, then push `feat/pane-preview`. **No PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, screen on Linux, refusals on mac). +- Create: `e2e/preview.spec.ts`, `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). +- Modify: `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). + +- [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: a stream frame reaches the owner's live socket when the test injects one through the broker's test seam IF the e2e boots the server in-proc (read how `e2e/` boots it first; if it is child-process, skip the inject and cover frames in unit instead, say which in the spec comment); clean up sessions, pane, then close. +- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). +- [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). +- [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. +- [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac). diff --git a/docs/superpowers/specs/2026-10-21-pane-preview-design.md b/docs/superpowers/specs/2026-10-21-pane-preview-design.md index 85dcebb81..94829c4a6 100644 --- a/docs/superpowers/specs/2026-10-21-pane-preview-design.md +++ b/docs/superpowers/specs/2026-10-21-pane-preview-design.md @@ -41,7 +41,7 @@ One lane mechanism, three consumers. The plane pairs each session by an opaque r ## 4. Protocol: bump 22 → 23, shipped together -Doctrine copied from the bump history (`node-frames.ts:80-187`): additive in shape, breaking anyway because the gate is exact-match; server first; `MIN_NODE_VERSION` and `apps/node/agent/package.json` rise in the same commit (`docs/node-gates.md`). There is no feature flag and there will not be one: a lagging agent is held (update-only) until the desktop update path crosses it, the same doctrine `ssh_enabled` (16) and the agent relay (18) used, because a capability gate cannot live with "old agent ignored the frame" reading the same as "new agent answered no." +Doctrine copied from the bump history (`node-frames.ts:80-187`): additive in shape, breaking anyway because the gate is exact-match; server first; `MIN_NODE_VERSION` and `apps/node/agent/package.json` rise in the same commit). There is no feature flag and there will not be one: a lagging agent is held (update-only) until the desktop update path crosses it, the same doctrine `ssh_enabled` (16) and the agent relay (18) used, because a capability gate cannot live with "old agent ignored the frame" reading the same as "new agent answered no." Bump-history entry to land with the code: @@ -99,7 +99,7 @@ Executor arms live beside the relay arms (`commands/ssh-relay-exec.ts` is the sh Pane detail (`routes/subshells_.$id.tsx`, the same terminal-wrapper column) gains a **Preview** section under the terminal: -- Port list from `GET /preview`, refreshed on pane focus and after any pane-output burst with visible throttling (the polling-routes exclusion keeps it out of request logs). Each row: port, detected kind (server / browser-debug / screen), **Open** (tooltip-disabled with the real reason when `access === "view"` or the node switch is off), **Watch** on the browser row. +- Port list from `GET /preview`, refreshed on pane focus and after any pane-output burst, throttled to one request per five-second window. Each row: port, detected kind (server / browser-debug / screen), **Open** (tooltip-disabled with the real reason when `access === "view"` or the node switch is off), **Watch** on the browser row. - Open renders a collapsible side panel holding the `/pv/` iframe with an "open in new tab" affordance; Watch renders a canvas fed by `preview-stream` frames with an fps/age badge and an explicit "still there?" staleness rule (no frame for 5 s reads stale, matching the feed's posture rather than showing a frozen page as live). - Screen opens the noVNC canvas over a forward session (`edit`-gated) pointed at the pane's `screen.rfbPort`, RFB bytes riding `/pv/:session/` upgrades. - Gating reuses the existing trio: disabled control + `disabledReason` tooltip per the design system; `view` vs `edit` read from the row's resolved `access` exactly as `subshell-terminal.tsx` does for stdin. Disclosure: the foreign-node icon and banner machinery (`use-trust-notices`, `TrustIndicators`) is unchanged, but the Preview section raises the **shared-pane** banner variant once per device when first opened, because this surface shows more than the terminal does. From a49b4d7f791c3315197ee7fb56c2a4a5eebe6691 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 00:03:39 -0700 Subject: [PATCH 005/139] Fold decomposition review into pane-preview plan The WS tunnel split is the substantive one: one blind byte pump cannot serve both its consumers (raw RFB needs no handshake, HMR targets need theirs), so sessions carry an http/raw mode the broker derives from the pane's registered screen port - no wire or REST change. Plus the close-reason runtime list and pinned census, a closeLaneForConnection broker member, deps seams on the forward lane, bridge, and broker, the queue constant, raw/ws test cases that cannot silently pass, the docs meta.json registration, and the child-process e2e reality. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 7e3f59880..5073d6af2 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -31,6 +31,9 @@ 3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`pane_size`, `ssh_enabled`, `subshells_report`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a request-log mechanism that does not exist on disk; the spec erratum was fixed separately, and the plan implements the visible throttling the same sentence asks for. 4. Spec §12.4 asked for noVNC as a "lazy-loaded chunk". `.claude/rules/code-style.md` forbids dynamic import with one named exception and says a second exception is a design question, so RFB is a STATIC import. Bundle cost (~250 KB pre-gzip, one page's worth) is accepted; if a chunk boundary is wanted later it is a design question for the rules file, not this plan's call. 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. +6. Spec §5 calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port from `onDisplayOpened` (Task 8), so no REST or protocol field is added; the spec's §6 body stays `{ port }`. + +**Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. --- @@ -80,10 +83,10 @@ - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` **Interfaces:** -- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_REF_RE`. +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_REF_RE`. - Consumes: nothing. -- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim): +- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: ```ts describe("preview commands", () => { @@ -111,7 +114,19 @@ describe("preview commands", () => { expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "viewer-left" }))).toBeNull(); }); test("launch accepts a screen size and bounds it", () => { - // clone the existing launch parse test; add screen {width:1280,height:720} round-trips, width 100 -> null, height 5000 -> null + // base mirrors the file's own launch fixture (:48) - copy its remaining fields if the parser demands them + const base = { type: "launch", subshellId: "s1", socket: "subshell-abc", cwd: "/home/u/repo", harnessId: "claude-code", preset: { name: "P", env: {}, flags: [], settings: null, configIsolation: false }, subshellEnv: { SUBSHELL_API_KEY: "subshell_x" } }; + expect(parseNodeCommandBody(JSON.stringify({ ...base, screen: { width: 1280, height: 720 } }))).toMatchObject({ screen: { width: 1280, height: 720 } }); + expect(parseNodeCommandBody(JSON.stringify({ ...base, screen: { width: 100, height: 720 } }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, screen: { width: 1280, height: 5000 } }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify(base))).toMatchObject({ type: "launch" }); + }); + test("the close-reason census is pinned", () => { + // the SSH_RELAY_CLOSE_REASONS loop at :1412 is the pattern: every member round trips, one bogus reason is refused + for (const reason of PREVIEW_CLOSE_REASONS) { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "a".repeat(32), reason }))).toEqual({ type: "preview_close", ref: "a".repeat(32), reason }); + } + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "a".repeat(32), reason: "because" }))).toBeNull(); }); }); describe("preview link frames and events", () => { @@ -149,6 +164,8 @@ export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; export const PREVIEW_FORWARD_SESSIONS_PER_PANE = 4; export const PREVIEW_SESSIONS_PER_NODE = 16; export const PREVIEW_WATCH_SUBSCRIBERS_MAX = 8; +/** Lane waiters queued beyond the pane cap before a refusal (decision 2's burst posture). */ +export const PREVIEW_LANE_QUEUE_MAX = 8; /** A forward lane with no bytes either way for this long self-closes (spec §6: 15 min). */ export const PREVIEW_IDLE_MS = 900_000; /** Lanes ride a lifetime inside this window; the command refuses outside it. */ @@ -185,6 +202,9 @@ export type PreviewCloseReason = | "viewer-left" | "pane-gone" | "node-dropped" | "slow-link" | "over-cap" | "idle" | "revoked" | "not-cdp" | "target-gone"; +/** The runtime list beside the union, per the enums rule: one edit site, and the tests' census iterates it. */ +export const PREVIEW_CLOSE_REASONS: readonly PreviewCloseReason[] = ["viewer-left", "pane-gone", "node-dropped", "slow-link", "over-cap", "idle", "revoked", "not-cdp", "target-gone"]; + /** Lane teardown. Never access-gated (the set_ssh_enabled doctrine); an unknown ref answers closed:false. */ export interface PreviewCloseCommand { type: "preview_close"; ref: string; reason: PreviewCloseReason } @@ -320,7 +340,7 @@ test("an IPv6 loopback listener parses", async () => { **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "forward"`), `PreviewCloseCommand`, `PreviewDataFrame` + `preview_data` NodeEvent, `preview_closed` NodeEvent, limits. -- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-target-refused`, `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. +- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks, deps }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-target-refused`, `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. The `deps` seam (`{ connect?, now?, timer? }`, same style as Task 7's) is how the tests hold a connect open, fake the clock, and shrink the idle/lifetime/slow-link budgets - module-level `export const` limits are not overridable from a test. - [ ] **Step 1: Failing tests** (real in-test echo server on an ephemeral loopback port; fake context capturing emitted events; tiny injected idle/lifetime budgets for the timer cases): - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. @@ -345,7 +365,7 @@ test("an IPv6 loopback listener parses", async () => { **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. -- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed }): Promise<{ subscribers: number }>`; `stopBridge(ref, reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. +- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the 60 s idle, and the fps throttle are all testable without a real browser or clock); `stopBridge(ref, reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. - [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5. - [ ] **Step 2: Fail.** @@ -383,12 +403,12 @@ test("an IPv6 loopback listener parses", async () => { - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(sessionRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void`; `sendLaneBytes(laneRef, bytes)`; `closeSession(ref, actorUserId, reason)` (session + all its lanes); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (dedupe per pane+port, join semantics, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, ref)`; `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)`; `PreviewSession { ref, kind: "forward" | "screencast", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }`. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(sessionRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void`; `sendLaneBytes(laneRef, bytes)`; `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeSession(ref, actorUserId, reason)` (session + all its lanes); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (dedupe per pane+port, join semantics, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, ref)`; `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)`; `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }`; `openForward` sets `mode` to `raw` when the port is the pane's registered screen `rfbPort` (recorded by `onDisplayOpened`), else `http` - that is decision 6's discriminator, and `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`), Task 1 grammar, audit metadata shape from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content - spec §6). -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per viewer via an injected `sendToUsers` and bumps activity; per-viewer byte debt closes THAT viewer only (`slow-link`), never the lane (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per viewer via an injected `sendToUsers` and bumps activity; per-viewer byte debt closes THAT viewer only (`slow-link`), never the lane (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests` per `.claude/rules/code-style.md`; timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToUsers?, send? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep, fake sender for fan-out and `sendCommand`), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -429,12 +449,13 @@ test("an IPv6 loopback listener parses", async () => { - upstream `Content-Security-Policy` with three directives survives as two, `frame-ancestors` gone; the report-only variant gets the same carve-out; - relative `Location: /foo` returns prefixed `/pv//foo`; - an upgrade with a cross-site `Origin` is refused; same-origin is not; - - binary WS round trip byte-exact; + - binary WS round trip byte-exact in `http` mode, with the target BEHIND the fake node - if the `ws` client ever bypassed the lane (dialing the plane-local port), the test fails with connection refused instead of silently passing; + - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); - unknown `/pv//` -> 404, body leaks nothing; - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): `Origin` must equal the plane's own origin (refuse cross-site), open a dedicated lane, dial `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })`, pump both directions (beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered -> close both `slow-link`); either side closing closes both and the lane, reason `viewer-left`. +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): `Origin` must equal the plane's own origin (refuse cross-site), open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -496,9 +517,9 @@ test("an IPv6 loopback listener parses", async () => { **Files:** - Create: `e2e/preview.spec.ts`, `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). -- Modify: `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). +- Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). -- [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: a stream frame reaches the owner's live socket when the test injects one through the broker's test seam IF the e2e boots the server in-proc (read how `e2e/` boots it first; if it is child-process, skip the inject and cover frames in unit instead, say which in the spec comment); clean up sessions, pane, then close. +- [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. - [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). - [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. From 59954f57e0adb8901b98fef8cff7e9a40c8df19a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 00:26:38 -0700 Subject: [PATCH 006/139] Fold round-2 decomposition review into the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch is now wired end to end: REST owns bridge lifecycle, the live watch/unwatch frames attach the VIEWING SOCKET, and the SPA sends both halves (the feed hook's existing send() becomes the seam). Lane subscriptions key on laneRef so an iframe and an HMR socket under one session stop trading bytes. preview_close reaches the CDP bridge and link-drop reaches bridgeCloseAll. Plane->node lane bytes get a real sender cloning sendRelayFrameOverNodeSocket - they never rode sendCommand. The bridge drops the invented traffic-idle rule (a static page repaints nothing) and keeps spec §5's zero-subscriber semantics. Plus stopWatch by pane id, closeForPane replacing the uncalled closeSession, deps seams the tests name, per-socket §11.14 precision, the e2e/docs/commit-discipline minors, and the NIT list. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 5073d6af2..67e75518a 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -45,7 +45,7 @@ - `live-frames.ts` (modify): server member `preview-stream`, client members `watch` / `unwatch`, parse arms. - `ssh-limits.ts` (modify): `PREVIEW_*` constants (spec §4 makes this file the home of shared link-lane limits). - `versions.ts` (modify): floor 2.4.0. -- `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): new hand-pinned census + boundary cases (the census tests in this file enumerate by hand and the count is the tripwire, `node-frames.test.ts:1105-1118`). +- `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): new hand-pinned census + boundary cases (the census tests in this file enumerate by hand and the count is the tripwire, `node-frames.test.ts:1105-1120`). **`apps/node/agent/src/`** - `preview/flags.ts`, `preview/ports.ts`, `preview/forward.ts`, `preview/cdp-bridge.ts`, `preview/display.ts` (new): the four mechanisms plus the mirror. @@ -59,14 +59,14 @@ - `services/preview-broker.ts` + `services/__tests__/preview-broker.test.ts` (new). - `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-watch.route.ts` (POST + DELETE in one route module, or two files - follow the single-flat-route rule: it is one resource, the directory exists), `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts`. - `api/subshells/create-subshell.route.ts` + `services/nodes/remote-launcher.ts` (modify): `screen` body field into the `launch` command, display audit on ack, refusal surfaced. -- `api/subshells/subshell-exit.route.ts` (modify): display close audit beside the death handling. +- `api/subshells/subshell-exit.route.ts` (modify): `broker.closeForPane(paneId, "pane-gone")` beside the death handling (it covers the display audit via `onDisplayClosed` and every lane/bridge of the pane). - `preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts` + `preview/__tests__/` (new): the `/pv` proxy. - `ws/live-ws.ts` (modify): `watch`/`unwatch` client frames; `ws/live-registry.ts` (modify): targeted send; `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts` (modify). - `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts`, `api/subshells/subshell-shares.route.ts` (modify): revoke hook beside the existing `dropLiveSocketsFor` calls. - `services/nodes/relay-frames.ts` (modify): the node-link intake that calls `getRelayBroker().routeRelayFrame(nodeId, frame)` grows a preview sibling routing `preview_data` / `preview_stream` / `preview_closed` to the preview broker. **`apps/server/web/src/`** -- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus). +- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus; export its `send()` client-frame helper so `watch`/`unwatch` can actually be sent). **Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview-lane rules), `docs/security.md` (preview-lane paragraph), `apps/node/agent/AGENTS.md` (preview executors join the inventory; loopback invariant; xvfb/x11vnc/xauth optional runtime deps with named refusals), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset: `@internal/server` minor + `@internal/node` minor). @@ -114,7 +114,7 @@ describe("preview commands", () => { expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "viewer-left" }))).toBeNull(); }); test("launch accepts a screen size and bounds it", () => { - // base mirrors the file's own launch fixture (:48) - copy its remaining fields if the parser demands them + // base mirrors the file's own launch fixture (:47-54) - copy its remaining fields if the parser demands them const base = { type: "launch", subshellId: "s1", socket: "subshell-abc", cwd: "/home/u/repo", harnessId: "claude-code", preset: { name: "P", env: {}, flags: [], settings: null, configIsolation: false }, subshellEnv: { SUBSHELL_API_KEY: "subshell_x" } }; expect(parseNodeCommandBody(JSON.stringify({ ...base, screen: { width: 1280, height: 720 } }))).toMatchObject({ screen: { width: 1280, height: 720 } }); expect(parseNodeCommandBody(JSON.stringify({ ...base, screen: { width: 100, height: 720 } }))).toBeNull(); @@ -244,10 +244,12 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside ```bash bunx turbo build --filter=@internal/subshell-protocol -git add packages/subshell-protocol apps/node/agent/package.json +git add packages/subshell-protocol apps/node/agent/package.json apps/server/api apps/node/agent git commit -m "feat(protocol): preview lanes, screencast events, and the owner switch (protocol 23, floor 2.4.0)" ``` +(Step 6's version-pin fixture fixes in `apps/` ship IN this commit - the bump doctrine means HEAD is only green when the floor, the version, and every fixture that pins them land together.) + --- ### Task 2: Live feed grammar (watch subscribe frames + stream frame) @@ -280,9 +282,9 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing plane route test** cloning the ssh one: cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. -- [ ] **Step 3: Implement plane half** (clone the ssh switch's storage mechanism, name it `preview_enabled`), then node half: executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. -- [ ] **Step 4: Mirror-drift test** (node): an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. Node: a verified `set_preview_enabled` flips the mirror (0600), a gated command refuses while off, and an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 3: Implement the plane half** (clone the ssh switch's storage mechanism, name it `preview_enabled`); plane tests green. +- [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. --- @@ -323,6 +325,8 @@ test("an IPv6 loopback listener parses", async () => { }); ``` +Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switch OFF refuses naming the remedy; a dead pane refuses `preview-ports-pane-gone`; a successful answer passes Task 1's `node-results` validator (the shape is contract, not vibes). + - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `ports.ts`:** one pass (spec §5: stateless), parse `net/tcp`/`net/tcp6` state `0A`, hex `local_address:port`, collect inodes; ppid map from `/stat`; BFS descendants of `panePid` (cap: 512 pids, refusal `preview-ports-overcap`); readlink `fd/*`, intersect, deepest descendant per inode; CDP probe each listed port with the injected default `GET http://127.0.0.1:/json/version` (400 ms, `AbortSignal.timeout`, accept when the JSON has a `Browser` field); non-Linux refuses BEFORE touching fs. `procRoot` and `probe` exist only for tests. - [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with the display registry's `screen` for that pane. @@ -340,7 +344,7 @@ test("an IPv6 loopback listener parses", async () => { **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "forward"`), `PreviewCloseCommand`, `PreviewDataFrame` + `preview_data` NodeEvent, `preview_closed` NodeEvent, limits. -- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks, deps }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-target-refused`, `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone`. The `deps` seam (`{ connect?, now?, timer? }`, same style as Task 7's) is how the tests hold a connect open, fake the clock, and shrink the idle/lifetime/slow-link budgets - module-level `export const` limits are not overridable from a test. +- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks, deps }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone` (there is no host-refusal: the wire carries a port, never a host, and the grammar bounds it, so a non-loopback dial is unrepresentable). The `deps` seam (`{ connect?, now?, timer? }`, same style as Task 7's) is how the tests hold a connect open, fake the clock, and shrink the idle/lifetime/slow-link budgets - module-level `export const` limits are not overridable from a test. - [ ] **Step 1: Failing tests** (real in-test echo server on an ephemeral loopback port; fake context capturing emitted events; tiny injected idle/lifetime budgets for the timer cases): - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. @@ -360,16 +364,16 @@ test("an IPv6 loopback listener parses", async () => { **Files:** - Create: `apps/node/agent/src/preview/cdp-bridge.ts` -- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender) +- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `apps/node/agent/src/commands/preview-close-exec.ts` (after the forward lane reports `closed: false`, consult the bridge registry: `stopBridge(ref, reason)` answers `closed: true`; unknown in both registries keeps the tombstone contract), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender; the link-drop hook that Task 5 wires `closeAll` into must ALSO call `bridgeCloseAll(reason)` - without this a continuous-repaint page screencasts forever after the plane closes the watch) - Test: `apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts` **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. -- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the 60 s idle, and the fps throttle are all testable without a real browser or clock); `stopBridge(ref, reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. +- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the 60 s idle, and the fps throttle are all testable without a real browser or clock); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5. +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is `127.0.0.1`/`localhost` -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). No stream traffic for 60 s -> self-close `idle`. Lifetime timer. +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is `127.0.0.1`/`localhost` -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). - [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, counts toward `PREVIEW_SESSIONS_PER_NODE`, result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. @@ -384,7 +388,7 @@ test("an IPv6 loopback listener parses", async () => { **Interfaces:** - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. -- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, Bun.which }` for fakes), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. +- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. - [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision. - [ ] **Step 2: Fail.** @@ -398,17 +402,17 @@ test("an IPv6 loopback listener parses", async () => { ### Task 8: The preview broker (plane) **Files:** -- Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/__tests__/preview-broker.test.ts` +- Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` - Modify: `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` + `api/subshells/subshell-shares.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(sessionRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void`; `sendLaneBytes(laneRef, bytes)`; `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeSession(ref, actorUserId, reason)` (session + all its lanes); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (dedupe per pane+port, join semantics, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, ref)`; `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)`; `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }`; `openForward` sets `mode` to `raw` when the port is the pane's registered screen `rfbPort` (recorded by `onDisplayOpened`), else `http` - that is decision 6's discriminator, and `sessionFor(ref)` exposes it to the proxy. -- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`), Task 1 grammar, audit metadata shape from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content - spec §6). +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new plane->node byte sender, M4 below - NOT `sendCommand`); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer refcount, zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; one->zero sends `preview_close viewer-left`); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }` - `viewers` counts REST watchers for the refcount, and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's registered screen `rfbPort` (recorded by `onDisplayOpened`), else `http` - that is decision 6's discriminator, and `sessionFor(ref)` exposes it to the proxy. +- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender (M4: commands and data frames are different link paths, exactly as the relay split them); Task 1 grammar; audit metadata shape from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content - spec §6). -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per viewer via an injected `sendToUsers` and bumps activity; per-viewer byte debt closes THAT viewer only (`slow-link`), never the lane (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToUsers?, send? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep, fake sender for fan-out and `sendCommand`), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -422,12 +426,12 @@ test("an IPv6 loopback listener parses", async () => { **Interfaces:** - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. -- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). +- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). - [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 403 (guard rejection, §5.5); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (spec §3; pin the positive too). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). -- [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.onDisplayClosed(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). +- [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). - [ ] **Step 5: `bunx turbo build --filter=@internal/server` (Eden types), green, commit** `feat(api): preview REST surface, gated screen launches, and the view/edit authz matrix`. --- @@ -439,7 +443,7 @@ test("an IPv6 loopback listener parses", async () => { - Modify: server bootstrap (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` beside the SPA routes - NOT under `/api` per spec §6 - and the WS upgrade handler on the same wildcard beside the ws plugin's registrations), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). **Interfaces:** -- Consumes: `broker.sessionFor/openLaneForConnection/subscribeLane/sendLaneBytes/closeSession`, Task 1 grammar. +- Consumes: `broker.sessionFor/openLaneForConnection/subscribeLane(laneRef)/sendLaneBytes/closeLaneForConnection` (the proxy's one-shot responses and WS tunnels close their own lanes; session death arrives from `closeForPane`/`closeAllFor*`, not from the proxy), Task 1 grammar. - Produces: `lane-duplex.ts`: `class LaneDuplex extends Duplex` - constructor `({ writeOut(bytes): void, onLane(cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void })`; `_write` calls `writeOut`; lane bytes `push`; lane `closed` ends the duplex. - [ ] **Step 1: Failing unit tests** (real in-proc `Bun.serve` target + a 25-line in-test fake node that pumps `preview_data` to a loopback socket): @@ -463,14 +467,14 @@ test("an IPv6 loopback listener parses", async () => { ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.startWatch/stopWatch` after a `loadSubshellAccess` >= view check against the socket's holder; socket close -> `broker.dropViewer`), `ws/live-registry.ts` (add `sendToUsers(userIds: Iterable, frame: LiveServerFrame)` over the existing byUser map), `services/nodes/relay-frames.ts` or the broker (whichever owns the socket-debt counter per viewer: a laggy viewer's byte debt drops THAT viewer with a `slow-link` close, per spec §9), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), the send wrapper that feeds attachments (whichever side counts bytes: a lagging socket's byte debt triggers `detachStream(paneId, socket)` with a `slow-link` audit, never a lane close - spec §9 drops slow VIEWERS, not lanes), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. - Test: extend both files. **Interfaces:** - Consumes: Task 2 frames, Task 8 broker, `loadSubshellAccess` (:72). -- Produces: `sendToUsers`; for a holder with `view`+, `{"type":"watch","id":paneId}` later delivers `{"type":"preview-stream","id","seq","w","h","blob"}`. +- Produces: `sendToSockets`; for a holder with `view`+, `{"type":"watch","id":paneId}` attaches that socket, and a running bridge then delivers `{"type":"preview-stream","id","seq","w","h","blob"}` to attached sockets only. -- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant delivers a stream frame with `blob` untouched; watch on a foreign pane reaches the broker not at all (spy) and the socket stays open; `unwatch` stops delivery; socket close calls `dropViewer`; byte debt from one lagging socket drops only that viewer, `slow-link`. +- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected); byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. - [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives - compare, do not guess, against how the matrix handles revocations today). - [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round. - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. @@ -481,16 +485,16 @@ test("an IPv6 loopback listener parses", async () => { **Files:** - Create: `apps/server/web/src/hooks/use-subshell-preview.ts`, `lib/preview-bus.ts`, `components/preview/preview-section.tsx`, `components/preview/port-list.tsx`, `components/preview/open-panel.tsx`, `components/preview/watch-viewer.tsx`, `components/preview/screen-launch-hint.tsx` -- Modify: `routes/subshells_.$id.tsx` (compose under the terminal, spec §7's column), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` to the bus), node settings page (the Preview toggle beside the ssh one, same gate/tooltip posture - read the ssh toggle's component first), create dialog (optional "Virtual display" -> `screen` body field), phone layout check (grep how the dock trades tiling for tabs and reuse the same layout predicate). +- Modify: `routes/subshells_.$id.tsx` (compose under the terminal, spec §7's column), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` to the bus AND export its existing client-frame send - the `send(socket, LiveClientFrame)` helper at :23, already used for `previews`/`resync` at :156/:174, because `watch`/`unwatch` never reach the server unless the SPA sends them), node settings page (the Preview toggle beside the ssh one, same gate/tooltip posture - read the ssh toggle's component first), create dialog (optional "Virtual display" -> `screen` body field), phone layout check (grep how the dock trades tiling for tabs and reuse the same layout predicate). - Test: `components/preview/__tests__/` cloning whatever render harness `routes/__tests__/setup.test.tsx` uses (check `find apps/server/web/src -name "*.test.ts*"` and imitate the lighter precedent if one exists). **Interfaces:** - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. -- Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }`. +- Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the exported live-send) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the Screen action and the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit`; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). +- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). - [ ] **Step 4: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. --- From 8fdc56e23bb6b00cf2530bd467024959cb924d9a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 00:52:36 -0700 Subject: [PATCH 007/139] Fold correctness round-2 review into the plan Eight minors and six nits, every one re-verified on disk first: the 401 node-key row, the paneEnvFor env seam (tmux-runner opts.env is the tmux client env, not the pane's), launch leaving the dataless list and set_preview_enabled's unread ack, the revoke cut inside setShares beside the shares-changed announce, no ref in preview audit rows (the relay says out loud its ref is not a credential; ours is), a per-subscription watch counter not a Set, mode discrimination from the live ports record so a plane restart cannot downgrade a display to http, REQUEST_LOG_IGNORE entries for the info poll and the /pv path - which also means my earlier spec erratum was wrong: the exclusion mechanism exists, restored in spec and plan. Task 3 now spells its storage clone out as the 0062 column-pair migration, and the /pv upgrade probe is described as the reachability/gate probe it actually is. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 46 ++++++++++--------- .../specs/2026-10-21-pane-preview-design.md | 2 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 67e75518a..5a5b61701 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -15,7 +15,7 @@ - Protocol doctrine (spec §4): `NODE_PROTOCOL_VERSION` 22 -> 23 this round, exact-match gate at connect (server ships first; a lagging agent is held with an update message, never feature-gated). `MIN_NODE_VERSION` (`packages/subshell-protocol/src/versions.ts:123`, today `"2.2.0"`) and `apps/node/agent/package.json` (today `"2.3.0"`) rise to `"2.4.0"` **in the same commit as the bump**; after this the floor equals the shipping version, which is how the floor crosses an exact-match protocol. - Node executor contract (spec §5 + `apps/node/agent/AGENTS.md`): act only on verified commands, result before anything long-running (`pending: true` + off-chain work), self-expiring timers, never `process.exit`, refusals name ids and remedies. The forward lane's connect MUST follow the `ssh-relay-exec.ts` A-branch (`:101` returns `{ ok: true, data: { ..., pending: true } }` and connects off the exec chain): a SYN-blackholed target must not park unrelated commands. - Dialing is loopback-only (`127.0.0.1` / `::1`), both forward and CDP; the bridge also distrusts any `webSocketDebuggerUrl` not aimed at loopback. Non-loopback is out of scope forever (spec §11). -- Close reasons are a closed set (spec §6 plus decisions 3): `viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `not-cdp`, `target-gone`. +- Close reasons are a closed set (spec §6's seven, plus `not-cdp` from its §9 error table and `target-gone` from decision 3): `viewer-left`, `pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `not-cdp`, `target-gone`. - Watch fps is clamped node-side to `[PREVIEW_WATCH_FPS_MIN, PREVIEW_WATCH_FPS_MAX]` = [1, 5] (spec §4/§12.2); `fps` is OPTIONAL on the wire and defaults to 5. - NO dynamic `import()` anywhere in the repo except `packages/pane-runtime/src/plugin-runtime.ts` (the one named exception in `.claude/rules/code-style.md`). The SPA has no code-splitting precedent on disk; noVNC ships as a static import (decision 4). - Pinned dependency versions: after `bun add`, run `syncpack fix` then `bun install`; check with `bun run lint:lockfile`. @@ -27,11 +27,11 @@ **Implementation decisions recorded against the spec (approve these as you review):** 1. Spec §12.4 / §7 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, the official repo's own package: `type: module`, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside, all internal imports relative). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained), fewer bytes and no drift. -2. Spec §6 describes `POST /preview/open` sending `preview_open` and awaiting the ack (kept, as the dialability probe). One TCP lane cannot carry a dev server's keep-alive HTTP and its permanent HMR WebSocket at once, so **browser connections beyond the probe each open their own node lane** (`preview_open` with a fresh 32-hex ref registered under the same session; the session secret authorizes many lanes; the probe lane closes as soon as it is acked). Spec quotas still count lanes; a burst beyond the cap queues up to 8 waiters instead of failing. -3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`pane_size`, `ssh_enabled`, `subshells_report`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a request-log mechanism that does not exist on disk; the spec erratum was fixed separately, and the plan implements the visible throttling the same sentence asks for. +2. Spec §6 describes `POST /preview/open` sending `preview_open` and awaiting the ack (kept as a reachability/gate probe: the ack lands before the node's dial of the target settles, so a refused port surfaces later as `target-gone`, never on the POST - Task 5). One TCP lane cannot carry a dev server's keep-alive HTTP and its permanent HMR WebSocket at once, so **browser connections beyond the probe each open their own node lane** (`preview_open` with a fresh 32-hex ref registered under the same session; the session secret authorizes many lanes; the probe lane closes as soon as it is acked). Spec quotas still count lanes; a burst beyond the cap queues up to 8 waiters instead of failing. +3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`ssh_enabled`, `subshells_report`, `relay`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a mechanism that DOES exist (`REQUEST_LOG_IGNORE`, `apps/server/api/src/plugins/context.plugin.ts:32`, exported and pinned by `plugins/__tests__/request-log-ignore.test.ts`, whose comment's reason is exactly "POLLED by a page somebody leaves open"), and this feature joins it twice: the info poll for noise, and the `/pv` path for a credential reason - the session secret rides the URL, so a debug request line would log a live credential (an earlier revision of this plan claimed the mechanism was missing; that claim was wrong, verified against disk 2026-10-22). Visible throttling (one request per five seconds) is implemented too, as the same sentence asks. 4. Spec §12.4 asked for noVNC as a "lazy-loaded chunk". `.claude/rules/code-style.md` forbids dynamic import with one named exception and says a second exception is a design question, so RFB is a STATIC import. Bundle cost (~250 KB pre-gzip, one page's worth) is accepted; if a chunk boundary is wanted later it is a design question for the rules file, not this plan's call. 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. -6. Spec §5 calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port from `onDisplayOpened` (Task 8), so no REST or protocol field is added; the spec's §6 body stays `{ port }`. +6. Spec §5 calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. **Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. @@ -57,12 +57,14 @@ **`apps/server/api/src/`** - `services/nodes/preview-enabled.ts` (new, clones `nodes/ssh-enabled.ts` shape incl. its storage) + route file beside the ssh-enabled route. - `services/preview-broker.ts` + `services/__tests__/preview-broker.test.ts` (new). +- `services/nodes/preview-frames.ts` (new): the plane->node `preview_data` byte sender, a clone of `sendRelayFrameOverNodeSocket` - lane bytes ride link frames, never `sendCommand`. - `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-watch.route.ts` (POST + DELETE in one route module, or two files - follow the single-flat-route rule: it is one resource, the directory exists), `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts`. - `api/subshells/create-subshell.route.ts` + `services/nodes/remote-launcher.ts` (modify): `screen` body field into the `launch` command, display audit on ack, refusal surfaced. - `api/subshells/subshell-exit.route.ts` (modify): `broker.closeForPane(paneId, "pane-gone")` beside the death handling (it covers the display audit via `onDisplayClosed` and every lane/bridge of the pane). - `preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts` + `preview/__tests__/` (new): the `/pv` proxy. - `ws/live-ws.ts` (modify): `watch`/`unwatch` client frames; `ws/live-registry.ts` (modify): targeted send; `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts` (modify). -- `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts`, `api/subshells/subshell-shares.route.ts` (modify): revoke hook beside the existing `dropLiveSocketsFor` calls. +- `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts` (modify): broker revoke cuts beside the existing `dropLiveSocketsFor` calls. +- `services/subshells.service.ts` (modify): the share-revocation cut goes INSIDE `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the shares route delegates to this service and touches no socket itself, and package doctrine puts the cut in the service that owns the act. - `services/nodes/relay-frames.ts` (modify): the node-link intake that calls `getRelayBroker().routeRelayFrame(nodeId, frame)` grows a preview sibling routing `preview_data` / `preview_stream` / `preview_closed` to the preview broker. **`apps/server/web/src/`** @@ -76,7 +78,7 @@ **Files:** - Modify: `packages/subshell-protocol/src/node-frames.ts` (version at :187, bump history near :80, `NodeCommandBody` at :781 and `parseNodeCommandBody`, `NodeEvent` at :1540, `parseNodeEvent` at :2048, `RelayFrame` region :1446 as the shape to clone, ready member region beside `sshEnabled`, the `launch` command member at :784) -- Modify: `packages/subshell-protocol/src/node-results.ts` (validators; read its header + the `launch` and `set_ssh_enabled` entries first) +- Modify: `packages/subshell-protocol/src/node-results.ts` (validators; read its header first: `launch` sits on its DATALESS list today - success is just `{ ok: true }`, no validator - so the `screen` echo below is the first data field that command ever carried, and `parseNodeSshExecKick` models the hand-rolled validator style) - Modify: `packages/subshell-protocol/src/ssh-limits.ts` - Modify: `packages/subshell-protocol/src/versions.ts:123` (and its "Raised to..." comment) - Modify: `apps/node/agent/package.json` (`"version": "2.3.0"` -> `"2.4.0"`) @@ -237,7 +239,7 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside > **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch; a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. -(Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (`{ ports, cdpPort, screen }` exactly as spec §4's answer), `set_preview_enabled` (follow the `set_ssh_enabled` entry's shape), and `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it). +(Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (`{ ports, cdpPort, screen }` exactly as spec §4's answer), `set_preview_enabled` gets NO validator and its ack is never read: the ssh precedent fires its command best-effort and discards the result (`ssh-enabled.ts:48` + `:138`), the plane's truth is its own DB row, and a dataless command has no entry here by the file's own header rule. `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it on ack - a post-ack `getPorts` would race the 5 s cache on a pane that just started), which makes `launch` the first data-carrying command on the header's dataless list: write `parseNodeLaunchResult` in the file's hand-rolled style, validate on BOTH ends per the header contract, and update the header's dataless sentence to drop `launch` from it. - [ ] **Step 6: Run the whole protocol package, then the suites that pin the version:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/subshell-protocol`; then `bun run test` for `@internal/server` and `@internal/node` and fix every fixture that pins protocol 22 or the old floor, IN THIS COMMIT (bump doctrine). - [ ] **Step 7: Build + commit:** @@ -255,7 +257,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s ### Task 2: Live feed grammar (watch subscribe frames + stream frame) **Files:** -- Modify: `packages/subshell-protocol/src/live-frames.ts` (server union near `preview`/`subshell-recheck` at :43/:63, client union, `parseLiveClientFrame`) +- Modify: `packages/subshell-protocol/src/live-frames.ts` (server union beside `subshell-recheck` (:63) and `preview` (:69); client union; `parseLiveClientFrame`) - Test: `packages/subshell-protocol/src/__tests__/live-frames.test.ts` **Interfaces:** @@ -273,8 +275,8 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s **Files (read the clone source first, mirror it exactly including its storage):** - Read: `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route and test (`grep -rln "set_ssh_enabled" apps/server/api/src/api apps/server/api/src/services`), node half: `grep -rn "sshEnabled" apps/node/agent/src`, the `set_ssh_enabled` executor in `apps/node/agent/src/commands/`. -- Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one. -- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416), the route mount. +- Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one, `apps/server/api/src/db/migrations/0062-node-preview-enabled.ts` (+ its migration test, cloning `migrations/__tests__/0047-node-ssh-enabled.test.ts`). +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). - Test: plane route test + `apps/node/agent/src/__tests__/preview-flags.test.ts`. **Interfaces:** @@ -283,7 +285,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. - [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. Node: a verified `set_preview_enabled` flips the mirror (0600), a gated command refuses while off, and an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). -- [ ] **Step 3: Implement the plane half** (clone the ssh switch's storage mechanism, name it `preview_enabled`); plane tests green. +- [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the 0039 precedent in `.claude/rules/testing.md`). Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -383,8 +385,8 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/display.ts` -- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) -- Test: `apps/node/agent/src/__tests__/preview-display.test.ts` +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) +- Test: `apps/node/agent/src/__tests__/preview-display.test.ts`, `packages/pane-runtime/src/__tests__/launch.test.ts` (the extraEnv layer: pane sees DISPLAY/XAUTHORITY, the exit-hook env from :224 agrees, and with no display the chain is byte-identical to today) **Interfaces:** - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. @@ -393,7 +395,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; generate the auth file with `xauth generate -f :NN .` and start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp`; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). -- [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env beside the SUBSHELL_* credentials (same env builder launch.ts already passes to pane-runtime's `tmux-runner.ts` `opts.env` at :192/:267) -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. +- [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional fourth layer `extraEnv` (empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. - [ ] **Step 5: Sweep timer** beside daemon's other self-timers: hourly `display.sweepOrphans(alive pane ids)`, cloned from the ssh per-pane-dir retention pattern (`ssh-dir-retention` precedent; read it first). - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. @@ -403,14 +405,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` + `api/subshells/subshell-shares.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts). +- Modify: `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new plane->node byte sender, M4 below - NOT `sendCommand`); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer refcount, zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; one->zero sends `preview_close viewer-left`); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Set, lastActivity }` - `viewers` counts REST watchers for the refcount, and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's registered screen `rfbPort` (recorded by `onDisplayOpened`), else `http` - that is decision 6's discriminator, and `sessionFor(ref)` exposes it to the proxy. -- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender (M4: commands and data frames are different link paths, exactly as the relay split them); Task 1 grammar; audit metadata shape from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content - spec §6). +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:608` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -421,14 +423,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `api/subshells/preview-info.route.ts`, `preview-open.route.ts`, `preview-watch.route.ts` (POST + DELETE on one path, the directory rule says one resource one file until it earns a split - these are two handlers, keep two files `preview-watch-start.route.ts` / `preview-watch-stop.route.ts` if the file-size rule bites), `api/subshells/index.ts` (mount), `api/subshells/__tests__/preview-routes-auth.test.ts` -- Modify: `api/subshells/create-subshell.route.ts` (body `screen`), `services/nodes/remote-launcher.ts` (find: `grep -rln "type: \"launch\"" apps/server/api/src` - pass `screen` into the `launch` command, surface node refusals, call `onDisplayOpened` on ack), `api/subshells/subshell-exit.route.ts` (call `onDisplayClosed(paneId, "pane-gone")` beside the death handling) +- Modify: `api/subshells/create-subshell.route.ts` (body `screen`), `services/nodes/remote-launcher.ts` (find: `grep -rln "type: \"launch\"" apps/server/api/src` - pass `screen` into the `launch` command, surface node refusals, call `onDisplayOpened` on ack), `api/subshells/subshell-exit.route.ts` (call `broker.closeForPane(paneId, "pane-gone")` beside the death handling - it covers the display audit through `onDisplayClosed` plus every lane and bridge of the pane, so nothing calls `onDisplayClosed` on its own), `plugins/context.plugin.ts` (add the info-poll path to `REQUEST_LOG_IGNORE` - an open subshell view polls it, and the file's own comment names that as the criterion) + `plugins/__tests__/request-log-ignore.test.ts` (the pinned list gains the entry). - Test: the auth matrix file (clone the actor-table shape from `__tests__/subshell-input-ssh.route.test.ts` and `subshell-view-ssh.test.ts`). **Interfaces:** - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 403 (guard rejection, §5.5); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (spec §3; pin the positive too). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -440,7 +442,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts`, `preview/__tests__/pv-proxy.test.ts` -- Modify: server bootstrap (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` beside the SPA routes - NOT under `/api` per spec §6 - and the WS upgrade handler on the same wildcard beside the ws plugin's registrations), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). +- Modify: server bootstrap (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` beside the SPA routes - NOT under `/api` per spec §6 - and the WS upgrade handler on the same wildcard beside the ws plugin's registrations), `plugins/context.plugin.ts` (`REQUEST_LOG_IGNORE` gains `/^\/pv\//`: request lines carry the path, the path carries the 32-hex session secret, and a debug request line would therefore log a live credential - same class as the setup-key note in `apps/server/api/AGENTS.md`'s log rules; pin it in `request-log-ignore.test.ts`), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). **Interfaces:** - Consumes: `broker.sessionFor/openLaneForConnection/subscribeLane(laneRef)/sendLaneBytes/closeLaneForConnection` (the proxy's one-shot responses and WS tunnels close their own lanes; session death arrives from `closeForPane`/`closeAllFor*`, not from the proxy), Task 1 grammar. @@ -455,7 +457,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - an upgrade with a cross-site `Origin` is refused; same-origin is not; - binary WS round trip byte-exact in `http` mode, with the target BEHIND the fake node - if the `ws` client ever bypassed the lane (dialing the plane-local port), the test fails with connection refused instead of silently passing; - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); - - unknown `/pv//` -> 404, body leaks nothing; + - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). diff --git a/docs/superpowers/specs/2026-10-21-pane-preview-design.md b/docs/superpowers/specs/2026-10-21-pane-preview-design.md index 94829c4a6..481467e04 100644 --- a/docs/superpowers/specs/2026-10-21-pane-preview-design.md +++ b/docs/superpowers/specs/2026-10-21-pane-preview-design.md @@ -99,7 +99,7 @@ Executor arms live beside the relay arms (`commands/ssh-relay-exec.ts` is the sh Pane detail (`routes/subshells_.$id.tsx`, the same terminal-wrapper column) gains a **Preview** section under the terminal: -- Port list from `GET /preview`, refreshed on pane focus and after any pane-output burst, throttled to one request per five-second window. Each row: port, detected kind (server / browser-debug / screen), **Open** (tooltip-disabled with the real reason when `access === "view"` or the node switch is off), **Watch** on the browser row. +- Port list from `GET /preview`, refreshed on pane focus and after any pane-output burst, throttled to one request per five-second window and added to the server's existing `REQUEST_LOG_IGNORE` list (`plugins/context.plugin.ts`) so an open page cannot flood the self-truncating debug log; the `/pv` path joins that list too, since its URL carries the session secret. Each row: port, detected kind (server / browser-debug / screen), **Open** (tooltip-disabled with the real reason when `access === "view"` or the node switch is off), **Watch** on the browser row. - Open renders a collapsible side panel holding the `/pv/` iframe with an "open in new tab" affordance; Watch renders a canvas fed by `preview-stream` frames with an fps/age badge and an explicit "still there?" staleness rule (no frame for 5 s reads stale, matching the feed's posture rather than showing a frozen page as live). - Screen opens the noVNC canvas over a forward session (`edit`-gated) pointed at the pane's `screen.rfbPort`, RFB bytes riding `/pv/:session/` upgrades. - Gating reuses the existing trio: disabled control + `disabledReason` tooltip per the design system; `view` vs `edit` read from the row's resolved `access` exactly as `subshell-terminal.tsx` does for stdin. Disclosure: the foreign-node icon and banner machinery (`use-trust-notices`, `TrustIndicators`) is unchanged, but the Preview section raises the **shared-pane** banner variant once per device when first opened, because this surface shows more than the terminal does. From 95598a433bbd0aa14a835ce367824ede37fbace0 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 01:19:06 -0700 Subject: [PATCH 008/139] Fold round-3 decomposition review into the plan One major: the node event switch in node-ws-handler.ts is where every node event is dispatched, has no default branch, and no task touched it - all three preview event types would have landed dead with every unit test green. Preview arms, named, plus a dispatch test in the file's own handleNodeMessage style. Five minors: Task 1's commit stops staging whole app directories in a tree carrying other workstreams' WIP; the lane-bypass test now counts the fake node's bytes (an in-proc target answers plane loopback, so a bypassed dial used to pass its own guard); sendToSockets' production wiring has an owner (Task 11), the per-viewer byte-debt seam is designed (bufferedAmount as the optional LiveWsSocket member the readyState habit already allows, checked at broker fan-out, PREVIEW_STREAM_DEBT_MAX pinned at 4 MiB), and Task 3's node test states the gate it can actually express. Nits: the routing-ref-not-a-credential quote is at :583, not :608; screen-launch-hint joins the file structure; /pv names server.ts and the before-the-SPA-plugin ordering; PREVIEW_COMMAND_TYPES is package-exported like its SSH twin. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 5a5b61701..8e41b4a42 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -66,9 +66,10 @@ - `api/users/set-user-disabled.route.ts`, `set-user-role.route.ts` (modify): broker revoke cuts beside the existing `dropLiveSocketsFor` calls. - `services/subshells.service.ts` (modify): the share-revocation cut goes INSIDE `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the shares route delegates to this service and touches no socket itself, and package doctrine puts the cut in the service that owns the act. - `services/nodes/relay-frames.ts` (modify): the node-link intake that calls `getRelayBroker().routeRelayFrame(nodeId, frame)` grows a preview sibling routing `preview_data` / `preview_stream` / `preview_closed` to the preview broker. +- `services/nodes/node-ws-handler.ts` (modify): the event switch's `case "relay":` arm (:1196, delegating to `onRelayFrame` from relay-frames.ts) is where every node event is dispatched - the switch has NO default branch, so an event type with no arm here is silently dropped. The three preview event types need their own arms; without them the plane discards every preview frame while unit tests that call the intake function directly stay green. **`apps/server/web/src/`** -- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-viewer.tsx` (new); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus; export its `send()` client-frame helper so `watch`/`unwatch` can actually be sent). +- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-launch-hint.tsx`, `screen-viewer.tsx` (new; `screen-launch-hint.tsx` arrives with Task 12, `screen-viewer.tsx` with Task 13); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus; export its `send()` client-frame helper so `watch`/`unwatch` can actually be sent). **Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview-lane rules), `docs/security.md` (preview-lane paragraph), `apps/node/agent/AGENTS.md` (preview executors join the inventory; loopback invariant; xvfb/x11vnc/xauth optional runtime deps with named refusals), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset: `@internal/server` minor + `@internal/node` minor). @@ -85,10 +86,10 @@ - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` **Interfaces:** -- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_REF_RE`. +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. - Consumes: nothing. -- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: +- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim; follow the disk pattern exactly on LOCATION: `SSH_COMMAND_TYPES` is exported from `node-frames.ts` and re-exported through the `src/index.ts` barrel (`:292`), imported by the test at :32, so do the same - package-exported, test-imported, not a test-local constant). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: ```ts describe("preview commands", () => { @@ -246,7 +247,15 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside ```bash bunx turbo build --filter=@internal/subshell-protocol -git add packages/subshell-protocol apps/node/agent/package.json apps/server/api apps/node/agent +git add -A packages/subshell-protocol apps/node/agent/package.json +# plus EVERY apps/ fixture step 6 actually fixed, by PATH (run +# `git status --short apps/` and add exactly the files you edited - typically +# the node-gates/version fixtures under apps/server/api/src/services/nodes/ +# __tests__ and apps/node/agent/__tests__). Whole directories must NEVER be +# staged: this tree is shared with concurrent workstreams and uncommitted WIP +# sits under apps/server/api (auth-guard.ts, ws.plugin.ts, ssh-relay.service.ts +# as of 2026-10-22). Before committing, `git status --short` must show no +# staged file you did not touch. git commit -m "feat(protocol): preview lanes, screencast events, and the owner switch (protocol 23, floor 2.4.0)" ``` @@ -284,7 +293,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. Node: a verified `set_preview_enabled` flips the mirror (0600), a gated command refuses while off, and an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - command gates arrive with Tasks 4/5/7, each tested there); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the 0039 precedent in `.claude/rules/testing.md`). Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -405,14 +414,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. -- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:608` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape `LiveWsSocket` already speaks, and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -442,7 +451,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/preview/lane-duplex.ts`, `preview/pv-http.ts`, `preview/pv-ws.ts`, `preview/__tests__/pv-proxy.test.ts` -- Modify: server bootstrap (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` beside the SPA routes - NOT under `/api` per spec §6 - and the WS upgrade handler on the same wildcard beside the ws plugin's registrations), `plugins/context.plugin.ts` (`REQUEST_LOG_IGNORE` gains `/^\/pv\//`: request lines carry the path, the path carries the 32-hex session secret, and a debug request line would therefore log a live credential - same class as the setup-key note in `apps/server/api/AGENTS.md`'s log rules; pin it in `request-log-ignore.test.ts`), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). +- Modify: `src/server.ts` (mount `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS` on `/pv/:session/*` - NOT under `/api` per spec §6, and BEFORE the static SPA plugin: the file's own comment at :59-61 spells out why a root-level path mounted after it gets 404'd by the file branch, the `installScriptRoute` precedent - and register the WS upgrade handler for the same wildcard beside the ws plugin's registrations), `plugins/context.plugin.ts` (`REQUEST_LOG_IGNORE` gains `/^\/pv\//`: request lines carry the path, the path carries the 32-hex session secret, and a debug request line would therefore log a live credential - same class as the setup-key note in `apps/server/api/AGENTS.md`'s log rules; pin it in `request-log-ignore.test.ts`), `apps/server/api/package.json` (add `ws@8.22.0`, then `syncpack fix && bun install`; it is used ONLY with the documented stream-override option so it rides a lane duplex - verify `createConnection` is honored in `node_modules/ws/lib/websocket.js` before coding; there is no other way to put a `ws` client on a synthetic socket, and Bun's own WebSocket cannot be aimed there). **Interfaces:** - Consumes: `broker.sessionFor/openLaneForConnection/subscribeLane(laneRef)/sendLaneBytes/closeLaneForConnection` (the proxy's one-shot responses and WS tunnels close their own lanes; session death arrives from `closeForPane`/`closeAllFor*`, not from the proxy), Task 1 grammar. @@ -455,7 +464,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - upstream `Content-Security-Policy` with three directives survives as two, `frame-ancestors` gone; the report-only variant gets the same carve-out; - relative `Location: /foo` returns prefixed `/pv//foo`; - an upgrade with a cross-site `Origin` is refused; same-origin is not; - - binary WS round trip byte-exact in `http` mode, with the target BEHIND the fake node - if the `ws` client ever bypassed the lane (dialing the plane-local port), the test fails with connection refused instead of silently passing; + - binary WS round trip byte-exact in `http` mode. Lane-bypass guard, stated precisely: the in-proc `Bun.serve` target DOES answer on plane loopback, so a client that bypassed the lane would still connect and pass a naive byte-equality check - the fake node therefore COUNTS the bytes it pipes, and the test asserts the handshake plus the round-trip payload all passed through the fake (bypassing the lane leaves the counter at zero even though the response looks perfect); - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). @@ -469,7 +478,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), the send wrapper that feeds attachments (whichever side counts bytes: a lagging socket's byte debt triggers `detachStream(paneId, socket)` with a `slow-link` audit, never a lane close - spec §9 drops slow VIEWERS, not lanes), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :59-60: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. - Test: extend both files. **Interfaces:** From 20ca3007b3d4cfbd1b3b9121751c93f9a4521506 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 01:29:18 -0700 Subject: [PATCH 009/139] Fold correctness round-3 review into the plan Five minors, all quote-verified: SSH_COMMAND_TYPES lives in ssh-frames.ts, not node-frames.ts, so the census instruction now copies the shape and puts PREVIEW_COMMAND_TYPES with its own grammar; the 0039 curated-list precedent is in apps/server/api/AGENTS.md:824, not the testing rules; the e2e spec lands where Playwright's testDir will find it (e2e/tests/47-preview.spec.ts); the fps comment no longer promises a default the arm must not apply; and the permission surface got planned - the switch route gets its own nodes:preview catalog member (reusing nodes:ssh would let every ssh-scoped key flip preview) and the pane routes carry the read/execute annotations their siblings draw, with the scoped-key refusal pinned as its own matrix row. Nits: the push assertion moves to the service test whose scripted wire can actually see it (the route test's node fixture is offline on purpose); the disable/role routes' missing relay cuts are named as a different axis; bufferedAmount is described as the Task 11 addition it is, not something LiveWsSocket already has. One nit was rejected on the disk: "POLLED by a page somebody leaves open" is verbatim at context.plugin.ts:19. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 8e41b4a42..856122b3d 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -89,7 +89,7 @@ - Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. - Consumes: nothing. -- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim; follow the disk pattern exactly on LOCATION: `SSH_COMMAND_TYPES` is exported from `node-frames.ts` and re-exported through the `src/index.ts` barrel (`:292`), imported by the test at :32, so do the same - package-exported, test-imported, not a test-local constant). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: +- [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim; follow the disk pattern on SHAPE: `SSH_COMMAND_TYPES` is a const-exported list (it lives at `ssh-frames.ts:375`, where its grammar lives, re-exported at the `src/index.ts:292` barrel and imported by name in the test) - do the same for `PREVIEW_COMMAND_TYPES`, exported from `node-frames.ts` where the preview grammar lives, re-exported through the barrel, test-imported, not test-local (the barrel is mobile's Metro surface: this adds no `node:*` import, so it is safe there). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: ```ts describe("preview commands", () => { @@ -101,7 +101,7 @@ describe("preview commands", () => { }); test("the kind decides whether fps may appear", () => { const noFpsScreencast = { type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, lifetimeMs: 60_000 }; - expect(parseNodeCommandBody(JSON.stringify(noFpsScreencast))).toEqual(noFpsScreencast); // fps optional, spec §4; the arm defaults it + expect(parseNodeCommandBody(JSON.stringify(noFpsScreencast))).toEqual(noFpsScreencast); // fps is OPTIONAL on the wire (spec §4): the arm validates the shape and must NOT default it (this toEqual pins an absent key staying absent); the default to PREVIEW_WATCH_FPS_DEFAULT belongs at the bridge (Task 6), where the consumer is const fpsOnForward = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 8080, fps: 5, lifetimeMs: 60_000 }; expect(parseNodeCommandBody(JSON.stringify(fpsOnForward))).toBeNull(); }); @@ -285,6 +285,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s **Files (read the clone source first, mirror it exactly including its storage):** - Read: `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route and test (`grep -rln "set_ssh_enabled" apps/server/api/src/api apps/server/api/src/services`), node half: `grep -rn "sshEnabled" apps/node/agent/src`, the `set_ssh_enabled` executor in `apps/node/agent/src/commands/`. - Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one, `apps/server/api/src/db/migrations/0062-node-preview-enabled.ts` (+ its migration test, cloning `migrations/__tests__/0047-node-ssh-enabled.test.ts`). +- Modify: `packages/subshell-protocol/src/api-key-permissions.ts` (the `node-security` group gains `{ id: "nodes:preview", label: "Preview panes", description: "Turn pane preview on or off on a node." }` beside `nodes:ssh` at :36): the ssh route is annotated `systemPermission: "nodes:ssh"` (`set-node-ssh-enabled.route.ts:82`) and a route's permission string is a catalog member (`auth-guard.ts:194`), so the preview switch needs its OWN id - reusing `nodes:ssh` would let every ssh-scoped key flip preview, and the catalog feeds the key-minting UI's checkboxes, so it is a public-surface entry, not an internal string. - Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). - Test: plane route test + `apps/node/agent/src/__tests__/preview-flags.test.ts`. @@ -293,8 +294,8 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + `sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`; system key 403; default read `{on:false}` with no row. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - command gates arrive with Tasks 4/5/7, each tested there); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). -- [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the 0039 precedent in `.claude/rules/testing.md`). Plane tests green. +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - command gates arrive with Tasks 4/5/7, each tested there); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -414,11 +415,11 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` / relay revocation cuts), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape `LiveWsSocket` already speaks, and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. - [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. @@ -439,7 +440,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation itself ("This endpoint isn't available to scoped API keys.", `auth-guard.ts:205-212` - the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -531,7 +532,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 14: e2e, docs, changeset, boundary verification **Files:** -- Create: `e2e/preview.spec.ts`, `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). +- Create: `e2e/tests/47-preview.spec.ts` (testDir is `./tests` per `playwright.config.ts:14`, and suites are numbered - next free number after `46-server-filters`; a root-level `e2e/preview.spec.ts` would never be collected), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). - Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. From 78f6957a5632db38d3cc2d77c8cc10848bf97805 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 01:39:58 -0700 Subject: [PATCH 010/139] Rebase on main and align plan with PR #424 trust-boundary hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scoped-key refusal row: #424's named-permission 403 branch, block re-anchored :201-213 - /pv upgrade gated with browserOriginAllowed (registry origins pass; plane-origin equality rejected published-network dashboards and contradicted spec §6's 'cross-site' wording) - Task 11 updates ws.plugin.ts's stale watch-previews forward-reference - Task 1 staging note: sibling WIP merged; refresh the dirty-tree discipline text --- .../plans/2026-10-21-pane-preview.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 856122b3d..cbe95685b 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -252,10 +252,15 @@ git add -A packages/subshell-protocol apps/node/agent/package.json # `git status --short apps/` and add exactly the files you edited - typically # the node-gates/version fixtures under apps/server/api/src/services/nodes/ # __tests__ and apps/node/agent/__tests__). Whole directories must NEVER be -# staged: this tree is shared with concurrent workstreams and uncommitted WIP -# sits under apps/server/api (auth-guard.ts, ws.plugin.ts, ssh-relay.service.ts -# as of 2026-10-22). Before committing, `git status --short` must show no -# staged file you did not touch. +# staged: this tree is shared with concurrent workstreams (the sibling WIP +# under apps/server/api as of 2026-10-22 merged as PR #424 and this branch +# rebased onto it; bun.lock and a leftover .changeset are dirty as of +# writing, and a new sibling can appear at any time). The INDEX is shared +# too: commit with an explicit pathspec (`git commit -- `, never a +# bare `git commit`), because a sibling's `git add` landing between your +# staging and your commit otherwise rides along in yours (measured +# 2026-10-22: bun.lock did exactly that). `git status --short` before and +# after must show nothing staged that you did not touch. git commit -m "feat(protocol): preview lanes, screencast events, and the owner switch (protocol 23, floor 2.4.0)" ``` @@ -440,7 +445,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation itself ("This endpoint isn't available to scoped API keys.", `auth-guard.ts:205-212` - the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw). Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -464,14 +469,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - `Host` arrives as `127.0.0.1:`; - upstream `Content-Security-Policy` with three directives survives as two, `frame-ancestors` gone; the report-only variant gets the same carve-out; - relative `Location: /foo` returns prefixed `/pv//foo`; - - an upgrade with a cross-site `Origin` is refused; same-origin is not; + - an upgrade with a cross-site `Origin` is refused; the plane's own origin and a registry (published-network) origin are NOT, and an upgrade with no `Origin` header passes - `browserOriginAllowed` semantics, not plane-origin equality; - binary WS round trip byte-exact in `http` mode. Lane-bypass guard, stated precisely: the in-proc `Bun.serve` target DOES answer on plane loopback, so a client that bypassed the lane would still connect and pass a naive byte-equality check - the fake node therefore COUNTS the bytes it pipes, and the test asserts the handshake plus the round-trip payload all passed through the fake (bypassing the lane leaves the counter at zero even though the response looks perfect); - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): `Origin` must equal the plane's own origin (refuse cross-site), open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -479,7 +484,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :59-60: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`. +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :59-60: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** From dc1eca7d3d9b5b496e2b0c5c23fa8888d82e4f9e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 01:50:59 -0700 Subject: [PATCH 011/139] Fold correctness round-4 review into the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview_open range fixture gains the required port (lifetime-bound cases were vacuous: the missing field refused the body before bounds ran) - watch bridge is pane-keyed, matching the spec's portless DELETE, pane-keyed socket map and ref-less stream frame; a second port on a live bridge is a named 409, and the SPA sends its live frame only after a REST join - decision citations fixed: spec §6 (not §5) owns the raw-tunnel quote; the POLLED-by-a-page reason belongs to context.plugin.ts:19, not the test --- .../superpowers/plans/2026-10-21-pane-preview.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index cbe95685b..8c258bb3e 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -28,10 +28,10 @@ 1. Spec §12.4 / §7 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, the official repo's own package: `type: module`, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside, all internal imports relative). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained), fewer bytes and no drift. 2. Spec §6 describes `POST /preview/open` sending `preview_open` and awaiting the ack (kept as a reachability/gate probe: the ack lands before the node's dial of the target settles, so a refused port surfaces later as `target-gone`, never on the POST - Task 5). One TCP lane cannot carry a dev server's keep-alive HTTP and its permanent HMR WebSocket at once, so **browser connections beyond the probe each open their own node lane** (`preview_open` with a fresh 32-hex ref registered under the same session; the session secret authorizes many lanes; the probe lane closes as soon as it is acked). Spec quotas still count lanes; a burst beyond the cap queues up to 8 waiters instead of failing. -3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`ssh_enabled`, `subshells_report`, `relay`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a mechanism that DOES exist (`REQUEST_LOG_IGNORE`, `apps/server/api/src/plugins/context.plugin.ts:32`, exported and pinned by `plugins/__tests__/request-log-ignore.test.ts`, whose comment's reason is exactly "POLLED by a page somebody leaves open"), and this feature joins it twice: the info poll for noise, and the `/pv` path for a credential reason - the session secret rides the URL, so a debug request line would log a live credential (an earlier revision of this plan claimed the mechanism was missing; that claim was wrong, verified against disk 2026-10-22). Visible throttling (one request per five seconds) is implemented too, as the same sentence asks. +3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`ssh_enabled`, `subshells_report`, `relay`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a mechanism that DOES exist (`REQUEST_LOG_IGNORE`, `apps/server/api/src/plugins/context.plugin.ts:32`, exported and pinned by `plugins/__tests__/request-log-ignore.test.ts`; the reason sits verbatim in `context.plugin.ts:19`: "Every one of these is POLLED by a page somebody leaves open"), and this feature joins it twice: the info poll for noise, and the `/pv` path for a credential reason - the session secret rides the URL, so a debug request line would log a live credential (an earlier revision of this plan claimed the mechanism was missing; that claim was wrong, verified against disk 2026-10-22). Visible throttling (one request per five seconds) is implemented too, as the same sentence asks. 4. Spec §12.4 asked for noVNC as a "lazy-loaded chunk". `.claude/rules/code-style.md` forbids dynamic import with one named exception and says a second exception is a design question, so RFB is a STATIC import. Bundle cost (~250 KB pre-gzip, one page's worth) is accepted; if a chunk boundary is wanted later it is a design question for the rules file, not this plan's call. 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. -6. Spec §5 calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. +6. Spec §6's Browser-lanes paragraph calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. **Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. @@ -106,7 +106,7 @@ describe("preview commands", () => { expect(parseNodeCommandBody(JSON.stringify(fpsOnForward))).toBeNull(); }); test("refuses out-of-range numbers", () => { - const base = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", lifetimeMs: 60_000 }; + const base = { type: "preview_open", ref: "a".repeat(32), kind: "forward", paneId: "p", port: 8080, lifetimeMs: 60_000 }; expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 70_000 }))).toBeNull(); expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 1.5 }))).toBeNull(); expect(parseNodeCommandBody(JSON.stringify({ type: "preview_open", ref: "a".repeat(32), kind: "screencast", paneId: "p", port: 9222, fps: PREVIEW_FPS_GRAMMAR_MAX + 1, lifetimeMs: 60_000 }))).toBeNull(); @@ -424,10 +424,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: dedupe per pane+port, per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -445,7 +445,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400. Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -507,9 +507,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. -- Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. +- Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the exported live-send) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the exported live-send) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). - [ ] **Step 4: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. From e53107c0df17a874cd387e7fdaf6b561dae0a9c2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 01:53:48 -0700 Subject: [PATCH 012/139] Fold decomposition round-4 review into the plan - MAJOR: Task 7 now wires preview-ports-exec.ts to the display registry (Task 4 hardwired screen:null per its own note and nothing un-hardwired it: decision 6's discriminator and Task 14's real leg read a supply line no task built); + ports-screen and bindProbe-seam tests - PREVIEW_STREAM_DEBT_MAX actually lands in the constants fence (was named, fenced nowhere) - byte-cap seam keys named where the cap tests need them (Task 5 laneBufferMaxBytes, Task 10 preAckMaxBytes) - live sender decided: feed context value gains bound send(frame): boolean (helper at :23 needs a socket the provider closure never shared), plus watch re-fire on reconnect - decision 7 recorded: per-BRIDGE subscriber sum vs PREVIEW_WATCH_SUBSCRIBERS_MAX, and the socket fan-out capped at the same 8 with over-cap audit - NITs: phantom previewFlags() -> getPreviewEnabled(); ::1 added to the CDP host check per the Global Constraint; orphan dropViewer removed; collision-retry seam --- .../plans/2026-10-21-pane-preview.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 8c258bb3e..1502faa41 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -33,6 +33,8 @@ 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. 6. Spec §6's Browser-lanes paragraph calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. +7. The watch limits, stated where the spec left room: spec §6 caps "max 8 watch subscribers per bridge," and with the bridge pane-keyed (correctness round 4) the cap checks the SUM of the per-viewer counts on the bridge BEFORE the increment - a per-viewer reading of `preview-watch-over-cap` would make the number meaningless, because eight tabs of one account would pass while a crowd of nine accounts could not. The cap also covers the socket half - `attachStream` refuses a pane's 9th attached socket with an `over-cap` audit - because the socket fan-out is the same per-frame broadcast path the byte-debt rule guards, and one person's 40 tabs must not multiply every screencast emit; reusing `PREVIEW_WATCH_SUBSCRIBERS_MAX` keeps one honest number in play. + **Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. --- @@ -69,7 +71,7 @@ - `services/nodes/node-ws-handler.ts` (modify): the event switch's `case "relay":` arm (:1196, delegating to `onRelayFrame` from relay-frames.ts) is where every node event is dispatched - the switch has NO default branch, so an event type with no arm here is silently dropped. The three preview event types need their own arms; without them the plane discards every preview frame while unit tests that call the intake function directly stay green. **`apps/server/web/src/`** -- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-launch-hint.tsx`, `screen-viewer.tsx` (new; `screen-launch-hint.tsx` arrives with Task 12, `screen-viewer.tsx` with Task 13); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus; export its `send()` client-frame helper so `watch`/`unwatch` can actually be sent). +- `hooks/use-subshell-preview.ts`, `lib/preview-bus.ts` (new); `components/preview/preview-section.tsx`, `port-list.tsx`, `open-panel.tsx`, `watch-viewer.tsx`, `screen-launch-hint.tsx`, `screen-viewer.tsx` (new; `screen-launch-hint.tsx` arrives with Task 12, `screen-viewer.tsx` with Task 13); `routes/subshells_.$id.tsx`, create/launch dialog, node settings page (modify); `hooks/use-live-subshells-feed.tsx` (modify: dispatch `preview-stream` frames to the bus; add a bound `send: (frame) => boolean` to the feed context value - bound to the provider's socket, false while disconnected - so `watch`/`unwatch` can actually be sent; Task 12 owns the decision). **Docs and release:** `apps/server/api/AGENTS.md` (audit inventory + preview-lane rules), `docs/security.md` (preview-lane paragraph), `apps/node/agent/AGENTS.md` (preview executors join the inventory; loopback invariant; xvfb/x11vnc/xauth optional runtime deps with named refusals), `apps/docs/content/docs/` (user page), `.changeset/` (one changeset: `@internal/server` minor + `@internal/node` minor). @@ -169,6 +171,8 @@ export const PREVIEW_SESSIONS_PER_NODE = 16; export const PREVIEW_WATCH_SUBSCRIBERS_MAX = 8; /** Lane waiters queued beyond the pane cap before a refusal (decision 2's burst posture). */ export const PREVIEW_LANE_QUEUE_MAX = 8; +/** Per-viewer-socket queued-bytes ceiling on the live fan-out; Task 8 detaches past it (`slow-link`). ~10 s of q60 at the 5 fps cap, generous against Bun's own WS backpressure. */ +export const PREVIEW_STREAM_DEBT_MAX = 4_194_304; /** A forward lane with no bytes either way for this long self-closes (spec §6: 15 min). */ export const PREVIEW_IDLE_MS = 900_000; /** Lanes ride a lifetime inside this window; the command refuses outside it. */ @@ -301,7 +305,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. - [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - command gates arrive with Tasks 4/5/7, each tested there); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. -- [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(previewFlags() ? { previewEnabled: previewFlags() } : {})`. +- [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. --- @@ -361,7 +365,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "forward"`), `PreviewCloseCommand`, `PreviewDataFrame` + `preview_data` NodeEvent, `preview_closed` NodeEvent, limits. -- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks, deps }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone` (there is no host-refusal: the wire carries a port, never a host, and the grammar bounds it, so a non-loopback dial is unrepresentable). The `deps` seam (`{ connect?, now?, timer? }`, same style as Task 7's) is how the tests hold a connect open, fake the clock, and shrink the idle/lifetime/slow-link budgets - module-level `export const` limits are not overridable from a test. +- Produces: `forward.ts`: `openLane({ ref, port, paneId, lifetimeMs, hooks, deps }): void` (registers SYNCHRONOUSLY then dials off-chain; the hooks carry the ack result), `closeLane(ref, reason): { closed: boolean }`, `ingestInboundFrame(frame): void`, `laneCount(paneId?): number`, `closeAll(reason): void`; refusals `preview-node-off`, `preview-lane-over-node-cap`, `preview-lane-over-pane-cap`, `preview-pane-gone` (there is no host-refusal: the wire carries a port, never a host, and the grammar bounds it, so a non-loopback dial is unrepresentable). The `deps` seam (`{ connect?, now?, timer?, laneBufferMaxBytes? }`, same style as Task 7's) is how the tests hold a connect open, fake the clock, and shrink the idle/lifetime/slow-link budgets - module-level `export const` limits are not overridable from a test, which is exactly why the byte cap is a seam key (the slow-link case needs a small buffer, and 1 MiB is not small). - [ ] **Step 1: Failing tests** (real in-test echo server on an ephemeral loopback port; fake context capturing emitted events; tiny injected idle/lifetime budgets for the timer cases): - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. @@ -390,7 +394,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is `127.0.0.1`/`localhost` -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). - [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, counts toward `PREVIEW_SESSIONS_PER_NODE`, result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. @@ -400,14 +404,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/display.ts` -- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) - Test: `apps/node/agent/src/__tests__/preview-display.test.ts`, `packages/pane-runtime/src/__tests__/launch.test.ts` (the extraEnv layer: pane sees DISPLAY/XAUTHORITY, the exit-hook env from :224 agrees, and with no display the chain is byte-identical to today) **Interfaces:** - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. -- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. +- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; generate the auth file with `xauth generate -f :NN .` and start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp`; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional fourth layer `extraEnv` (empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. @@ -424,10 +428,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`, cap `preview-watch-over-cap`); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's); `dropViewer(viewerUserId)`; `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -473,7 +477,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - binary WS round trip byte-exact in `http` mode. Lane-bypass guard, stated precisely: the in-proc `Bun.serve` target DOES answer on plane loopback, so a client that bypassed the lane would still connect and pass a naive byte-equality check - the fake node therefore COUNTS the bytes it pipes, and the test asserts the handshake plus the round-trip payload all passed through the fake (bypassing the lane leaves the counter at zero even though the response looks perfect); - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success). + - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success) - `pv-ws.ts` takes an options `{ preAckMaxBytes? }` defaulting to the constant so the test drives the boundary small, the same seam doctrine as Task 5's `laneBufferMaxBytes`. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). - [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. @@ -502,14 +506,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/web/src/hooks/use-subshell-preview.ts`, `lib/preview-bus.ts`, `components/preview/preview-section.tsx`, `components/preview/port-list.tsx`, `components/preview/open-panel.tsx`, `components/preview/watch-viewer.tsx`, `components/preview/screen-launch-hint.tsx` -- Modify: `routes/subshells_.$id.tsx` (compose under the terminal, spec §7's column), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` to the bus AND export its existing client-frame send - the `send(socket, LiveClientFrame)` helper at :23, already used for `previews`/`resync` at :156/:174, because `watch`/`unwatch` never reach the server unless the SPA sends them), node settings page (the Preview toggle beside the ssh one, same gate/tooltip posture - read the ssh toggle's component first), create dialog (optional "Virtual display" -> `screen` body field), phone layout check (grep how the dock trades tiling for tabs and reuse the same layout predicate). +- Modify: `routes/subshells_.$id.tsx` (compose under the terminal, spec §7's column), `hooks/use-live-subshells-feed.tsx` (dispatch `preview-stream` to the bus AND give the tree a usable sender: the `send(socket, LiveClientFrame)` helper at :23 is a two-arg module function, the live socket lives in the provider's closure (:134, the `ws` variable), and the context value carries only `{ connected, lastList, requestPreviews }` (:429-431) - exporting the helper would hand the preview hook a function with nothing to call it on. So: add `send: (frame: LiveClientFrame) => boolean` to `LiveSubshellsFeedValue`, bound to the current socket, returning false when not connected. `watch`/`unwatch` never reach the server unless the SPA sends them, which is why this half exists), node settings page (the Preview toggle beside the ssh one, same gate/tooltip posture - read the ssh toggle's component first), create dialog (optional "Virtual display" -> `screen` body field), phone layout check (grep how the dock trades tiling for tabs and reuse the same layout predicate). - Test: `components/preview/__tests__/` cloning whatever render harness `routes/__tests__/setup.test.tsx` uses (check `find apps/server/web/src -name "*.test.ts*"` and imitate the lighter precedent if one exists). **Interfaces:** - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the exported live-send) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). - [ ] **Step 4: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. From e940a649b47e07a7a71e538b92968a90d96ff776 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 02:17:40 -0700 Subject: [PATCH 013/139] Fold decomposition round-5 review into the plan - Task 1 runs lint:lockfile:fix and ships the re-recorded bun.lock version line (bun.lock:128 pins 2.3.0; bun install never resyncs; lint.yml:84 gates it, so the bump commit without the fix reddens CI forward) - closeForGrantee is detach-only, matching Task 11's pinned socket-alive outcome; Task 8's fake asserts close is never called - Files-block gaps: Task 7 gains commands/context.ts, Task 8 gains node-ws-handler.test.ts (the dispatch rows) - Task 11 revocation pointer corrected: the matrix has no revoke case today, the precedent is live-ws.ts:284-288 - info on an online non-Linux node pins nodeEnabled true + preview-ports-unsupported reason (Task 14's mac leg reads exactly that) --- .../plans/2026-10-21-pane-preview.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 1502faa41..bb4188b90 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -251,7 +251,8 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside ```bash bunx turbo build --filter=@internal/subshell-protocol -git add -A packages/subshell-protocol apps/node/agent/package.json +bun run lint:lockfile:fix +git add -A packages/subshell-protocol apps/node/agent/package.json bun.lock # plus EVERY apps/ fixture step 6 actually fixed, by PATH (run # `git status --short apps/` and add exactly the files you edited - typically # the node-gates/version fixtures under apps/server/api/src/services/nodes/ @@ -270,6 +271,8 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s (Step 6's version-pin fixture fixes in `apps/` ship IN this commit - the bump doctrine means HEAD is only green when the floor, the version, and every fixture that pins them land together.) +`lint:lockfile:fix` is not decoration: `bun.lock:128-129` records this workspace's version (`"2.3.0"`) and `bun install` never resyncs it - `lint.yml:84` runs `lint:lockfile` on every push, so the bump commit without the fix reddens CI from that commit forward, and Task 10's own lockfile step trips on a drift it did not cause. The script is deliberately narrow (anchored, asserts each match exactly once): expect `bun.lock`'s diff to be ONLY the recorded `@internal/node` version line. Anything else in that diff is somebody else's lockfile drift mid-flight - name it in the commit body and do not silently commit it. + --- ### Task 2: Live feed grammar (watch subscribe frames + stream frame) @@ -404,7 +407,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/display.ts` -- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `commands/context.ts` (the display registry handle the ports executor reads - same placement as the pane handle Task 4 added), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) - Test: `apps/node/agent/src/__tests__/preview-display.test.ts`, `packages/pane-runtime/src/__tests__/launch.test.ts` (the extraEnv layer: pane sees DISPLAY/XAUTHORITY, the exit-hook env from :224 agrees, and with no display the chain is byte-identical to today) **Interfaces:** @@ -424,14 +427,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions, watchers, and stream sockets ON THAT PANE - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions and watchers ON THAT PANE and DROPS THEIR STREAM REGISTRATIONS - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched, and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive across a mid-stream grant removal); stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -447,9 +450,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. -- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). +- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -496,7 +499,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Produces: `sendToSockets`; for a holder with `view`+, `{"type":"watch","id":paneId}` attaches that socket, and a running bridge then delivers `{"type":"preview-stream","id","seq","w","h","blob"}` to attached sockets only. - [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected); byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. -- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives - compare, do not guess, against how the matrix handles revocations today). +- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives). Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a revoked-row holder gets asked for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. - [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round. - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. From 890ff7d34fc2e62d185269d4719372387e71d761 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 02:26:01 -0700 Subject: [PATCH 014/139] Fold correctness round-5 review into the plan --- .../plans/2026-10-21-pane-preview.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index bb4188b90..71b93554a 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -163,7 +163,10 @@ export const PREVIEW_WATCH_FPS_DEFAULT = 5; export const PREVIEW_WATCH_FPS_MAX = 5; export const PREVIEW_FPS_GRAMMAR_MAX = 60; export const PREVIEW_WATCH_JPEG_QUALITY = 60; -/** Frames the bridge may have in flight toward the plane before it starts dropping. */ +/** Frames the bridge holds queued at the emit boundary (produced but not yet accepted); + * the queue drains as the pump accepts, so this never becomes a counter with no decrement. + * Full means NEW frames drop and emission resumes on drain - never backpressure the + * browser (spec §5). The link never acks events individually; this counts bridge-held frames. */ export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; /** Lane quotas: per pane, per node, and per watch bridge. */ export const PREVIEW_FORWARD_SESSIONS_PER_PANE = 4; @@ -306,7 +309,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - command gates arrive with Tasks 4/5/7, each tested there); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -377,6 +380,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap`. - (e) idle: injected small idle budget self-closes with `preview_closed` reason `idle`; target socket end emits `preview_closed` `target-gone` exactly once per lane; a `preview_close` that lands first suppresses the later EOF event (assert one emission total). - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. + - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. @@ -395,9 +399,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. - Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the 60 s idle, and the fps throttle are all testable without a real browser or clock); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes). +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` un-acked-by-link frames in flight (drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). - [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, counts toward `PREVIEW_SESSIONS_PER_NODE`, result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. @@ -414,10 +418,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. - Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever). +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; generate the auth file with `xauth generate -f :NN .` and start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp`; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). -- [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional fourth layer `extraEnv` (empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. +- [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. - [ ] **Step 5: Sweep timer** beside daemon's other self-timers: hourly `display.sweepOrphans(alive pane ids)`, cloned from the ssh per-pane-dir retention pattern (`ssh-dir-retention` precedent; read it first). - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. @@ -427,14 +431,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket; AFTER the shares write it calls `broker.closeUnauthorizedForPane(paneId)`, mirroring how `set-node-shares.route.ts:51` runs the relay broker's `closeUnauthorizedForNode` after its own write rather than diffing events). - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeForGrantee(paneId, granteeUserId, reason)` (a lost SHARE closes only that grantee's sessions and watchers ON THAT PANE and DROPS THEIR STREAM REGISTRATIONS - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it - `closeAllForUser` would wrongly sweep their previews on other panes, which they may still hold); `attachStream(paneId, socket)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at :59-60 reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Set` map, because "which device sees the canvas" is socket-level state; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeForGrantee` closes that grantee's session on the revoked pane only, leaving the owner's and a second grantee's untouched, and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive across a mid-stream grant removal); stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -491,7 +495,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more, bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :59-60: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** @@ -499,7 +503,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Produces: `sendToSockets`; for a holder with `view`+, `{"type":"watch","id":paneId}` attaches that socket, and a running bridge then delivers `{"type":"preview-stream","id","seq","w","h","blob"}` to attached sockets only. - [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected); byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. -- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives). Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a revoked-row holder gets asked for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. +- [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives); a mid-stream `edit`->`view` downgrade keeps those frames flowing (watch is what `view` buys) while it is lane access that closes - Task 8's broker test owns that split, this matrix asserts the stream half. Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a client that received a row it has never seen ASKS for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. - [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round. - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. From b12619cd845554b509f8bbfbb76a5c67c235d224 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 02:47:02 -0700 Subject: [PATCH 015/139] Fold decomposition round-6 review into the plan preview_enabled event wired end to end (Task 1 grammar, Task 3 reconcile/ dispatch/heartbeat, spec bump entry), preview-node-off named in Task 4, wire-level tests for the disable/role/shares cuts, PreviewStreamSocket + PreviewStreamFrame typed once, closeAll lane test, Task 1 barrel in Files. Co-Authored-By: Claude --- .../plans/2026-10-21-pane-preview.md | 49 +++++++++++-------- .../specs/2026-10-21-pane-preview-design.md | 2 +- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 71b93554a..ed942d18b 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -42,11 +42,12 @@ ## File Structure **`packages/subshell-protocol/src/`** -- `node-frames.ts` (modify): version 23 + bump-history entry, four new commands into `NodeCommandBody` (:781), `PreviewDataFrame` + `parsePreviewFrame`, `NodeEvent` members `preview_data` / `preview_stream` / `preview_closed`, `launch` gains `screen`, `ready.previewEnabled`, `NodePreviewEnabledWire`. +- `node-frames.ts` (modify): version 23 + bump-history entry, four new commands into `NodeCommandBody` (:781), `PreviewDataFrame` + `parsePreviewFrame`, `NodeEvent` members `preview_data` / `preview_stream` / `preview_closed` / `preview_enabled`, `launch` gains `screen`, `ready.previewEnabled`, `NodePreviewEnabledWire`. - `node-results.ts` (modify): answer validators for the four new commands (file header rule: both sides validate `data`) and the `launch` result's optional `screen` echo. - `live-frames.ts` (modify): server member `preview-stream`, client members `watch` / `unwatch`, parse arms. - `ssh-limits.ts` (modify): `PREVIEW_*` constants (spec §4 makes this file the home of shared link-lane limits). - `versions.ts` (modify): floor 2.4.0. +- `index.ts` (modify): the barrel re-export of the new symbols (Task 1 Steps 1 and 4 land it; the barrel is mobile's Metro surface, and nothing added here pulls a `node:*` import). - `__tests__/node-frames.test.ts`, `__tests__/live-frames.test.ts` (modify): new hand-pinned census + boundary cases (the census tests in this file enumerate by hand and the count is the tripwire, `node-frames.test.ts:1105-1120`). **`apps/node/agent/src/`** @@ -84,11 +85,12 @@ - Modify: `packages/subshell-protocol/src/node-results.ts` (validators; read its header first: `launch` sits on its DATALESS list today - success is just `{ ok: true }`, no validator - so the `screen` echo below is the first data field that command ever carried, and `parseNodeSshExecKick` models the hand-rolled validator style) - Modify: `packages/subshell-protocol/src/ssh-limits.ts` - Modify: `packages/subshell-protocol/src/versions.ts:123` (and its "Raised to..." comment) +- Modify: `packages/subshell-protocol/src/index.ts` (the barrel re-export Step 1 and Step 4 call for; today this Files list omitted the file two of its own steps edit) - Modify: `apps/node/agent/package.json` (`"version": "2.3.0"` -> `"2.4.0"`) - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` **Interfaces:** -- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`), `preview_enabled` (`{ on, changedAt }`, the mirror-drift event spec §4's bullet names - Task 3 is its consumer, not Task 8's broker); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. - Consumes: nothing. - [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim; follow the disk pattern on SHAPE: `SSH_COMMAND_TYPES` is a const-exported list (it lives at `ssh-frames.ts:375`, where its grammar lives, re-exported at the `src/index.ts:292` barrel and imported by name in the test) - do the same for `PREVIEW_COMMAND_TYPES`, exported from `node-frames.ts` where the preview grammar lives, re-exported through the barrel, test-imported, not test-local (the barrel is mobile's Metro surface: this adds no `node:*` import, so it is safe there). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: @@ -141,10 +143,12 @@ describe("preview link frames and events", () => { expect(parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES + 1).toString("base64") })).toBeNull(); expect(parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES).toString("base64") })).not.toBeNull(); // cap on RAW bytes, like RelayFrame's base64RawLength }); - test("the three node events parse", () => { + test("the four node events parse", () => { expect(parseNodeEvent({ ...frame })).toEqual(frame); expect(parseNodeEvent({ type: "preview_stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" })).toEqual({ type: "preview_stream", ref: "a".repeat(32), seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" }); expect(parseNodeEvent({ type: "preview_closed", ref: "a".repeat(32), reason: "target-gone" })).toEqual({ type: "preview_closed", ref: "a".repeat(32), reason: "target-gone" }); + expect(parseNodeEvent({ type: "preview_enabled", on: true, changedAt: "2026-10-22T00:00:00.000Z" })).toEqual({ type: "preview_enabled", on: true, changedAt: "2026-10-22T00:00:00.000Z" }); + expect(parseNodeEvent({ type: "preview_enabled", on: "yes", changedAt: "2026-10-22T00:00:00.000Z" })).toBeNull(); }); }); ``` @@ -239,13 +243,14 @@ Into `NodeCommandBody` (:781) add all four; add `screen?: { width: number; heigh ```ts | { type: "preview_stream"; ref: string; seq: number; w: number; h: number; ts: number; blob: string } | { type: "preview_closed"; ref: string; reason: PreviewCloseReason } +| { type: "preview_enabled"; on: boolean; changedAt: string } ``` and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside `sshEnabled` (`daemon.ts:416` sends the latter). `export interface NodePreviewEnabledWire { on: boolean; changedAt: string }` beside `NodeSshEnabledWire` (:1294). Export the new symbols from `src/index.ts`; touch nothing that pulls a `node:*` builtin into the barrel (the mobile Metro rule in `.claude/rules/verification.md`). -- [ ] **Step 5: Wire parses, bump, results.** Add the four arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, integer 1..`PREVIEW_FPS_GRAMMAR_MAX`), the three arms to `parseNodeEvent` (clone the `relay` arm at :2163), export `parsePreviewFrame` cloning `parseRelayFrame` with `PREVIEW_FRAME_MAX_BYTES`. Then `NODE_PROTOCOL_VERSION = 23` with the spec §4 bump entry VERBATIM: +- [ ] **Step 5: Wire parses, bump, results.** Add the four arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, integer 1..`PREVIEW_FPS_GRAMMAR_MAX`), the four arms to `parseNodeEvent` (clone the `relay` arm at :2163; the `preview_enabled` arm validates `{ on, changedAt }` exactly as the `ssh_enabled` arm does), export `parsePreviewFrame` cloning `parseRelayFrame` with `PREVIEW_FRAME_MAX_BYTES`. Then `NODE_PROTOCOL_VERSION = 23` with the spec §4 bump entry VERBATIM: -> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch; a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. +> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch, and the `preview_enabled` event reports a mirror that moved underneath the connection (ssh's `ssh_enabled` twin); a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. (Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (`{ ports, cdpPort, screen }` exactly as spec §4's answer), `set_preview_enabled` gets NO validator and its ack is never read: the ssh precedent fires its command best-effort and discards the result (`ssh-enabled.ts:48` + `:138`), the plane's truth is its own DB row, and a dataless command has no entry here by the file's own header rule. `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it on ack - a post-ack `getPorts` would race the 5 s cache on a pane that just started), which makes `launch` the first data-carrying command on the header's dataless list: write `parseNodeLaunchResult` in the file's hand-rolled style, validate on BOTH ends per the header contract, and update the header's dataless sentence to drop `launch` from it. @@ -285,12 +290,12 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Test: `packages/subshell-protocol/src/__tests__/live-frames.test.ts` **Interfaces:** -- Produces: `LiveServerFrame` member `{ type: "preview-stream"; id: string; seq: number; w: number; h: number; blob: string }` (`id` is the subshell id; per spec §6 the live frame carries NO ref - subscribers address panes, refs stay plane-side); `LiveClientFrame` members `{ type: "watch"; id: string }`, `{ type: "unwatch"; id: string }`; `parseLiveClientFrame` arms. +- Produces: `LiveServerFrame` member `{ type: "preview-stream"; id: string; seq: number; w: number; h: number; blob: string }`, written as an exported named `PreviewStreamFrame` interface that the union references by name (Task 8's fan-out and Task 11's `sendToSockets` take a `PreviewStreamFrame` parameter, and `LiveServerFrame` itself cannot be written bare there: it takes two type params with no defaults, `live-frames.ts:26`) (`id` is the subshell id; per spec §6 the live frame carries NO ref - subscribers address panes, refs stay plane-side); `LiveClientFrame` members `{ type: "watch"; id: string }`, `{ type: "unwatch"; id: string }`; `parseLiveClientFrame` arms. - Consumes: nothing (the blob is a string). - [ ] **Step 1: Failing tests** in `live-frames.test.ts`: `parseLiveClientFrame` accepts `{"type":"watch","id":"s1"}` and `{"type":"unwatch","id":"s1"}`; rejects a missing or non-string `id`; rejects unknown client types exactly as its current cases do (read the file's existing style first and clone it). - [ ] **Step 2: Run, watch them fail** (`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/live-frames.test.ts`). -- [ ] **Step 3: Implement** the union members and parse arms. JSDoc: the stream frame is published only to sockets the broker has verified hold `view` on the row; it carries no `ref` on purpose. +- [ ] **Step 3: Implement** the union members and parse arms - the stream member is the exported `PreviewStreamFrame` interface used by name in the union. JSDoc: the stream frame is published only to sockets the broker has verified hold `view` on the row; it carries no `ref` on purpose. - [ ] **Step 4: Package tests, turbo build, commit** `feat(protocol): live watch subscriptions and preview-stream frames`. --- @@ -301,15 +306,15 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Read: `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route and test (`grep -rln "set_ssh_enabled" apps/server/api/src/api apps/server/api/src/services`), node half: `grep -rn "sshEnabled" apps/node/agent/src`, the `set_ssh_enabled` executor in `apps/node/agent/src/commands/`. - Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one, `apps/server/api/src/db/migrations/0062-node-preview-enabled.ts` (+ its migration test, cloning `migrations/__tests__/0047-node-ssh-enabled.test.ts`). - Modify: `packages/subshell-protocol/src/api-key-permissions.ts` (the `node-security` group gains `{ id: "nodes:preview", label: "Preview panes", description: "Turn pane preview on or off on a node." }` beside `nodes:ssh` at :36): the ssh route is annotated `systemPermission: "nodes:ssh"` (`set-node-ssh-enabled.route.ts:82`) and a route's permission string is a catalog member (`auth-guard.ts:194`), so the preview switch needs its OWN id - reusing `nodes:ssh` would let every ssh-scoped key flip preview, and the catalog feeds the key-minting UI's checkboxes, so it is a public-surface entry, not an internal string. -- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). -- Test: plane route test + `apps/node/agent/src/__tests__/preview-flags.test.ts`. +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416; AND the heartbeat call - `maybeReportPreviewEnabled(ctx)` beside `maybeReportSshEnabled` at :1317), `apps/node/agent/src/commands/report.ts` (`reportPreviewEnabled` + `maybeReportPreviewEnabled`, cloning `reportSshEnabled` at :163 (send the mirror verbatim + stash the memo) and `maybeReportSshEnabled` at :186 (report only when the mirror moved since this connection last spoke about it)), `apps/node/agent/src/commands/context.ts` (the `lastReportedPreviewEnabled` memo beside the ssh one - the memo is what keeps a plane-pushed `set_preview_enabled` from echoing back as an event), `apps/server/api/src/services/nodes/node-events.ts` (the `NodeLifecycleHooks` member `onPreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined)` beside `onSshEnabled` at :114), `apps/server/api/src/services/nodes/node-ws-handler.ts` (the ready-path read beside `hooks.onSshEnabled` at :1099 AND a `case "preview_enabled":` arm cloning :1170 - the switch has no default branch, so an event with no arm lands dead and every other suite stays green), `apps/server/api/src/index.ts` (import + the hook wiring `onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported)` beside :330), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). +- Test: plane route test + `apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts` (clone the ssh one's two layers: the pure decide table, and the hook body with one real row and one scripted node whose push lands in the real wire - the `ssh-enabled.test.ts:21-22` seam, which is what proves the `set_preview_enabled` frame parses against the shipped parser) + the dispatch rows in `apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts` (clone the `ssh_enabled` event row at :937-944: feed `{ type: "preview_enabled", on, changedAt }` through `handleNodeMessage` and assert the lifecycle hook saw it) + `apps/node/agent/src/__tests__/preview-flags.test.ts`. **Interfaces:** -- Consumes: `SetPreviewEnabledCommand`, `NodePreviewEnabledWire` (Task 1). -- Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`. +- Consumes: `SetPreviewEnabledCommand`, `NodePreviewEnabledWire`, the `preview_enabled` NodeEvent (Task 1). +- Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); node `commands/report.ts`: `reportPreviewEnabled(ctx, state)` + `maybeReportPreviewEnabled(ctx)`; plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`, and `reconcilePreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined): Promise` - clone `ssh-enabled.ts:174`'s direction rule verbatim: the row is the record, every disagreement answers with a push of the row's own value (that push IS the corruption repair), never an adopt. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -324,7 +329,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Test: `apps/node/agent/src/__tests__/preview-ports.test.ts` **Interfaces:** -- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported`; command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. +- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported` and on switch-off with `preview-node-off` (the same word Tasks 5 and 7 refuse with, so Task 7's "every entry point refuses with one voice" is a fact the executor test pins, not a claim); command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. - Consumes: pane pid (above), display registry (Task 7; before it exists, `screen` is null). - [ ] **Step 1: Failing unit tests** over a fixture `/proc` in a temp dir (write `root//stat` with the ppid field, `root//cmdline`, `root//fd/N` symlinks whose target is the string `socket:[INODE]`, and `root/net/tcp` + `net/tcp6` LISTEN rows with the inode column): @@ -352,11 +357,11 @@ test("an IPv6 loopback listener parses", async () => { }); ``` -Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switch OFF refuses naming the remedy; a dead pane refuses `preview-ports-pane-gone`; a successful answer passes Task 1's `node-results` validator (the shape is contract, not vibes). +Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switch OFF refuses `preview-node-off` naming the remedy; a dead pane refuses `preview-ports-pane-gone`; a successful answer passes Task 1's `node-results` validator (the shape is contract, not vibes). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `ports.ts`:** one pass (spec §5: stateless), parse `net/tcp`/`net/tcp6` state `0A`, hex `local_address:port`, collect inodes; ppid map from `/stat`; BFS descendants of `panePid` (cap: 512 pids, refusal `preview-ports-overcap`); readlink `fd/*`, intersect, deepest descendant per inode; CDP probe each listed port with the injected default `GET http://127.0.0.1:/json/version` (400 ms, `AbortSignal.timeout`, accept when the JSON has a `Browser` field); non-Linux refuses BEFORE touching fs. `procRoot` and `probe` exist only for tests. -- [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with the display registry's `screen` for that pane. +- [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses `preview-node-off` naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with the display registry's `screen` for that pane. - [ ] **Step 5: Green + commit** `feat(node): attribute pane subtrees' listening sockets, probing CDP per spec`. --- @@ -381,6 +386,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (e) idle: injected small idle budget self-closes with `preview_closed` reason `idle`; target socket end emits `preview_closed` `target-gone` exactly once per lane; a `preview_close` that lands first suppresses the later EOF event (assert one emission total). - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. + - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. @@ -397,7 +403,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. -- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the 60 s idle, and the fps throttle are all testable without a real browser or clock); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. +- Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the lifetime timer, the fps throttle, and the emit-boundary drop are all testable without a real browser or clock - the 60 s idle is deliberately NOT here: it is plane-side zero-subscriber state, Step 3 forbids a node-side traffic-idle); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. - [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart). - [ ] **Step 2: Fail.** @@ -411,7 +417,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/display.ts` -- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `commands/context.ts` (the display registry handle the ports executor reads - same placement as the pane handle Task 4 added), `daemon.ts` (hourly orphan sweep), the pane-death path `launch.ts`/reportDeath already used for plane death hooks (read how pane death is reported; spec §5 names `reportDeath`) +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `commands/context.ts` (the display registry handle the ports executor reads - same placement as the pane handle Task 4 added), `daemon.ts` (hourly orphan sweep), `apps/node/agent/src/commands/report.ts` (the pane-death hook path: `reportDeath` at :376 is where a supervised pane's death is reported and forgotten - read how it runs its hooks; spec §5 names it) - Test: `apps/node/agent/src/__tests__/preview-display.test.ts`, `packages/pane-runtime/src/__tests__/launch.test.ts` (the extraEnv layer: pane sees DISPLAY/XAUTHORITY, the exit-hook env from :224 agrees, and with no display the chain is byte-identical to today) **Interfaces:** @@ -431,14 +437,15 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket; AFTER the shares write it calls `broker.closeUnauthorizedForPane(paneId)`, mirroring how `set-node-shares.route.ts:51` runs the relay broker's `closeUnauthorizedForNode` after its own write rather than diffing events). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that; do NOT wire the fourth preview event `preview_enabled` here - that one belongs to Task 3's arm and drives the switch reconciler, not the broker), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket; AFTER the shares write it calls `broker.closeUnauthorizedForPane(paneId)`, mirroring how `set-node-shares.route.ts:51` runs the relay broker's `closeUnauthorizedForNode` after its own write rather than diffing events). +- Test: `apps/server/api/src/api/users/__tests__/users-management.test.ts` (Step 1's wire cases in its `disable` describe at :403 and `role assignment` describe at :153) + that task's new `preview-broker.test.ts` case around the shares write. - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are the structural `{ send(data: string): unknown; bufferedAmount?: number }` shape - `LiveWsSocket` carries send/readyState/close today and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -495,7 +502,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: LiveServerFrame)` - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** diff --git a/docs/superpowers/specs/2026-10-21-pane-preview-design.md b/docs/superpowers/specs/2026-10-21-pane-preview-design.md index 481467e04..4537c5423 100644 --- a/docs/superpowers/specs/2026-10-21-pane-preview-design.md +++ b/docs/superpowers/specs/2026-10-21-pane-preview-design.md @@ -45,7 +45,7 @@ Doctrine copied from the bump history (`node-frames.ts:80-187`): additive in sha Bump-history entry to land with the code: -> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch; a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. +> **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch, and the `preview_enabled` event reports a mirror that moved underneath the connection (ssh's `ssh_enabled` twin); a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. New **signed commands** (`node-frames.ts` grammar + census test, `node-results.ts` validators for each answer): From 75b208a49ec8d58299ed8a26541a4b321cb27356 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 02:54:18 -0700 Subject: [PATCH 016/139] Fold correctness round-6 review into the plan - Cookie jar gets an owner: rides PreviewSession (dies with it, no second teardown path), pv-http reads/writes it, pv-ws http re-dial carries it - Sender cap named and placed: sendLaneBytes closes over-cap plane-side, forward pump case (i) node-side; over-cap's only close use is spec section 6's sentence that reserved it - Node-reported bridge close now tears the plane half down (session, viewer counts, detach-not-close) so startWatch re-opens instead of joining a phantom bridge - fps grammar reworded to optional-under-screencast (matches the test) - Task 14 manual live leg grounded in spec section 10's own non-gating sentence (reviewer's CI-job premise not in the spec) --- .../plans/2026-10-21-pane-preview.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index ed942d18b..66f10fa0c 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -248,7 +248,7 @@ Into `NodeCommandBody` (:781) add all four; add `screen?: { width: number; heigh and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside `sshEnabled` (`daemon.ts:416` sends the latter). `export interface NodePreviewEnabledWire { on: boolean; changedAt: string }` beside `NodeSshEnabledWire` (:1294). Export the new symbols from `src/index.ts`; touch nothing that pulls a `node:*` builtin into the barrel (the mobile Metro rule in `.claude/rules/verification.md`). -- [ ] **Step 5: Wire parses, bump, results.** Add the four arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` iff `kind === "screencast"`, integer 1..`PREVIEW_FPS_GRAMMAR_MAX`), the four arms to `parseNodeEvent` (clone the `relay` arm at :2163; the `preview_enabled` arm validates `{ on, changedAt }` exactly as the `ssh_enabled` arm does), export `parsePreviewFrame` cloning `parseRelayFrame` with `PREVIEW_FRAME_MAX_BYTES`. Then `NODE_PROTOCOL_VERSION = 23` with the spec §4 bump entry VERBATIM: +- [ ] **Step 5: Wire parses, bump, results.** Add the four arms to `parseNodeCommandBody` (the `preview_open` arm enforces the kind-shape rule its test pins: `fps` may appear ONLY when `kind === "screencast"` and is OPTIONAL there (an absent key stays absent - no defaulting at the grammar; the default belongs to Task 6's bridge), integer 1..`PREVIEW_FPS_GRAMMAR_MAX`), the four arms to `parseNodeEvent` (clone the `relay` arm at :2163; the `preview_enabled` arm validates `{ on, changedAt }` exactly as the `ssh_enabled` arm does), export `parsePreviewFrame` cloning `parseRelayFrame` with `PREVIEW_FRAME_MAX_BYTES`. Then `NODE_PROTOCOL_VERSION = 23` with the spec §4 bump entry VERBATIM: > **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch, and the `preview_enabled` event reports a mirror that moved underneath the connection (ssh's `ssh_enabled` twin); a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. @@ -387,8 +387,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. + - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. +- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. - [ ] **Step 5: Green + commit** `feat(node): pending-ack forward lanes with off-chain dial and 1 MiB slow-link pump`. @@ -442,12 +443,12 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity }` - `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names, and `over-cap`'s only CLOSE use in this feature (the refusals keep their distinct `preview-*-over-cap` codes); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason; DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -483,7 +484,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Failing unit tests** (real in-proc `Bun.serve` target + a 25-line in-test fake node that pumps `preview_data` to a loopback socket): - outbound `Set-Cookie` forced to `Path=/pv//`, other attributes preserved; - - the browser's `Cookie` header NEVER reaches the target (send `Cookie: plane=`, assert the target saw no `plane=`, and later requests rebuilt from the jar alone); + - the browser's `Cookie` header NEVER reaches the target (send `Cookie: plane=`, assert the target saw no `plane=`); the jar round trip: response one sets a cookie, a request two that sends NO `Cookie` header arrives at the target carrying the jarred cookie - on a FRESH lane (the fake node already counts lane bytes, and a lane reused from request one would pass this with zero jar code) - while a request against a DIFFERENT session's proxy path carries nothing, and a session reopened after `closeForPane` starts with an empty jar (the jar rides the session object, so it dies wherever sessions die); - `Host` arrives as `127.0.0.1:`; - upstream `Content-Security-Policy` with three directives survives as two, `frame-ancestors` gone; the report-only variant gets the same carve-out; - relative `Location: /foo` returns prefixed `/pv//foo`; @@ -493,8 +494,8 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success) - `pv-ws.ts` takes an options `{ preAckMaxBytes? }` defaulting to the constant so the test drives the boundary small, the same seam doctrine as Task 5's `laneBufferMaxBytes`. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop `cookie`, set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path, rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. +- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store the rewritten value in `session.cookieJar` keyed by cookie name (same name replaces; per-path keying inside the jar is a v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -559,7 +560,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. -- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). +- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). - [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. - [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac). From 6380cc80e6be87c6bf86f307b75d7516691fc247 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 03:10:06 -0700 Subject: [PATCH 017/139] Fold decomposition round-7 review into the plan - Files entries now have owning steps: Task 9 info-poll REQUEST_LOG_IGNORE (+ pinned-test row), Task 12 settings toggle + create-dialog screen field (tests in Step 1, green in Step 4), Task 13 Screen-button wiring + render test, Task 14 changeset step, Task 10 bun add ws + server.ts registration - Task 2's barrel line: PreviewStreamFrame cannot reach Tasks 8/11 until index.ts names it (named-list block, no subpath export) - over-cap naming unified: refusals carry preview-*-over-cap codes, the bare word names closes (the sender rule, both halves), and node.preview_watch.viewer_drop gives the two unnamed audits (attach cap, byte debt) an action with reason in metadata; Task 14 inventory extended - Commit discipline restated in Global Constraints (every task commits, neighbors see only their own task); Task 1 Step 7 stages file paths, not the protocol directory - Probe-lane close named (viewer-left, wire-only; audit pairs belong to sessions), getPorts gains its declared shape and openForward's catch-and-fall-back for the mac leg - NITs: barrel Files parenthetical no longer a stale review note --- .../plans/2026-10-21-pane-preview.md | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 66f10fa0c..58bd337f3 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -33,7 +33,9 @@ 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. 6. Spec §6's Browser-lanes paragraph calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. -7. The watch limits, stated where the spec left room: spec §6 caps "max 8 watch subscribers per bridge," and with the bridge pane-keyed (correctness round 4) the cap checks the SUM of the per-viewer counts on the bridge BEFORE the increment - a per-viewer reading of `preview-watch-over-cap` would make the number meaningless, because eight tabs of one account would pass while a crowd of nine accounts could not. The cap also covers the socket half - `attachStream` refuses a pane's 9th attached socket with an `over-cap` audit - because the socket fan-out is the same per-frame broadcast path the byte-debt rule guards, and one person's 40 tabs must not multiply every screencast emit; reusing `PREVIEW_WATCH_SUBSCRIBERS_MAX` keeps one honest number in play. +7. The watch limits, stated where the spec left room: spec §6 caps "max 8 watch subscribers per bridge," and with the bridge pane-keyed (correctness round 4) the cap checks the SUM of the per-viewer counts on the bridge BEFORE the increment - a per-viewer reading of `preview-watch-over-cap` would make the number meaningless, because eight tabs of one account would pass while a crowd of nine accounts could not. The cap also covers the socket half - `attachStream` refuses a pane's 9th attached socket with the `preview-stream-over-cap` refusal code and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in its METADATA - refusal codes keep the distinct `preview-*-over-cap` shape so the bare close-reason word stays reserved for actual closes, and a viewer drop closes nothing) - because the socket fan-out is the same per-frame broadcast path the byte-debt rule guards, and one person's 40 tabs must not multiply every screencast emit; reusing `PREVIEW_WATCH_SUBSCRIBERS_MAX` keeps one honest number in play. + +**Commit discipline, restated because EVERY task ends in a commit and neighbors only see their own task:** the working tree shares the git INDEX with concurrent workstreams, so every commit step here means `git commit -m "..." -- ` - never a bare `git commit`, and `git add` takes file paths, never whole directories. Measured 2026-10-22 in this exact tree: a bare commit swallowed a sibling's staged `bun.lock`. Tasks 10 and 13 dirty `bun.lock` themselves (the `ws` add, the noVNC add) - stage it by path with the rest, and `git status --short` before and after must show nothing staged that you did not touch. **Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. @@ -85,7 +87,7 @@ - Modify: `packages/subshell-protocol/src/node-results.ts` (validators; read its header first: `launch` sits on its DATALESS list today - success is just `{ ok: true }`, no validator - so the `screen` echo below is the first data field that command ever carried, and `parseNodeSshExecKick` models the hand-rolled validator style) - Modify: `packages/subshell-protocol/src/ssh-limits.ts` - Modify: `packages/subshell-protocol/src/versions.ts:123` (and its "Raised to..." comment) -- Modify: `packages/subshell-protocol/src/index.ts` (the barrel re-export Step 1 and Step 4 call for; today this Files list omitted the file two of its own steps edit) +- Modify: `packages/subshell-protocol/src/index.ts` (the barrel re-exports Step 1's command types and Step 4's event additions; consumers import through it and `package.json` ships no subpath for the frames files, so an export without a barrel line is invisible to every consumer) - Modify: `apps/node/agent/package.json` (`"version": "2.3.0"` -> `"2.4.0"`) - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` @@ -260,12 +262,15 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside ```bash bunx turbo build --filter=@internal/subshell-protocol bun run lint:lockfile:fix -git add -A packages/subshell-protocol apps/node/agent/package.json bun.lock -# plus EVERY apps/ fixture step 6 actually fixed, by PATH (run -# `git status --short apps/` and add exactly the files you edited - typically -# the node-gates/version fixtures under apps/server/api/src/services/nodes/ -# __tests__ and apps/node/agent/__tests__). Whole directories must NEVER be -# staged: this tree is shared with concurrent workstreams (the sibling WIP +git add packages/subshell-protocol/src/node-frames.ts packages/subshell-protocol/src/node-results.ts packages/subshell-protocol/src/ssh-limits.ts packages/subshell-protocol/src/index.ts packages/subshell-protocol/package.json packages/subshell-protocol/src/__tests__/node-frames.test.ts apps/node/agent/package.json bun.lock +# plus EVERY protocol test file Steps 1-6 added or edited, and EVERY apps/ +# fixture step 6 actually fixed, each by PATH - run `git status --short +# packages/subshell-protocol` and `git status --short apps/` and add exactly +# the files you edited (typically the node-gates/version fixtures under +# apps/server/api/src/services/nodes/__tests__ and apps/node/agent/__tests__). +# No directory is staged whole: this tree is shared with concurrent +# workstreams - Task 2's live-frames tests live in the SAME __tests__ +# directory (the sibling WIP # under apps/server/api as of 2026-10-22 merged as PR #424 and this branch # rebased onto it; bun.lock and a leftover .changeset are dirty as of # writing, and a new sibling can appear at any time). The INDEX is shared @@ -287,6 +292,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s **Files:** - Modify: `packages/subshell-protocol/src/live-frames.ts` (server union beside `subshell-recheck` (:63) and `preview` (:69); client union; `parseLiveClientFrame`) +- Modify: `packages/subshell-protocol/src/index.ts` (the barrel's live-frames block is a NAMED list - `type LiveClientFrame, type LiveServerFrame, parseLiveClientFrame` at :73-76 - and `package.json` exports no `./live-frames` subpath, so Tasks 8 and 11 CANNOT import `PreviewStreamFrame` until this line names it: add `type PreviewStreamFrame` to that block. Neither neighbor's Files list carries a protocol file, which is exactly why this line rides here - Task 1 learned the same lesson for its own exports) - Test: `packages/subshell-protocol/src/__tests__/live-frames.test.ts` **Interfaces:** @@ -443,12 +449,12 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane, returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names, and `over-cap`'s only CLOSE use in this feature (the refusals keep their distinct `preview-*-over-cap` codes); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with an `over-cap` audit while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId)` (5 s shared cache per pane, spec §6); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); audit slow-link; continue; }` - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `over-cap` while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket (`slow-link` audit) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every close path audits its pair with the named reason. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -464,7 +470,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). @@ -494,8 +500,8 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success) - `pv-ws.ts` takes an options `{ preAckMaxBytes? }` defaulting to the constant so the test drives the boundary small, the same seam doctrine as Task 5's `laneBufferMaxBytes`. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `pv-http.ts`:** resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store the rewritten value in `session.cookieJar` keyed by cookie name (same name replaces; per-path keying inside the jar is a v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. +- [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store the rewritten value in `session.cookieJar` keyed by cookie name (same name replaces; per-path keying inside the jar is a v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the six verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -528,10 +534,11 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). -- [ ] **Step 4: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. +- [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). +- [ ] **Step 5: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. --- @@ -547,7 +554,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Read the library's real shape** (`sed -n '1,80p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. - [ ] **Step 2: Failing test** (mocked module; the component constructs on mount, disconnects on unmount - assert the lane's `viewer-left` path). -- [ ] **Step 3: Implement:** `new RFB(el, wsUrl)` with `scaleViewport = true`, `focusOnClick = true`; no credentials (spec §5: the lane and the grant are the authentication); `on disconnect` render the reason, `clipViewport` false. +- [ ] **Step 3: Implement:** `new RFB(el, wsUrl)` with `scaleViewport = true`, `focusOnClick = true`; no credentials (spec §5: the lane and the grant are the authentication); `on disconnect` render the reason, `clipViewport` false. Then wire the Screen BUTTON into `preview-section.tsx` (Task 12's Files deferred it to this task; nothing else lands it): when `info.screen` is present and the caller holds `edit`, a `Screen` action POSTs open against `info.screen.rfbPort` (decision 6 makes the session `raw`) and mounts this viewer in the panel. Its render test goes in Step 2: the button appears iff `info.screen && edit`, and pressing it drives exactly one open call against `info.screen.rfbPort` (spy) - the manual Task 14 container leg is otherwise the ONLY coverage this button ever gets, and manual legs do not gate. - [ ] **Step 4: License record:** the package ships its MPL-2.0 LICENSE and `bun run lint:licenses` governs package fields; if the repo has a third-party notices list (`grep -riE "third[-_ ]?party" apps/website apps/docs docs | head`) add an entry, else say in the commit body that the package's own license file is the record (decision 1). - [ ] **Step 5: Green + commit** `feat(web): noVNC screen viewer over the forward lane`. @@ -557,10 +564,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `e2e/tests/47-preview.spec.ts` (testDir is `./tests` per `playwright.config.ts:14`, and suites are numbered - next free number after `46-server-filters`; a root-level `e2e/preview.spec.ts` would never be collected), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). -- Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`; preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). +- Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. - [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). -- [ ] **Step 4: Full boundary:** `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. +- [ ] **Step 4: Changeset + full boundary:** `bunx changeset` - `@internal/server` minor + `@internal/node` minor (the Files line lands HERE because no other step touches it; never hand-edit CHANGELOGs, and stage the generated file by path). Then `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. - [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac). From af00fc4c7ae33d41fff99edb3d8174bb8ce85389 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 14:59:43 -0700 Subject: [PATCH 018/139] Fold correctness round-7 review into the plan --- .../plans/2026-10-21-pane-preview.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 58bd337f3..02ab1d8f5 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -388,14 +388,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. - (b) connected lane echoes: inbound `preview_data` dir "in" reaches the socket, socket bytes leave as `preview_data` dir "out" events. - (c) `preview_close` is NOT access-gated (assert no switch check on the close path) and an unknown ref answers `{ closed: false }` (tombstone). - - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap`. + - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap` (at THIS task only lanes consume the node-side budget; by the time Task 6's bridges land, the ONE counter is shared - spec §6's "16 per node" covers every lane and bridge of any mix, Task 6 Step 4 pins that). - (e) idle: injected small idle budget self-closes with `preview_closed` reason `idle`; target socket end emits `preview_closed` `target-gone` exactly once per lane; a `preview_close` that lands first suppresses the later EOF event (assert one emission total). - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. - - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. + - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word). without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer; `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. +- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer: expiry closes the lane `idle`, the same word Task 6's bridge twin pins (the closed set has no lane-only word). `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. - [ ] **Step 5: Green + commit** `feat(node): pending-ack forward lanes with off-chain dial and 1 MiB slow-link pump`. @@ -405,17 +405,17 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/cdp-bridge.ts` -- Modify: `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `apps/node/agent/src/commands/preview-close-exec.ts` (after the forward lane reports `closed: false`, consult the bridge registry: `stopBridge(ref, reason)` answers `closed: true`; unknown in both registries keeps the tombstone contract), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender; the link-drop hook that Task 5 wires `closeAll` into must ALSO call `bridgeCloseAll(reason)` - without this a continuous-repaint page screencasts forever after the plane closes the watch) +- Modify: `apps/node/agent/src/preview/forward.ts` (export `forwardCount()`, the open-lane count, for the shared node budget), `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `apps/node/agent/src/commands/preview-close-exec.ts` (after the forward lane reports `closed: false`, consult the bridge registry: `stopBridge(ref, reason)` answers `closed: true`; unknown in both registries keeps the tombstone contract), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender; the link-drop hook that Task 5 wires `closeAll` into must ALSO call `bridgeCloseAll(reason)` - without this a continuous-repaint page screencasts forever after the plane closes the watch) - Test: `apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts` **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. - Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the lifetime timer, the fps throttle, and the emit-boundary drop are all testable without a real browser or clock - the 60 s idle is deliberately NOT here: it is plane-side zero-subscriber state, Step 3 forbids a node-side traffic-idle); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart). +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }`. On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). -- [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, counts toward `PREVIEW_SESSIONS_PER_NODE`, result `{ ref, subscribers: 1 }` per Task 1's validator. +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (decision 8, a recorded deviation from spec §4's wording, which sketched "every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate": capture every repaint and do the rate reduction at the emit boundary instead. The link still carries at most 5 fps; one pin-able clamp (`clampFps`) replaces a second, untested N-sizing formula; dropped frames cost no base64 and no plane↔node bytes, which is the spec's own concern; and every capture gets its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one rather than an N-conditional variant). On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). +- [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, then the SHARED node budget: spec §6's "16 per node" is ONE counter over BOTH registries - sixteen of any mix - so the branch refuses `preview-watch-over-node-cap` BEFORE the CDP handshake when `forwardCount() + bridgeCount()` is at `PREVIEW_SESSIONS_PER_NODE` (a second per-registry cap would let one node hold 32 preview sessions on the limit the spec named, and a dial-then-refuse would leave a live CDP connection behind for a request that was never going to register; a refusal still names its own kind - a LANE squeezed out by bridges keeps saying `preview-lane-over-node-cap`). This needs the forward half's number, so `forward.ts` also exports `forwardCount()`. Result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. --- @@ -431,9 +431,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. - Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; generate the auth file with `xauth generate -f :NN .` and start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp`; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). +- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. - [ ] **Step 5: Sweep timer** beside daemon's other self-timers: hourly `display.sweepOrphans(alive pane ids)`, cloned from the ssh per-pane-dir retention pattern (`ssh-dir-retention` precedent; read it first). - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. @@ -449,10 +449,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so an absent `port` resolves to the pane's cached `cdpPort` BEFORE any check (spec §6's default), a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - the broker never imports the ws layer, so the structural type is the seam) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - that task is the broker's only bridge to `ws/live-registry.ts`, a VALUE import the structural type exists to keep the SINGLE crossing. The broker's own tests never touch it (an injected fan-out), and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -496,12 +496,12 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - relative `Location: /foo` returns prefixed `/pv//foo`; - an upgrade with a cross-site `Origin` is refused; the plane's own origin and a registry (published-network) origin are NOT, and an upgrade with no `Origin` header passes - `browserOriginAllowed` semantics, not plane-origin equality; - binary WS round trip byte-exact in `http` mode. Lane-bypass guard, stated precisely: the in-proc `Bun.serve` target DOES answer on plane loopback, so a client that bypassed the lane would still connect and pass a naive byte-equality check - the fake node therefore COUNTS the bytes it pipes, and the test asserts the handshake plus the round-trip payload all passed through the fake (bypassing the lane leaves the counter at zero even though the response looks perfect); - - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); + - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); that raw target then dies and the broker fake reports the lane closed `target-gone`: the browser WS's recorded `close` event must carry code 1011 with reason exactly `target-gone` (the word is the contract Task 13's disconnect handling consumes - a close without it passes every pump assertion here while the UI can only say "connection lost"); - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success) - `pv-ws.ts` takes an options `{ preAckMaxBytes? }` defaulting to the constant so the test drives the boundary small, the same seam doctrine as Task 5's `laneBufferMaxBytes`. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store the rewritten value in `session.cookieJar` keyed by cookie name (same name replaces; per-path keying inside the jar is a v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the six verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. +- [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store ONLY the rewritten `name=value` pair - never the attributes - in `session.cookieJar` keyed by cookie name (the jar's values ARE the pairs attached to later requests, so a value still wearing `Path=` or `Expires=` would ship as a bogus cookie; same name replaces, and name-only keying with last-write-wins across paths is the deliberate v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the six verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -509,7 +509,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: the `sendToSockets` parameter's DEFAULT is live-registry's - this is where Task 8's injectable seam meets the real sockets), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: Task 11 supplies the real fan-out and wires it where the broker is constructed - live-registry's send loop; this is Task 8's injectable seam meeting the real sockets, and the broker's ONLY value crossing to the ws layer - unconfigured it never fans out, and the broker itself imports nothing from `ws/`), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** @@ -534,7 +534,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7; the spec's request-log parenthetical was an erratum - throttle is the requirement). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). - [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). @@ -548,7 +548,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Modify: `apps/server/web/package.json` (`bun add @novnc/novnc` -> `1.7.0`, `syncpack fix`, `bun install`) - Create: `apps/server/web/src/components/preview/screen-viewer.tsx` - Modify: `preview-section.tsx` (Screen action when `info.screen` and `edit`: a forward-open against `info.screen.rfbPort`, then the viewer in the panel) -- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the `@novnc/novnc` module: `mock.module` per bun; assert the constructed URL is the session's WS tunnel root `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render a human line each: `slow-link`, `revoked`, `viewer-left`, `target-gone`). +- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the `@novnc/novnc` module: `mock.module` per bun; assert the constructed URL is the session's WS tunnel root `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render a human line each: `slow-link`, `revoked`, `viewer-left`, `target-gone` - and the test FEEDS those words the way they arrive: the close event pv-ws emits (code 1011 + the reason word, Task 10's wire shape), not a hand-built reason prop, and asserts the rendered line names the cause (`revoked` must not read as generic "connection lost"). **Interfaces:** Consumes: the forward lane (Screen REUSES it - zero new protocol); produces `screen-viewer.tsx`. From e65e086d3b0727e366eec9fd6dfa29e5597533bf Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 15:17:22 -0700 Subject: [PATCH 019/139] Fold decomposition round-8 review into the plan --- .../plans/2026-10-21-pane-preview.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 02ab1d8f5..57864dfbd 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -388,14 +388,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (a) ack is `{ ok: true, data: { ref, pending: true } }` and returns BEFORE the dial settles: assert the dispatch of `preview_open` against an address that never answers (a non-routable loopback port with a held connect via injected `connect`) does not block a following command on the fake chain, and the eventual connect failure emits `preview_closed` `target-gone` - the A-branch contract tested the way `commands-ssh-relay.test.ts` tests it. - (b) connected lane echoes: inbound `preview_data` dir "in" reaches the socket, socket bytes leave as `preview_data` dir "out" events. - (c) `preview_close` is NOT access-gated (assert no switch check on the close path) and an unknown ref answers `{ closed: false }` (tombstone). - - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap` (at THIS task only lanes consume the node-side budget; by the time Task 6's bridges land, the ONE counter is shared - spec §6's "16 per node" covers every lane and bridge of any mix, Task 6 Step 4 pins that). + - (d) quotas: the 17th concurrent lane refuses `preview-lane-over-node-cap`, per-pane the 5th `preview-lane-over-pane-cap` (at THIS task only lanes consume the node-side budget; by the time Task 6's bridges land, the ONE counter is shared - spec §6's "16 per node" covers every lane and bridge of any mix, and Task 6's two-direction shared-budget case pins both halves once the bridge count exists to sum). - (e) idle: injected small idle budget self-closes with `preview_closed` reason `idle`; target socket end emits `preview_closed` `target-gone` exactly once per lane; a `preview_close` that lands first suppresses the later EOF event (assert one emission total). - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. - - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word). without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. + - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word); (k) pane liveness: with the fake context's pane registry reporting the `paneId` absent, `preview_open` refuses `preview-pane-gone` BEFORE registration or dial - the fake `connect` must never run and no lane may appear in the registry (the forward twin of Task 4's pinned ports refusal: a dead pane's port can be rebound by a stranger process, and a lane dialed onto that is indistinguishable from dialing another pane's server; pinned gate-first the way (g) pins the switch twin). without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer: expiry closes the lane `idle`, the same word Task 6's bridge twin pins (the closed set has no lane-only word). `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. +- [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer: expiry closes the lane `idle`, the same word Task 6's bridge twin pins (the closed set has no lane-only word). `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. `preview-open-exec.ts` (created by this task, named in Files) is the command's gate ladder, in order: the mirror switch first (`preview-node-off`, case (g) pins it), then pane liveness - a `paneId` absent from the context's pane registry refuses `preview-pane-gone` before any registration (case (k)), then the per-pane and per-node quotas (case (d)) - every refusal at this ladder, so no mechanism starts for a request that was never going to register. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. - [ ] **Step 5: Green + commit** `feat(node): pending-ack forward lanes with off-chain dial and 1 MiB slow-link pump`. @@ -405,17 +405,17 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/cdp-bridge.ts` -- Modify: `apps/node/agent/src/preview/forward.ts` (export `forwardCount()`, the open-lane count, for the shared node budget), `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch), `apps/node/agent/src/commands/preview-close-exec.ts` (after the forward lane reports `closed: false`, consult the bridge registry: `stopBridge(ref, reason)` answers `closed: true`; unknown in both registries keeps the tombstone contract), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender; the link-drop hook that Task 5 wires `closeAll` into must ALSO call `bridgeCloseAll(reason)` - without this a continuous-repaint page screencasts forever after the plane closes the watch) +- Modify: `apps/node/agent/src/preview/forward.ts` (export `forwardCount()`, the open-lane count, for the shared node budget), `apps/node/agent/src/commands/preview-open-exec.ts` (the `screencast` branch AND the forward branch's node-cap check, which becomes the shared sum once bridges exist - Step 4), `apps/node/agent/src/commands/preview-close-exec.ts` (after the forward lane reports `closed: false`, consult the bridge registry: `stopBridge(ref, reason)` answers `closed: true`; unknown in both registries keeps the tombstone contract), `daemon.ts` (emit `preview_stream`/`preview_closed` via the same sender; the link-drop hook that Task 5 wires `closeAll` into must ALSO call `bridgeCloseAll(reason)` - without this a continuous-repaint page screencasts forever after the plane closes the watch) - Test: `apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts` **Interfaces:** - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. - Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the lifetime timer, the fps throttle, and the emit-boundary drop are all testable without a real browser or clock - the 60 s idle is deliberately NOT here: it is plane-side zero-subscriber state, Step 3 forbids a node-side traffic-idle); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer). +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer; AND the other direction, because the budget is one number, not two habits: with `bridgeCount()` faked at 15, a lane open fills slot 16 and the NEXT lane is refused `preview-lane-over-node-cap` before its fake `connect` runs (this case owns the lane-side sum check that Task 5 could not have written - no bridge registry existed when its executor coded the branch - so nothing here may skip it and leave half the cap silently doubled). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (decision 8, a recorded deviation from spec §4's wording, which sketched "every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate": capture every repaint and do the rate reduction at the emit boundary instead. The link still carries at most 5 fps; one pin-able clamp (`clampFps`) replaces a second, untested N-sizing formula; dropped frames cost no base64 and no plane↔node bytes, which is the spec's own concern; and every capture gets its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one rather than an N-conditional variant). On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). -- [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, then the SHARED node budget: spec §6's "16 per node" is ONE counter over BOTH registries - sixteen of any mix - so the branch refuses `preview-watch-over-node-cap` BEFORE the CDP handshake when `forwardCount() + bridgeCount()` is at `PREVIEW_SESSIONS_PER_NODE` (a second per-registry cap would let one node hold 32 preview sessions on the limit the spec named, and a dial-then-refuse would leave a live CDP connection behind for a request that was never going to register; a refusal still names its own kind - a LANE squeezed out by bridges keeps saying `preview-lane-over-node-cap`). This needs the forward half's number, so `forward.ts` also exports `forwardCount()`. Result `{ ref, subscribers: 1 }` per Task 1's validator. +- [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, then the SHARED node budget: spec §6's "16 per node" is ONE counter over BOTH registries - sixteen of any mix - so the sum check is the EXEC's gate on BOTH branches, before either mechanism starts (the lane path consults `bridgeCount()`, the bridge path consults `forwardCount()`; a sum check that lives only inside the screencast branch is how 32 total sessions come back - Task 5's lane-only check gets AMENDED here, not duplicated): `preview-watch-over-node-cap` BEFORE the CDP handshake, `preview-lane-over-node-cap` BEFORE registration or dial, whenever `forwardCount() + bridgeCount()` is at `PREVIEW_SESSIONS_PER_NODE` (a second per-registry cap would let one node hold 32 preview sessions on the limit the spec named, and a dial-then-refuse would leave a live CDP connection behind for a request that was never going to register; a refusal still names its own kind). The lane-side amendment and the `forwardCount()` export `forward.ts` gains are owned by this task's Files - Step 1's two-direction case pins both halves. Result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. --- @@ -444,15 +444,15 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/server/api/src/services/preview-broker.ts`, `apps/server/api/src/services/nodes/preview-frames.ts` (the plane->node byte-frame sender: lane bytes do NOT ride `sendCommand` - `PreviewDataFrame` is a link frame, not a command; clone `sendRelayFrameOverNodeSocket` at `ssh-relay.service.ts:651` (getLive + `conn.link.sealFrame` + deliver-or-throw) and read it first), `apps/server/api/src/services/__tests__/preview-broker.test.ts` -- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that; do NOT wire the fourth preview event `preview_enabled` here - that one belongs to Task 3's arm and drives the switch reconciler, not the broker), `services/nodes/__tests__/node-ws-handler.test.ts` (the three preview dispatch rows from Step 1, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arm live in sibling files), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook (mirror how the ssh broker reacts to node disconnects), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket; AFTER the shares write it calls `broker.closeUnauthorizedForPane(paneId)`, mirroring how `set-node-shares.route.ts:51` runs the relay broker's `closeUnauthorizedForNode` after its own write rather than diffing events). +- Modify: `services/nodes/node-ws-handler.ts` (the event switch gains arms for `preview_data` / `preview_stream` / `preview_closed`, cloning the `case "relay":` arm at :1196 that delegates to relay-frames.ts - this switch has no default branch, so THIS edit is what puts the intake on the wire; the relay-frames sibling below is unreachable without it, and a unit test that calls the sibling directly cannot see that; do NOT wire the fourth preview event `preview_enabled` here - that one belongs to Task 3's arm and drives the switch reconciler, not the broker), `services/nodes/__tests__/node-ws-handler.test.ts` (the preview dispatch rows from Step 1 - three event arms plus the `handleNodeClose` row, in the file's existing `ssh_enabled` style at :944 - declared here because the dispatch test and the production arms live in sibling files, and a broker test that calls the verbs directly cannot see either wiring), `services/nodes/relay-frames.ts` (route `preview_data` -> lane pump, `preview_stream` -> `onStreamEvent`, `preview_closed` -> lane/bridge close - beside its existing `getRelayBroker().routeRelayFrame(nodeId, frame)` call), the node link-drop hook INSIDE `handleNodeClose` (`node-ws-handler.ts:1245`, where the relay's `a-dropped` witness already runs - call `getPreviewBroker().closeAllForNode(nodeId, "node-dropped")` beside it, mirroring how the ssh broker reacts to node disconnects; an unhooked broker leaves stale sessions and lanes plane-side against refs the node forgot), `api/users/set-user-disabled.route.ts` + `set-user-role.route.ts` (broker revoke cuts beside the existing `dropLiveSocketsFor` calls - those routes carry no relay cut today, the relay's revocation pattern lives in `set-node-shares.route.ts:51` / `ssh-enabled.ts:43` and is a different axis), and `services/subshells.service.ts` (inside `setShares`, beside the `subshell.shares-changed` `publishLive` at :1245 - the route delegates there and touches no socket; AFTER the shares write it calls `broker.closeUnauthorizedForPane(paneId)`, mirroring how `set-node-shares.route.ts:51` runs the relay broker's `closeUnauthorizedForNode` after its own write rather than diffing events). - Test: `apps/server/api/src/api/users/__tests__/users-management.test.ts` (Step 1's wire cases in its `disable` describe at :403 and `role assignment` describe at :153) + that task's new `preview-broker.test.ts` case around the shares write. - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward, awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - that task is the broker's only bridge to `ws/live-registry.ts`, a VALUE import the structural type exists to keep the SINGLE crossing. The broker's own tests never touch it (an injected fan-out), and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - that task is the broker's only bridge to `ws/live-registry.ts`, a VALUE import the structural type exists to keep the SINGLE crossing. The broker's own tests never touch it (an injected fan-out), and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -570,4 +570,4 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). - [ ] **Step 4: Changeset + full boundary:** `bunx changeset` - `@internal/server` minor + `@internal/node` minor (the Files line lands HERE because no other step touches it; never hand-edit CHANGELOGs, and stage the generated file by path). Then `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. -- [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac). +- [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac, phone layout - Open/Screen hidden and Watch still there, the spec §7 rule Task 12 Step 3 ships but no test drives). From 6f413028beff3cda8fc478922965b2c7892f747a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 15:24:35 -0700 Subject: [PATCH 020/139] Fold correctness round-8 review into the plan --- .../plans/2026-10-21-pane-preview.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 57864dfbd..f20612c52 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -338,11 +338,11 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported` and on switch-off with `preview-node-off` (the same word Tasks 5 and 7 refuse with, so Task 7's "every entry point refuses with one voice" is a fact the executor test pins, not a claim); command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. - Consumes: pane pid (above), display registry (Task 7; before it exists, `screen` is null). -- [ ] **Step 1: Failing unit tests** over a fixture `/proc` in a temp dir (write `root//stat` with the ppid field, `root//cmdline`, `root//fd/N` symlinks whose target is the string `socket:[INODE]`, and `root/net/tcp` + `net/tcp6` LISTEN rows with the inode column): +- [ ] **Step 1: Failing unit tests** over a fixture `/proc` in a temp dir (write `root//stat` with the ppid field, `root//cmdline`, `root//fd/N` symlinks whose target is the string `socket:[INODE]`, and `root/net/tcp` + `net/tcp6` LISTEN rows with the inode column). THREE roots, not one: `tinyProcRoot` holds exactly ONE attributed LISTEN (tcp 3000, pid 102), because test 1's exact-list assertion needs a root nothing else is in; `procRoot` is the full fixture (the 3000 row, an attributed 9222 for the CDP probe, an attributed tcp6 4321, and outsider 8080 owned by a pid OUTSIDE the pane tree); `emptyProcRoot` has no listeners. One shared root cannot serve all five: an attributed 9222 or 4321 makes test 1's `toEqual` fail, and a root without them makes tests 2 and 5 unanswerable - the split is what keeps "nothing else appears in the list" a live assertion): ```ts test("attributes a listening socket to the deepest descendant that owns it", async () => { - const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); + const r = await scanPanePorts({ procRoot: tinyProcRoot, panePid: 100, probe: async () => false }); expect(r.ports).toEqual([{ proto: "tcp", port: 3000, pid: 102 }]); }); test("cdpPort is the LISTEN port whose /json/version probe answers", async () => { @@ -393,7 +393,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. - - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close; (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word); (k) pane liveness: with the fake context's pane registry reporting the `paneId` absent, `preview_open` refuses `preview-pane-gone` BEFORE registration or dial - the fake `connect` must never run and no lane may appear in the registry (the forward twin of Task 4's pinned ports refusal: a dead pane's port can be rebound by a stranger process, and a lane dialed onto that is indistinguishable from dialing another pane's server; pinned gate-first the way (g) pins the switch twin). without it the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit. + - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close (without the guard the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit); (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word); (k) pane liveness: with the fake context's pane registry reporting the `paneId` absent, `preview_open` refuses `preview-pane-gone` BEFORE registration or dial - the fake `connect` must never run and no lane may appear in the registry (the forward twin of Task 4's pinned ports refusal: a dead pane's port can be rebound by a stranger process, and a lane dialed onto that is indistinguishable from dialing another pane's server; pinned gate-first the way (g) pins the switch twin). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer: expiry closes the lane `idle`, the same word Task 6's bridge twin pins (the closed set has no lane-only word). `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. `preview-open-exec.ts` (created by this task, named in Files) is the command's gate ladder, in order: the mirror switch first (`preview-node-off`, case (g) pins it), then pane liveness - a `paneId` absent from the context's pane registry refuses `preview-pane-gone` before any registration (case (k)), then the per-pane and per-node quotas (case (d)) - every refusal at this ladder, so no mechanism starts for a request that was never going to register. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. @@ -431,11 +431,11 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. - Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone) - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`; mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). +- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written once both children are up - the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. -- [ ] **Step 5: Sweep timer** beside daemon's other self-timers: hourly `display.sweepOrphans(alive pane ids)`, cloned from the ssh per-pane-dir retention pattern (`ssh-dir-retention` precedent; read it first). +- [ ] **Step 5: Sweep timer + boot hydration** beside daemon's other self-timers: `display.sweepOrphans(alive pane ids)` runs once at daemon start, then hourly. It is a DISK WALK cloned from the ssh per-pane-dir retention pattern (read it first), not only a registry loop - for each `/displays//` dir and its `.display-NN.lock`: pane NOT alive → kill the recorded pids if still running, remove dir and lock, freeing `:NN` (spec §5's "the sweep catches what a crash orphaned" names this disk pass; a registry-only loop catches nothing after a daemon restart, because the registry IS what the restart cleared); pane alive but unregistered (exactly what a node-daemon restart leaves) → rehydrate the registry from `display.json`, so `getPorts.screen` - decision 6's rfbPort discriminator - and per-pane idempotent `openFor` survive the restart instead of stranding one `:90..:199` id per screen-pane and darkening the Screen affordance on a pane that still has it. - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. --- @@ -449,12 +449,12 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing and close that lane `over-cap` with its audit - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - that task is the broker's only bridge to `ws/live-registry.ts`, a VALUE import the structural type exists to keep the SINGLE crossing. The broker's own tests never touch it (an injected fan-out), and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md` (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -509,7 +509,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: Task 11 supplies the real fan-out and wires it where the broker is constructed - live-registry's send loop; this is Task 8's injectable seam meeting the real sockets, and the broker's ONLY value crossing to the ws layer - unconfigured it never fans out, and the broker itself imports nothing from `ws/`), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: THIS edit site calls `configurePreviewStreamSender` with live-registry's send loop, before any frame can attach a socket - `ws/live-ws.ts` is thereby the broker's ONLY bridge to the ws layer, a single VALUE import the structural type keeps minimal; unconfigured the broker never fans out, and `preview-broker.ts` itself imports nothing from `ws/`), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** @@ -564,7 +564,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `e2e/tests/47-preview.spec.ts` (testDir is `./tests` per `playwright.config.ts:14`, and suites are numbered - next free number after `46-server-filters`; a root-level `e2e/preview.spec.ts` would never be collected), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). -- Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). +- Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_lane.over_cap` (the plane's sender-cap lane death, the only lane-level action - the session survives it) and `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. - [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). From 68b80a9100ff0e7fa85b3caaba8e6509bdd0d966 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 15:46:51 -0700 Subject: [PATCH 021/139] Fold review round-9 into the plan Union fold of round 9's decomposition pair (0/2/2) and correctness pair (0/1/3), both pinned to b4ad882fe, plus the six held round-8 re-review findings (H1-H6). The wiring clause moves onto live-ws.ts and preview-broker.ts is named NOT-an-edit-site; Task 2's barrel export gets its step; the screencast branch pins its pane-gone rung; display.json is written incrementally and boot rehydrate is conditional on bindProbe; deps gains kill; the switch-off screen-launch refusal moves to a failing step; Task 13 feeds viewer-left the way it actually arrives. --- .../plans/2026-10-21-pane-preview.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index f20612c52..098b7e902 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -301,7 +301,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - [ ] **Step 1: Failing tests** in `live-frames.test.ts`: `parseLiveClientFrame` accepts `{"type":"watch","id":"s1"}` and `{"type":"unwatch","id":"s1"}`; rejects a missing or non-string `id`; rejects unknown client types exactly as its current cases do (read the file's existing style first and clone it). - [ ] **Step 2: Run, watch them fail** (`env -u SHELLOPTS -u BASHOPTS bun test packages/subshell-protocol/src/__tests__/live-frames.test.ts`). -- [ ] **Step 3: Implement** the union members and parse arms - the stream member is the exported `PreviewStreamFrame` interface used by name in the union. JSDoc: the stream frame is published only to sockets the broker has verified hold `view` on the row; it carries no `ref` on purpose. +- [ ] **Step 3: Implement** the union members and parse arms - the stream member is the exported `PreviewStreamFrame` interface used by name in the union, and add `type PreviewStreamFrame` to the `src/index.ts` live-frames block (:73-76): the Files list names the barrel and this is the step that lands it - the package tests import relatively, so nothing else catches its absence until Tasks 8 and 11 cannot compile the contract. JSDoc: the stream frame is published only to sockets the broker has verified hold `view` on the row; it carries no `ref` on purpose. - [ ] **Step 4: Package tests, turbo build, commit** `feat(protocol): live watch subscriptions and preview-stream frames`. --- @@ -367,7 +367,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `ports.ts`:** one pass (spec §5: stateless), parse `net/tcp`/`net/tcp6` state `0A`, hex `local_address:port`, collect inodes; ppid map from `/stat`; BFS descendants of `panePid` (cap: 512 pids, refusal `preview-ports-overcap`); readlink `fd/*`, intersect, deepest descendant per inode; CDP probe each listed port with the injected default `GET http://127.0.0.1:/json/version` (400 ms, `AbortSignal.timeout`, accept when the JSON has a `Browser` field); non-Linux refuses BEFORE touching fs. `procRoot` and `probe` exist only for tests. -- [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses `preview-node-off` naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with the display registry's `screen` for that pane. +- [ ] **Step 4: Executor arm:** gate-first (switch OFF refuses `preview-node-off` naming the remedy "turn on Preview for this node"; dead pane refuses `preview-ports-pane-gone`); result = scan output with `screen: null` for that pane (Task 7's registry amends this arm later - per this task's Consumes note the registry does not exist yet). - [ ] **Step 5: Green + commit** `feat(node): attribute pane subtrees' listening sockets, probing CDP per spec`. --- @@ -393,7 +393,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - (f) slow-link: with `PREVIEW_LANE_BUFFER_MAX_BYTES` overridden small, an overflowing outbound pump closes with `slow-link`. - (g) gate: with the preview mirror OFF, `preview_open` (forward branch) is refused `preview-node-off` BEFORE any dial or registration - the positive half of the switch this file's own precedent pins for `ssh_relay_open` at `commands-ssh-relay.test.ts:127` (read that suite's context fake first and reuse its shape); (c) already pins the close-path negative twin at `:133`. - (h) `closeAll("node-dropped")` - the daemon link-drop hook Step 3 wires - closes every open lane and emits exactly ONE `preview_closed` per lane, lanes empty afterward: Task 6 pins the bridge twin (`bridgeCloseAll`), and without this case the forward half of the link-drop hook is prose a flapping link would expose. - - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close (without the guard the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit); (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute 15-minute expiry shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word); (k) pane liveness: with the fake context's pane registry reporting the `paneId` absent, `preview_open` refuses `preview-pane-gone` BEFORE registration or dial - the fake `connect` must never run and no lane may appear in the registry (the forward twin of Task 4's pinned ports refusal: a dead pane's port can be rebound by a stranger process, and a lane dialed onto that is indistinguishable from dialing another pane's server; pinned gate-first the way (g) pins the switch twin). + - (i) one oversized read closes without emitting: the fake target socket hands the lane ONE `data` event past `PREVIEW_FRAME_MAX_BYTES` (a fake socket can hand any chunk it likes; net's 64 KiB default is production behavior, not the harness's), and the lane must send zero `preview_data`, close `over-cap`, and emit exactly one `preview_closed` - Task 1's blob-cap test pins the receiver's parser refusing this blob, and this is the sender half spec §6 assigns the named close (without the guard the oversized blob would reach `parsePreviewFrame`, parse to null, and be dropped mid-TCP-stream with no close and no audit); (j) lifetime: an injected `timer`/small `lifetimeMs` fires `preview_closed` `idle` exactly once per lane (Step 3's `lifetimeMs` seam exists unused unless this case consumes it - the absolute lifetime expiry (the plane rides every broker lane at `PREVIEW_LIFETIME_MAX_MS`) shares `idle` with (e)'s traffic-idle, because the closed set has no lane-only word; Task 6's bridge twin pins the same word); (k) pane liveness: with the fake context's pane registry reporting the `paneId` absent, `preview_open` refuses `preview-pane-gone` BEFORE registration or dial - the fake `connect` must never run and no lane may appear in the registry (the forward twin of Task 4's pinned ports refusal: a dead pane's port can be rebound by a stranger process, and a lane dialed onto that is indistinguishable from dialing another pane's server; pinned gate-first the way (g) pins the switch twin). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `forward.ts`:** map ref->lane. Register + ack first (A-branch), then dial `net.connect({ host: "127.0.0.1", port })`, retry once on `::1`, both failures = `target-gone` (loopback literals only - the wire carries a port, never a host). Socket data -> base64 -> `preview_data` out event via the relay pump (deliver-or-throw); an oversized read (raw bytes past `PREVIEW_FRAME_MAX_BYTES`) is NEVER emitted: it closes the lane `over-cap` (net sockets default to 64 KiB chunks so this is a guard and not a routine - but the receiving parser refuses an over-cap blob and the daemon drops what will not parse, so an unchecked sender would corrupt the lane silently; spec §6's sender rule is a named close, never fragmentation); unaccepted bytes accumulate and past `PREVIEW_LANE_BUFFER_MAX_BYTES` close `slow-link` (spec §5's number). `ingestInboundFrame` (dir "in") writes the socket; both directions count as idle-timer activity. Every death path deregisters, then emits exactly one `preview_closed`. Lifetime timer: expiry closes the lane `idle`, the same word Task 6's bridge twin pins (the closed set has no lane-only word). `closeAll` on daemon link-drop. Result-before-teardown and the seam-cast posture copied from `ssh-relay-exec.ts` verbatim. `preview-open-exec.ts` (created by this task, named in Files) is the command's gate ladder, in order: the mirror switch first (`preview-node-off`, case (g) pins it), then pane liveness - a `paneId` absent from the context's pane registry refuses `preview-pane-gone` before any registration (case (k)), then the per-pane and per-node quotas (case (d)) - every refusal at this ladder, so no mechanism starts for a request that was never going to register. - [ ] **Step 4: Wire intake:** in `daemon.ts` beside the `parseRelayFrame` call site, add `parsePreviewFrame` -> `ctx.previewForward.ingestInboundFrame`; `preview_close` arm calls `closeLane` unconditionally. @@ -412,7 +412,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `PreviewOpenCommand` (`kind: "screencast"`), `preview_stream` + `preview_closed` NodeEvents, `PREVIEW_WATCH_*`. - Produces: `startBridge({ ref, cdpPort, fps, lifetimeMs, emitStream, emitClosed, deps }): Promise<{ subscribers: number }>` (`deps` carries `{ fetch?, WebSocketImpl?, now?, timer? }` so the handshake, the lifetime timer, the fps throttle, and the emit-boundary drop are all testable without a real browser or clock - the 60 s idle is deliberately NOT here: it is plane-side zero-subscriber state, Step 3 forbids a node-side traffic-idle); `stopBridge(ref, reason): boolean` (false when the ref is not a bridge, so the close executor can fall through); `bridgeCloseAll(reason)`; `bridgeCount()`; `clampFps(fps: number): number` (pure, exported for its test). Handshake budget 3 s; a port that fails `GET /json/list` or the budget closes with `not-cdp`. Emission contract (Task 11 leans on it): one `preview_stream` per emitted frame, `seq` monotonic per ref, `w/h` from frame metadata, `ts = Date.now()`, `blob` the base64 JPEG. -- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer; AND the other direction, because the budget is one number, not two habits: with `bridgeCount()` faked at 15, a lane open fills slot 16 and the NEXT lane is refused `preview-lane-over-node-cap` before its fake `connect` runs (this case owns the lane-side sum check that Task 5 could not have written - no bridge registry existed when its executor coded the branch - so nothing here may skip it and leave half the cap silently doubled). +- [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin), and with the fake context's pane registry reporting the `paneId` absent the screencast branch is refused `preview-pane-gone` with ZERO requests reaching the fake CDP server (Step 4's ladder names the rung; Task 5's case (k) is its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer; AND the other direction, because the budget is one number, not two habits: with `bridgeCount()` faked at 15, a lane open fills slot 16 and the NEXT lane is refused `preview-lane-over-node-cap` before its fake `connect` runs (this case owns the lane-side sum check that Task 5 could not have written - no bridge registry existed when its executor coded the branch - so nothing here may skip it and leave half the cap silently doubled). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (decision 8, a recorded deviation from spec §4's wording, which sketched "every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate": capture every repaint and do the rate reduction at the emit boundary instead. The link still carries at most 5 fps; one pin-able clamp (`clampFps`) replaces a second, untested N-sizing formula; dropped frames cost no base64 and no plane↔node bytes, which is the spec's own concern; and every capture gets its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one rather than an N-conditional variant). On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). - [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, then the SHARED node budget: spec §6's "16 per node" is ONE counter over BOTH registries - sixteen of any mix - so the sum check is the EXEC's gate on BOTH branches, before either mechanism starts (the lane path consults `bridgeCount()`, the bridge path consults `forwardCount()`; a sum check that lives only inside the screencast branch is how 32 total sessions come back - Task 5's lane-only check gets AMENDED here, not duplicated): `preview-watch-over-node-cap` BEFORE the CDP handshake, `preview-lane-over-node-cap` BEFORE registration or dial, whenever `forwardCount() + bridgeCount()` is at `PREVIEW_SESSIONS_PER_NODE` (a second per-registry cap would let one node hold 32 preview sessions on the limit the spec named, and a dial-then-refuse would leave a live CDP connection behind for a request that was never going to register; a refusal still names its own kind). The lane-side amendment and the `forwardCount()` export `forward.ts` gains are owned by this task's Files - Step 1's two-direction case pins both halves. Result `{ ref, subscribers: 1 }` per Task 1's validator. @@ -429,13 +429,13 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. -- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. +- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe, kill }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent, and `kill` is the pid-reap seam Step 1's death path and Step 5's dead-dir pass fake - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone) - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone), and the pair's third leg: a LIVE pane whose recorded children are gone (fake `bindProbe` refuses the recorded rfbPort) is reaped, not rehydrated - a service-manager restart that takes the whole cgroup while tmux and the pane survive leaves exactly that record - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written once both children are up - the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). +- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written INCREMENTALLY so every crash window leaves the pids that exist - `{ xvfbPid }` alone once Xvfb is up, the full record once both are; the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. -- [ ] **Step 5: Sweep timer + boot hydration** beside daemon's other self-timers: `display.sweepOrphans(alive pane ids)` runs once at daemon start, then hourly. It is a DISK WALK cloned from the ssh per-pane-dir retention pattern (read it first), not only a registry loop - for each `/displays//` dir and its `.display-NN.lock`: pane NOT alive → kill the recorded pids if still running, remove dir and lock, freeing `:NN` (spec §5's "the sweep catches what a crash orphaned" names this disk pass; a registry-only loop catches nothing after a daemon restart, because the registry IS what the restart cleared); pane alive but unregistered (exactly what a node-daemon restart leaves) → rehydrate the registry from `display.json`, so `getPorts.screen` - decision 6's rfbPort discriminator - and per-pane idempotent `openFor` survive the restart instead of stranding one `:90..:199` id per screen-pane and darkening the Screen affordance on a pane that still has it. +- [ ] **Step 5: Sweep timer + boot hydration** beside daemon's other self-timers: `display.sweepOrphans(alive pane ids)` runs once at daemon start, then hourly. It is a DISK WALK cloned from the ssh per-pane-dir retention pattern (read it first), not only a registry loop - for each `/displays//` dir and its `.display-NN.lock`: pane NOT alive → kill the recorded pids if still running, remove dir and lock, freeing `:NN` (spec §5's "the sweep catches what a crash orphaned" names this disk pass; a registry-only loop catches nothing after a daemon restart, because the registry IS what the restart cleared); pane alive but unregistered (what a plain node-daemon restart leaves) → rehydrate ONLY if `bindProbe` still answers the recorded rfbPort: children alive, the registry entry comes back from `display.json`, so `getPorts.screen` - decision 6's rfbPort discriminator - and per-pane idempotent `openFor` survive the restart instead of stranding one `:90..:199` id per screen-pane and darkening the Screen affordance on a pane that still has it; children dead (a service-manager restart that took the cgroup while tmux and the pane survived) → REAP, not rehydrate: installing a record for a dead socket strands the Screen affordance on a pane whose display is gone, and `openFor` against it would refuse `preview-display-not-ready` forever. Step 1's restart pair pins both legs. - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. --- @@ -470,10 +470,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). -- [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. Test: a launch with `screen` on a switch-off node refuses without creating the pane row's process (assert no `launch` command was sent). +- [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. The Step-1 create-path case turns green here: the preflight's refusal means no pane process and no `launch` on the wire. - [ ] **Step 5: `bunx turbo build --filter=@internal/server` (Eden types), green, commit** `feat(api): preview REST surface, gated screen launches, and the view/edit authz matrix`. --- @@ -509,7 +509,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt), `services/preview-broker.ts` (production wiring: THIS edit site calls `configurePreviewStreamSender` with live-registry's send loop, before any frame can attach a socket - `ws/live-ws.ts` is thereby the broker's ONLY bridge to the ws layer, a single VALUE import the structural type keeps minimal; unconfigured the broker never fans out, and `preview-broker.ts` itself imports nothing from `ws/`), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (two things: the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt; and the module holds the broker's production wiring - at module init, before any frame can attach a socket, it calls the Task 8 setter `configurePreviewStreamSender(sendToSockets)`, imported from `./live-registry.js`, so THIS file is the broker's ONLY crossing to the ws layer - a single VALUE import the structural type keeps minimal; `services/preview-broker.ts` is NOT an edit site: it imports nothing from `ws/`, and unconfigured it never fans out), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** @@ -548,7 +548,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Modify: `apps/server/web/package.json` (`bun add @novnc/novnc` -> `1.7.0`, `syncpack fix`, `bun install`) - Create: `apps/server/web/src/components/preview/screen-viewer.tsx` - Modify: `preview-section.tsx` (Screen action when `info.screen` and `edit`: a forward-open against `info.screen.rfbPort`, then the viewer in the panel) -- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the `@novnc/novnc` module: `mock.module` per bun; assert the constructed URL is the session's WS tunnel root `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render a human line each: `slow-link`, `revoked`, `viewer-left`, `target-gone` - and the test FEEDS those words the way they arrive: the close event pv-ws emits (code 1011 + the reason word, Task 10's wire shape), not a hand-built reason prop, and asserts the rendered line names the cause (`revoked` must not read as generic "connection lost"). +- Test: `components/preview/__tests__/screen-viewer.test.tsx` (mock the `@novnc/novnc` module: `mock.module` per bun; assert the constructed URL is the session's WS tunnel root `${wsProto}//${location.host}${proxyBase}` and disconnect reasons render a human line each: `slow-link`, `revoked`, `viewer-left`, `target-gone` - and the test FEEDS each word the way it ACTUALLY arrives: every word except `viewer-left` rides the close event pv-ws emits (code 1011 + the reason word, Task 10's wire shape), while `viewer-left` NEVER rides a close (Task 10 closes the browser WS for anything EXCEPT the viewer's own leave) and arrives through the component's own disconnect path, which Step 2 pins; not a hand-built reason prop, and assert the rendered line names the cause (`revoked` must not read as generic "connection lost"). **Interfaces:** Consumes: the forward lane (Screen REUSES it - zero new protocol); produces `screen-viewer.tsx`. @@ -567,7 +567,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_lane.over_cap` (the plane's sender-cap lane death, the only lane-level action - the session survives it) and `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. -- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`), forward lanes still work with a hand-pinned port (spec §10's mac leg). +- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every mac-REFUSING preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`; the switch itself and `set_preview_enabled` SUCCEED there, and forward lanes must keep working, with a hand-pinned port - spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). - [ ] **Step 4: Changeset + full boundary:** `bunx changeset` - `@internal/server` minor + `@internal/node` minor (the Files line lands HERE because no other step touches it; never hand-edit CHANGELOGs, and stage the generated file by path). Then `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. - [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac, phone layout - Open/Screen hidden and Watch still there, the spec §7 rule Task 12 Step 3 ships but no test drives). From b149535bf0e97dfe50f4e42b829f61d71e4fde43 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 16:05:13 -0700 Subject: [PATCH 022/139] Fold review round-10 into the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Union fold of round 10's decomposition pair (0/4/3) and correctness pair (0/1/0); the badge gap was found independently by both. The spec §9 mid-pane display loss gains a real mechanism: the display module watches its spawned children, reaps on exit, and marks display.lostFor, so the ports answer grows one optional screenLost binary-name field threaded Tasks 1-4-7-9-12 (deliberately NOT folded into the info route's reason word). Also owned at last: Task 7's pane-runtime extraEnv test, the fps/ age badge, the allowedHosts doc sentence, Task 13's novnc install step, Task 3's 0062 migration test, and Task 11's ws.plugin.ts comment fix. --- .../plans/2026-10-21-pane-preview.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 098b7e902..2c703c3b6 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -92,7 +92,7 @@ - Test: `packages/subshell-protocol/src/__tests__/node-frames.test.ts` **Interfaces:** -- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`), `preview_enabled` (`{ on, changedAt }`, the mirror-drift event spec §4's bullet names - Task 3 is its consumer, not Task 8's broker); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. +- Produces (Tasks 3-11 import these names): `NODE_PROTOCOL_VERSION = 23`; `NodePreviewEnabledWire { on: boolean; changedAt: string }`; commands `PreviewOpenCommand` (`kind: "forward" | "screencast"`, optional `fps`), `PreviewCloseCommand`, `PreviewPortsGetCommand`, `SetPreviewEnabledCommand` (`{ type, on, changedAt }`, decision 5), `PreviewCloseReason` (nine members, Global Constraints); `PreviewLaneKind`; `PREVIEW_CLOSE_REASONS` (runtime list beside the union); link frame `PreviewDataFrame { type: "preview_data"; ref; dir: "in" | "out"; blob }` (NO seq - lane order is the link's order, spec §4's shape) + `parsePreviewFrame(raw: string | object): PreviewDataFrame | null`; `NodeEvent` members `preview_data`, `preview_stream` (`{ ref, seq, w, h, ts, blob }`), `preview_closed` (`{ ref, reason }`), `preview_enabled` (`{ on, changedAt }`, the mirror-drift event spec §4's bullet names - Task 3 is its consumer, not Task 8's broker); `launch` gains `screen?: { width: number; height: number }` (320..4096 both) and its result validator gains optional `screen?: { display: string; rfbPort: number }`; `preview_ports_get`'s answer validator accepts, beside `{ ports, cdpPort, screen }`, an optional `screenLost: "Xvfb" | "x11vnc"` (spec §9's mid-pane display loss is REPORTED, not silently dropped - Task 7's child-exit watch is its only writer, and `screen` is null whenever it rides); ready member `previewEnabled?: NodePreviewEnabledWire`; constants `PREVIEW_FRAME_MAX_BYTES`, `PREVIEW_LANE_BUFFER_MAX_BYTES`, `PREVIEW_WATCH_FPS_MIN`, `PREVIEW_WATCH_FPS_DEFAULT`, `PREVIEW_WATCH_FPS_MAX`, `PREVIEW_FPS_GRAMMAR_MAX`, `PREVIEW_WATCH_JPEG_QUALITY`, `PREVIEW_WATCH_FRAMES_BUFFERED`, `PREVIEW_FORWARD_SESSIONS_PER_PANE`, `PREVIEW_SESSIONS_PER_NODE`, `PREVIEW_WATCH_SUBSCRIBERS_MAX`, `PREVIEW_IDLE_MS`, `PREVIEW_LIFETIME_MIN_MS`, `PREVIEW_LIFETIME_MAX_MS`, `PREVIEW_LANE_QUEUE_MAX`, `PREVIEW_STREAM_DEBT_MAX` (per-viewer-socket queued-bytes ceiling on the live feed - 4 MiB: ~10 s of q60 screencast at the 5 fps cap, generous against Bun's own WS backpressure), `PREVIEW_REF_RE`. - Consumes: nothing. - [ ] **Step 1: Write the failing grammar tests.** Append to `node-frames.test.ts`, cloning the file's own style (the `relay` tests for frames, the hand-pinned `SSH_COMMAND_TYPES` census at :1105 for the census - write a `PREVIEW_COMMAND_TYPES` list with the four types and `expect(...).toHaveLength(4)` so the count is a tripwire, not a claim; follow the disk pattern on SHAPE: `SSH_COMMAND_TYPES` is a const-exported list (it lives at `ssh-frames.ts:375`, where its grammar lives, re-exported at the `src/index.ts:292` barrel and imported by name in the test) - do the same for `PREVIEW_COMMAND_TYPES`, exported from `node-frames.ts` where the preview grammar lives, re-exported through the barrel, test-imported, not test-local (the barrel is mobile's Metro surface: this adds no `node:*` import, so it is safe there). Add `parsePreviewFrame`, `PREVIEW_CLOSE_REASONS`, and every `PREVIEW_*` constant you reference to the file's existing named-import block: @@ -320,7 +320,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); node `commands/report.ts`: `reportPreviewEnabled(ctx, state)` + `maybeReportPreviewEnabled(ctx)`; plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`, and `reconcilePreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined): Promise` - clone `ssh-enabled.ts:174`'s direction rule verbatim: the row is the record, every disagreement answers with a push of the row's own value (that push IS the corruption repair), never an adopt. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; the 0062 migration test rides HERE (clone `migrations/__tests__/0047-node-ssh-enabled.test.ts`, which imports its migration module directly - so it goes red now, before Step 3 writes the file; the Files entry names it, this is its step); that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -335,7 +335,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Test: `apps/node/agent/src/__tests__/preview-ports.test.ts` **Interfaces:** -- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported` and on switch-off with `preview-node-off` (the same word Tasks 5 and 7 refuse with, so Task 7's "every entry point refuses with one voice" is a fact the executor test pins, not a claim); command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. +- Produces: `scanPanePorts(opts: { procRoot?: string; panePid: number; probe?: (port: number) => Promise; screen?: { display: string; rfbPort: number } | null }): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>`; refuses on non-Linux with `preview-ports-unsupported` and on switch-off with `preview-node-off` (the same word Tasks 5 and 7 refuse with, so Task 7's "every entry point refuses with one voice" is a fact the executor test pins, not a claim); command result validated by Task 1's `node-results` entry. The CDP probe default is a loopback `GET /json/version` with a 400 ms budget (spec §4); `probe` exists so tests fake it. The EXECUTOR's answer is scan's result plus the `screen` it passed in; `screenLost` (Task 1's validator) rides the ANSWER only, is never set by this task's arm, and appears from Task 7's wiring onward. - Consumes: pane pid (above), display registry (Task 7; before it exists, `screen` is null). - [ ] **Step 1: Failing unit tests** over a fixture `/proc` in a temp dir (write `root//stat` with the ppid field, `root//cmdline`, `root//fd/N` symlinks whose target is the string `socket:[INODE]`, and `root/net/tcp` + `net/tcp6` LISTEN rows with the inode column). THREE roots, not one: `tinyProcRoot` holds exactly ONE attributed LISTEN (tcp 3000, pid 102), because test 1's exact-list assertion needs a root nothing else is in; `procRoot` is the full fixture (the 3000 row, an attributed 9222 for the CDP probe, an attributed tcp6 4321, and outsider 8080 owned by a pid OUTSIDE the pane tree); `emptyProcRoot` has no listeners. One shared root cannot serve all five: an attributed 9222 or 4321 makes test 1's `toEqual` fail, and a root without them makes tests 2 and 5 unanswerable - the split is what keeps "nothing else appears in the list" a live assertion): @@ -424,16 +424,16 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Files:** - Create: `apps/node/agent/src/preview/display.ts` -- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise. Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `commands/context.ts` (the display registry handle the ports executor reads - same placement as the pane handle Task 4 added), `daemon.ts` (hourly orphan sweep), `apps/node/agent/src/commands/report.ts` (the pane-death hook path: `reportDeath` at :376 is where a supervised pane's death is reported and forgotten - read how it runs its hooks; spec §5 names it) +- Modify: `apps/node/agent/src/commands/launch.ts` (the `screen` path + the `extraEnv` pass-in at :209/:224), `packages/pane-runtime/src/launch.ts` (optional `extraEnv` layer on `paneEnvFor` and `assembleHarnessCommand`, appended last in the spread chain so a display value never loses to a preset), `commands/preview-ports-exec.ts` (Task 4 landed BEFORE this registry and hardwired `screen: null` per its own Consumes note; this is the wiring step: read `display.getFor(paneId)` - the registry handle sits in `commands/context.ts` beside the relay pair - and answer `screen: { display, rfbPort }` for a registered pane, `null` otherwise - and when `display.lostFor(paneId)` names a display whose child died mid-pane (Step 3's exit watch), answer `screenLost: ` with it; `screen` is null exactly then, so the SPA can say WHY the affordance is gone instead of silence (spec §9). Skip it and production `preview_ports_get` answers `screen: null` forever, decision 6's getPorts-first discriminator silently downgrades every post-plane-restart Screen open to `http` (re-handshaking raw RFB through the WS framer), and Task 14's container leg fails with no task owning the fix - Task 8's unit fakes bypass the node and stay green throughout), `commands/context.ts` (the display registry handle the ports executor reads - same placement as the pane handle Task 4 added), `daemon.ts` (hourly orphan sweep), `apps/node/agent/src/commands/report.ts` (the pane-death hook path: `reportDeath` at :376 is where a supervised pane's death is reported and forgotten - read how it runs its hooks; spec §5 names it) - Test: `apps/node/agent/src/__tests__/preview-display.test.ts`, `packages/pane-runtime/src/__tests__/launch.test.ts` (the extraEnv layer: pane sees DISPLAY/XAUTHORITY, the exit-hook env from :224 agrees, and with no display the chain is byte-identical to today) **Interfaces:** - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. -- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe, kill }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent, and `kill` is the pid-reap seam Step 1's death path and Step 5's dead-dir pass fake - "exists so tests fake it"), `display.getFor(paneId)`, `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. +- Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe, kill }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent, and `kill` is the pid-reap seam Step 1's death path and Step 5's dead-dir pass fake - "exists so tests fake it"), `display.getFor(paneId)`, `display.lostFor(paneId): "Xvfb" | "x11vnc" | null` (the dead-mid-pane marker the ports arm answers with - set by Step 3's child-exit watch, cleared by the next `openFor`; in-memory by design: after a daemon restart the marker is gone and the affordance simply disappears, safely, because Step 3's exit reaping already freed the lock and dir - the named-cause duty covers the live window spec §9 describes), `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone), and the pair's third leg: a LIVE pane whose recorded children are gone (fake `bindProbe` refuses the recorded rfbPort) is reaped, not rehydrated - a service-manager restart that takes the whole cgroup while tmux and the pane survive leaves exactly that record - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned. +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone), and the pair's third leg: a LIVE pane whose recorded children are gone (fake `bindProbe` refuses the recorded rfbPort) is reaped, not rehydrated - a service-manager restart that takes the whole cgroup while tmux and the pane survive leaves exactly that record - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned; the Test list's SECOND file gets its authoring here too - `packages/pane-runtime/src/__tests__/launch.test.ts` gains the extraEnv trio: with `extraEnv` the chain puts DISPLAY/XAUTHORITY last (a display value beats the preset layer), `assembleHarnessCommand`'s exit-hook call (agent `launch.ts:224`) carries the same layer, and with NO extraEnv the assembled chain equals today's four-layer spread byte-for-byte (Step 4's preservation claim gets a pin or it is a claim); and a REGISTERED display whose child exits (fake `spawn` handle fires its exit) marks `lostFor` with that binary's name, reaps lock and dir through the same teardown `closeFor` runs, leaves the pane's row untouched, and clears the marker on the next `openFor` (spec §9's mid-pane loss; see Step 3's watch). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written INCREMENTALLY so every crash window leaves the pids that exist - `{ xvfbPid }` alone once Xvfb is up, the full record once both are; the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). +- [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written INCREMENTALLY so every crash window leaves the pids that exist - `{ xvfbPid }` alone once Xvfb is up, the full record once both are; the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). Child-exit watch (spec §9): keep the handles the injected `spawn` returned; when either child exits while the display is still REGISTERED (a mid-pane vanish - the daemon's own teardown paths kill children THROUGH `closeFor`/sweep and must not re-enter as a loss), mark `lostFor`, reap the lock and dir through the same teardown, log the binary's name, and leave the pane alone: its DISPLAY env points at a dead server from then on, its terminal keeps streaming, and the ports answer names the loss. - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. - [ ] **Step 5: Sweep timer + boot hydration** beside daemon's other self-timers: `display.sweepOrphans(alive pane ids)` runs once at daemon start, then hourly. It is a DISK WALK cloned from the ssh per-pane-dir retention pattern (read it first), not only a registry loop - for each `/displays//` dir and its `.display-NN.lock`: pane NOT alive → kill the recorded pids if still running, remove dir and lock, freeing `:NN` (spec §5's "the sweep catches what a crash orphaned" names this disk pass; a registry-only loop catches nothing after a daemon restart, because the registry IS what the restart cleared); pane alive but unregistered (what a plain node-daemon restart leaves) → rehydrate ONLY if `bindProbe` still answers the recorded rfbPort: children alive, the registry entry comes back from `display.json`, so `getPorts.screen` - decision 6's rfbPort discriminator - and per-pane idempotent `openFor` survive the restart instead of stranding one `:90..:199` id per screen-pane and darkening the Screen affordance on a pane that still has it; children dead (a service-manager restart that took the cgroup while tmux and the pane survived) → REAP, not rehydrate: installing a record for a dead socket strands the Screen affordance on a pane whose display is gone, and `openFor` against it would refuse `preview-display-not-ready` forever. Step 1's restart pair pins both legs. - [ ] **Step 6: Green + commit** `feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane`. @@ -468,9 +468,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. -- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). +- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, screenLost, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `screenLost` is the binary name when the pane's display died mid-pane - Task 7's marker arrives on the ports answer and passes through named, never inferred, with `screen: null` alongside it (spec §9), and the info schema declares the member as the optional union it is); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads); a node fake answering `screenLost: "x11vnc"` with `screen: null` passes BOTH through unchanged (the named-cause pair spec §9 demands - a route that swallowed the marker would render silence). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. The Step-1 create-path case turns green here: the preflight's refusal means no pane process and no `launch` on the wire. @@ -518,7 +518,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected); byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. - [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives); a mid-stream `edit`->`view` downgrade keeps those frames flowing (watch is what `view` buys) while it is lane access that closes - Task 8's broker test owns that split, this matrix asserts the stream half. Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a client that received a row it has never seen ASKS for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. -- [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round. +- [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round; and rewrite `ws.plugin.ts`'s :131 `watch-previews` forward-reference comment to the shipped `watch`/`unwatch` names (its Files entry, landed here - no shipped comment may pin a frame type that does not exist). - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. --- @@ -534,9 +534,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age from the newest frame's `ts`, rate from arrivals, frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7). +- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7); Watch paints the fps/age badge beside the canvas, and a named `screenLost` (with `screen` null) reads as the loss line - there is no hint to swap, no `screen` means no Screen control. - [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). - [ ] **Step 5: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. @@ -552,7 +552,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** Consumes: the forward lane (Screen REUSES it - zero new protocol); produces `screen-viewer.tsx`. -- [ ] **Step 1: Read the library's real shape** (`sed -n '1,80p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. +- [ ] **Step 1: Install first, then read the library's real shape**: `bun add @novnc/novnc@1.7.0` in `apps/server/web`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it (the Task 10 `ws@8.22.0` precedent, `bun run lint:lockfile:fix` and by-path `bun.lock` staging included), and without the install this step's own `sed` finds nothing; then read (`sed -n '1,80p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. - [ ] **Step 2: Failing test** (mocked module; the component constructs on mount, disconnects on unmount - assert the lane's `viewer-left` path). - [ ] **Step 3: Implement:** `new RFB(el, wsUrl)` with `scaleViewport = true`, `focusOnClick = true`; no credentials (spec §5: the lane and the grant are the authentication); `on disconnect` render the reason, `clipViewport` false. Then wire the Screen BUTTON into `preview-section.tsx` (Task 12's Files deferred it to this task; nothing else lands it): when `info.screen` is present and the caller holds `edit`, a `Screen` action POSTs open against `info.screen.rfbPort` (decision 6 makes the session `raw`) and mounts this viewer in the panel. Its render test goes in Step 2: the button appears iff `info.screen && edit`, and pressing it drives exactly one open call against `info.screen.rfbPort` (spy) - the manual Task 14 container leg is otherwise the ONLY coverage this button ever gets, and manual legs do not gate. - [ ] **Step 4: License record:** the package ships its MPL-2.0 LICENSE and `bun run lint:licenses` governs package fields; if the repo has a third-party notices list (`grep -riE "third[-_ ]?party" apps/website apps/docs docs | head`) add an entry, else say in the commit body that the package's own license file is the record (decision 1). @@ -563,7 +563,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 14: e2e, docs, changeset, boundary verification **Files:** -- Create: `e2e/tests/47-preview.spec.ts` (testDir is `./tests` per `playwright.config.ts:14`, and suites are numbered - next free number after `46-server-filters`; a root-level `e2e/preview.spec.ts` would never be collected), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). +- Create: `e2e/tests/47-preview.spec.ts` (testDir is `./tests` per `playwright.config.ts:14`, and suites are numbered - next free number after `46-server-filters`; a root-level `e2e/preview.spec.ts` would never be collected), `apps/docs/content/docs/preview.mdx` (user procedure: enable per node, watch a browser pane, open a server, Screen on Linux, mac's refusal is expected; plus one troubleshooting paragraph for spec §6's `allowedHosts` sentence: the proxy rewrites `Host` to `127.0.0.1:` so dev-server host checks see loopback, but an app that still rejects the Host header is NOT silently patched around here - its own `allowedHosts`-style setting is the remedy), changeset via `bunx changeset` (`@internal/server` minor + `@internal/node` minor; never hand-edit CHANGELOGs). - Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_lane.over_cap` (the plane's sender-cap lane death, the only lane-level action - the session survives it) and `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. From ed16d72368bc7f2014fdca1a25a6e375883a6d4b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 16:21:28 -0700 Subject: [PATCH 023/139] Fold review round-11 into the plan --- .../superpowers/plans/2026-10-21-pane-preview.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 2c703c3b6..7fc21d98d 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -254,7 +254,7 @@ and in the `ready` member block `previewEnabled?: NodePreviewEnabledWire` beside > **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed commands and the `preview_data` link frame carry a gated byte lane between a dashboard viewer and a pane's loopback port (forward and CDP bridge kinds); the `preview_stream` event carries read-only screencast frames; `preview_ports_get` reports a pane's listening ports and CDP/screen hints; `set_preview_enabled` mirrors a per-machine owner switch, and the `preview_enabled` event reports a mirror that moved underneath the connection (ssh's `ssh_enabled` twin); a launch may carry a `screen` request. An agent below 23 understands none of it, and the exact-match gate is what refuses pairing before any of it is sent. -(Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (`{ ports, cdpPort, screen }` exactly as spec §4's answer), `set_preview_enabled` gets NO validator and its ack is never read: the ssh precedent fires its command best-effort and discards the result (`ssh-enabled.ts:48` + `:138`), the plane's truth is its own DB row, and a dataless command has no entry here by the file's own header rule. `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it on ack - a post-ack `getPorts` would race the 5 s cache on a pane that just started), which makes `launch` the first data-carrying command on the header's dataless list: write `parseNodeLaunchResult` in the file's hand-rolled style, validate on BOTH ends per the header contract, and update the header's dataless sentence to drop `launch` from it. +(Plus one sentence appended for decision 3's `preview_closed`.) Then `MIN_NODE_VERSION = "2.4.0"` with its comment updated ("raised with protocol 23; the floor equals the shipping agent") and `apps/node/agent/package.json` to `"2.4.0"`. In `node-results.ts`: validators for `preview_open` (forward: `{ ref, pending: true }`; screencast: `{ ref, subscribers: number }`), `preview_close` (`{ ref, closed: boolean }`), `preview_ports_get` (spec §4's `{ ports, cdpPort, screen }` answer PLUS the optional `screenLost?: "Xvfb" | "x11vnc"` this task's Produces names: the hand-rolled style REBUILDS what it validates (`parseNodeSshExecKick` returns `{ started: true, execId: data.execId }` at :727-732), so the rebuild copies `screenLost` when present - a validator that never names the member strips it silently and the marker dies between Task 7's writer and Task 9's reader, green at every unit layer; Task 7 Step 1's through-the-validator case is the pin), `set_preview_enabled` gets NO validator and its ack is never read: the ssh precedent fires its command best-effort and discards the result (`ssh-enabled.ts:48` + `:138`), the plane's truth is its own DB row, and a dataless command has no entry here by the file's own header rule. `launch`'s data gains `screen?: { display: string; rfbPort: number }` (the display the node allocated, so the plane can audit it on ack - a post-ack `getPorts` would race the 5 s cache on a pane that just started), which makes `launch` the first data-carrying command on the header's dataless list: write `parseNodeLaunchResult` in the file's hand-rolled style, validate on BOTH ends per the header contract, and update the header's dataless sentence to drop `launch` from it. - [ ] **Step 6: Run the whole protocol package, then the suites that pin the version:** `env -u SHELLOPTS -u BASHOPTS bun run test --filter=@internal/subshell-protocol`; then `bun run test` for `@internal/server` and `@internal/node` and fix every fixture that pins protocol 22 or the old floor, IN THIS COMMIT (bump doctrine). - [ ] **Step 7: Build + commit:** @@ -321,7 +321,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. - [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; the 0062 migration test rides HERE (clone `migrations/__tests__/0047-node-ssh-enabled.test.ts`, which imports its migration module directly - so it goes red now, before Step 3 writes the file; the Files entry names it, this is its step); that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). -- [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits. Plane tests green. +- [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits, and give `api-key-permissions.ts`'s `node-security` group the catalog member its Files entry names (this is where it lands - the annotation string is a catalog id, and a route annotated with a member the catalog lacks is the bug this step must not ship). Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -431,7 +431,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: `launch.screen`, `launch` result `screen` echo (Task 1), `dataDir` (grep how `/ssh//` derives it and reuse the helper), `preview/flags.ts`. - Produces: `display.openFor(paneId, { width, height }, deps?): Promise<{ display: string; rfbPort: number }>` (`deps` injects `{ spawn, spawnSync, which, sleep, now, bindProbe, kill }` for fakes - `which(name)` is the ONE binary-lookup seam wrapping `Bun.which`, `bindProbe(port)` the rfb-port seam: the collision-retry case below needs it and Task 4's `probe` is the precedent, and `kill` is the pid-reap seam Step 1's death path and Step 5's dead-dir pass fake - "exists so tests fake it"), `display.getFor(paneId)`, `display.lostFor(paneId): "Xvfb" | "x11vnc" | null` (the dead-mid-pane marker the ports arm answers with - set by Step 3's child-exit watch, cleared by the next `openFor`; in-memory by design: after a daemon restart the marker is gone and the affordance simply disappears, safely, because Step 3's exit reaping already freed the lock and dir - the named-cause duty covers the live window spec §9 describes), `display.closeFor(paneId): void`, `display.sweepOrphans(alivePaneIds: string[])`. Refusals: `preview-display-unsupported` (non-Linux), `preview-node-off` (switch off, checked here so every entry point refuses with one voice), `preview-display-missing-binaries` (names `Xvfb`, `x11vnc`, or `xauth`), `preview-display-not-ready`. -- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone), and the pair's third leg: a LIVE pane whose recorded children are gone (fake `bindProbe` refuses the recorded rfbPort) is reaped, not rehydrated - a service-manager restart that takes the whole cgroup while tmux and the pane survive leaves exactly that record - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned; the Test list's SECOND file gets its authoring here too - `packages/pane-runtime/src/__tests__/launch.test.ts` gains the extraEnv trio: with `extraEnv` the chain puts DISPLAY/XAUTHORITY last (a display value beats the preset layer), `assembleHarnessCommand`'s exit-hook call (agent `launch.ts:224`) carries the same layer, and with NO extraEnv the assembled chain equals today's four-layer spread byte-for-byte (Step 4's preservation claim gets a pin or it is a claim); and a REGISTERED display whose child exits (fake `spawn` handle fires its exit) marks `lostFor` with that binary's name, reaps lock and dir through the same teardown `closeFor` runs, leaves the pane's row untouched, and clears the marker on the next `openFor` (spec §9's mid-pane loss; see Step 3's watch). +- [ ] **Step 1: Failing unit tests** (injected deps, no real processes): `:90` free is picked, a taken lockfile (`open(path, "wx")` EEXIST) pushes to `:91`; state dir 0700, log files 0600 under `/displays//`; missing `x11vnc` refuses naming it and leaves no lock behind; non-Linux refuses by name; readiness polls the X socket then the rfb port and refuses `preview-display-not-ready` after the budget; a pane death reported through the same hook closes its display (SIGTERM to both children, lock unlinked); `sweepOrphans` closes every registered display whose pane id is absent from the alive list, and a RESTART pair that the warm-registry cases cannot substitute for: rebuild the display module's state against the same temp `dataDir` with a cleared in-memory registry - a LIVE pane's dir rehydrates (registry entry rebuilt from `display.json` with its recorded rfbPort, so the ports-style read answers again) and a DEAD pane's dir is reaped (recorded pids killed through the fake deps, dir and lock gone), and the pair's third leg: a LIVE pane whose recorded children are gone (fake `bindProbe` refuses the recorded rfbPort) is reaped, not rehydrated - a service-manager restart that takes the whole cgroup while tmux and the pane survive leaves exactly that record - every other sweep assertion here runs registered and would pass with a registry-only loop; the rfb-port chooser retries on collision (fake `bindProbe`: first port busy, second free - the chosen port lands in the result); the injected `spawn` records EXACT argv and ORDER - `xauth -f add :NN . <32-hex>` fires BEFORE the Xvfb spawn, Xvfb receives `:NN -auth -screen 0 x24 -nolisten tcp`, x11vnc receives `-display :NN -auth -localhost -forever -shared -rfbport ` (argv is the only assertion that can catch a broken invocation - everything else in this list runs on fakes, and the plan's previous xauth line measured broken here while staying green); once `preview-ports-exec.ts` is wired, the ports executor answers `screen: { display, rfbPort }` for a pane with a registered display and `screen: null` without one (decision 6's supply line: this is the ONLY test between Task 4's null hardwire and production answering null forever), and both wired answers - plus the dead-marker shape `screen: null` + `screenLost: "x11vnc"` - pass through Task 1's `node-results` validator with the marker SURVIVING the rebuild (Task 1 Step 5's hand-rolled style strips unlisted members; no other test between that validator and Task 14's manual leg ever runs this field through it); and with the preview mirror OFF, `display.openFor` refuses `preview-node-off` BEFORE any spawn, lock, or directory, and the `launch.screen` path's flag check (Step 4) refuses naming the switch the same way - the `preview-node-off` Produces promises gets pinned here, on both entry points, with nothing spawned; the Test list's SECOND file gets its authoring here too - `packages/pane-runtime/src/__tests__/launch.test.ts` gains the extraEnv trio: with `extraEnv` the chain puts DISPLAY/XAUTHORITY last (a display value beats the preset layer), `assembleHarnessCommand`'s exit-hook call (agent `launch.ts:224`) carries the same layer, and with NO extraEnv the assembled chain equals today's four-layer spread byte-for-byte (Step 4's preservation claim gets a pin or it is a claim); and a REGISTERED display whose child exits (fake `spawn` handle fires its exit) marks `lostFor` with that binary's name, reaps lock and dir through the same teardown `closeFor` runs, leaves the pane's row untouched, and clears the marker on the next `openFor` (spec §9's mid-pane loss; see Step 3's watch). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement per spec §5:** numbers `:90..:199` via lock `/displays/.display-NN.lock` (`wx`); state dir for `Xvfb.log`, `x11vnc.log`, `Xauthority`, `display.env`, and `display.json` (`{ display, rfbPort, xvfbPid, x11vncPid }`, written INCREMENTALLY so every crash window leaves the pids that exist - `{ xvfbPid }` alone once Xvfb is up, the full record once both are; the registry's durable twin: the lock's dir name ties the NUMBER to the pane, the port and the pids live here); mint the auth file BEFORE anything spawns: a crypto-random 32-hex cookie, then `xauth -f add :NN . ` - `add` needs no live display (rc=0 measured 2026-10-22), while `generate` cannot be used at this point at all: it opens the display to read the cookie and dies `unable to open display` with Xvfb not yet up, and its `-f` is a GLOBAL option that after the subcommand is silently ignored (both defects measured on this host, rc=1; an earlier revision of this step shipped `xauth generate -f :NN .`, which fails on both counts in one line and would have stayed green through every fake-injected gate). Then start `Xvfb :NN -auth -screen 0 WxHx24 -nolisten tcp` - Xvfb reads the pre-seeded file and may replace the entry, which is harmless because the pane's `XAUTHORITY` and x11vnc's `-auth` read the SAME file after startup, so whatever cookie ends up in it is the one both sides use; pick an rfb port by bind-probe from 5910; `x11vnc -display :NN -auth -localhost -forever -shared -rfbport P` (loopback, no RFB password - spec §5 says so out loud); `display.env` carries `DISPLAY` and `XAUTHORITY` (the pinned env). Child-exit watch (spec §9): keep the handles the injected `spawn` returned; when either child exits while the display is still REGISTERED (a mid-pane vanish - the daemon's own teardown paths kill children THROUGH `closeFor`/sweep and must not re-enter as a loss), mark `lostFor`, reap the lock and dir through the same teardown, log the binary's name, and leave the pane alone: its DISPLAY env points at a dead server from then on, its terminal keeps streaming, and the ports answer names the loss. - [ ] **Step 4: `commands/launch.ts` `screen` path, in order:** platform check -> `flags.getPreviewEnabled()?.on` check (refusal BEFORE any spawn, spec §4/§6 - this is the gate reviewers flagged) -> `display.openFor` -> inject `DISPLAY` and `XAUTHORITY` into the pane env. NOT `tmux-runner.ts`'s `run`/`runAsync` `opts.env` (:188/:261) - that env is handed to the tmux CLIENT process and never reaches the pane. The seam is pane-runtime's `paneEnvFor` (`packages/pane-runtime/src/launch.ts:112`, the spread chain `{...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv}`): give `paneEnvFor` and `assembleHarnessCommand` an optional FIFTH layer `extraEnv` (the chain at `launch.ts:117` already has four - `curatedEnv`, `subshellEnv`, `preset.env`, `mcpEnv`, and `assembleHarnessCommand`'s fourth parameter is already `mcpEnv`; empty when the pane has no display), pass `{ DISPLAY, XAUTHORITY }` there at `apps/node/agent/src/commands/launch.ts:209`, and pass it at :224 too so the exit hook's env keeps agreeing with the pane's. A comment names it the deliberate addition to the pane env, the same way the SUBSHELL_* credentials are -> spawn -> echo `screen: { display, rfbPort }` in the launch result. Any refusal fails the launch with its named reason. Pane death: the existing death report also calls `display.closeFor`. @@ -449,10 +449,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socket, holderUserId)` / `detachStream(paneId, socket)` + `detachSocketsFor(socket)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null }>` (the exact `{ ports, cdpPort, screen }` answer of Task 4's scan and Task 1's result validator, cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: for each socket, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socket); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map, because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so attach records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane, `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -509,14 +509,14 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, socket, holderId)` / `detachStream(paneId, socket)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(socket)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (two things: the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt; and the module holds the broker's production wiring - at module init, before any frame can attach a socket, it calls the Task 8 setter `configurePreviewStreamSender(sendToSockets)`, imported from `./live-registry.js`, so THIS file is the broker's ONLY crossing to the ws layer - a single VALUE import the structural type keeps minimal; `services/preview-broker.ts` is NOT an edit site: it imports nothing from `ws/`, and unconfigured it never fans out), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (the OPEN handler mints the per-connection attach key beside `liveViewerId`: `ws.data.streamViewerId = crypto.randomUUID()` (the socket-lifetime id habit at `subshell-ws.ts:173` - a reconnect is legitimately a new viewer; `data` is the only thing that survives Elysia's per-callback wrapper rebuild, `live-ws.ts:36-42`, and Task 8's registry keys on THIS, never on the socket object and never on `liveViewerId` - that one is the user id at :147, and two tabs of one account are two viewers); client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, ws.data.streamViewerId, holderId, ws)` / `detachStream(paneId, ws.data.streamViewerId)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(ws.data.streamViewerId)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (two things: the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt; its `data` block gains `streamViewerId?: string` beside `liveViewerId` (the open handler's mint and every detach call site read the field; without it the type refuses both); and the module holds the broker's production wiring - at module init, before any frame can attach a socket, it calls the Task 8 setter `configurePreviewStreamSender(sendToSockets)`, imported from `./live-registry.js`, so THIS file is the broker's ONLY crossing to the ws layer - a single VALUE import the structural type keeps minimal; `services/preview-broker.ts` is NOT an edit site: it imports nothing from `ws/`, and unconfigured it never fans out), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** - Consumes: Task 2 frames, Task 8 broker, `loadSubshellAccess` (:72). - Produces: `sendToSockets`; for a holder with `view`+, `{"type":"watch","id":paneId}` attaches that socket, and a running bridge then delivers `{"type":"preview-stream","id","seq","w","h","blob"}` to attached sockets only. -- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected); byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. +- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected) - and drive that close through a FRESH wrapper object sharing the attached socket's `data`, what Elysia actually hands `close` (`live-ws.ts:36-42`): under socket-identity keying that close detaches NOTHING while every reuse-one-object fake passes, so the swapped wrapper is this rule's regression pin; byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. - [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives); a mid-stream `edit`->`view` downgrade keeps those frames flowing (watch is what `view` buys) while it is lane access that closes - Task 8's broker test owns that split, this matrix asserts the stream half. Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a client that received a row it has never seen ASKS for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. - [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round; and rewrite `ws.plugin.ts`'s :131 `watch-previews` forward-reference comment to the shipped `watch`/`unwatch` names (its Files entry, landed here - no shipped comment may pin a frame type that does not exist). - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. @@ -534,7 +534,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age from the newest frame's `ts`, rate from arrivals, frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). +- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age and rate read the ARRIVAL clock - age is now minus the newest frame RECEIVED - because the live frame carries no wall-clock field: `ts` exists on the node->plane `preview_stream` EVENT only, and spec §6's browser shape is `{ id, seq, w, h, blob }` (round 11 caught this clause citing a field the shape does not have); one clock also makes the badge agree with the 5 s stale rule, which has always been arrival-based. Frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7); Watch paints the fps/age badge beside the canvas, and a named `screenLost` (with `screen` null) reads as the loss line - there is no hint to swap, no `screen` means no Screen control. - [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). From f80ad62e8bbc28bf77f2cc8d41e2d62db6f0de19 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 16:40:19 -0700 Subject: [PATCH 024/139] Fold review round-12 into the plan --- .../plans/2026-10-21-pane-preview.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 7fc21d98d..d45e2b628 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -35,6 +35,8 @@ 7. The watch limits, stated where the spec left room: spec §6 caps "max 8 watch subscribers per bridge," and with the bridge pane-keyed (correctness round 4) the cap checks the SUM of the per-viewer counts on the bridge BEFORE the increment - a per-viewer reading of `preview-watch-over-cap` would make the number meaningless, because eight tabs of one account would pass while a crowd of nine accounts could not. The cap also covers the socket half - `attachStream` refuses a pane's 9th attached socket with the `preview-stream-over-cap` refusal code and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in its METADATA - refusal codes keep the distinct `preview-*-over-cap` shape so the bare close-reason word stays reserved for actual closes, and a viewer drop closes nothing) - because the socket fan-out is the same per-frame broadcast path the byte-debt rule guards, and one person's 40 tabs must not multiply every screencast emit; reusing `PREVIEW_WATCH_SUBSCRIBERS_MAX` keeps one honest number in play. +8. Spec §4's screencast pacing sketch ("every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate") is REPLACED by capture-every-repaint plus the emit-boundary clamp: `everyNthFrame: 1` and `clampFps` - one pin-able clamp replaces an untested N-sizing formula, dropped frames cost no base64 and no plane↔node bytes (the spec's own concern), and every capture keeps its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one. Task 6 Step 3 implements it; its inline reference points here. + **Commit discipline, restated because EVERY task ends in a commit and neighbors only see their own task:** the working tree shares the git INDEX with concurrent workstreams, so every commit step here means `git commit -m "..." -- ` - never a bare `git commit`, and `git add` takes file paths, never whole directories. Measured 2026-10-22 in this exact tree: a bare commit swallowed a sibling's staged `bun.lock`. Tasks 10 and 13 dirty `bun.lock` themselves (the `ws` add, the noVNC add) - stage it by path with the rest, and `git status --short` before and after must show nothing staged that you did not touch. **Naming note.** The word "preview" already names the pane's screen-thumbnail cache on disk (`apps/server/api/src/services/nodes/preview-cache.ts`). The approved spec chose "preview" for this feature anyway; keep it (renaming now churns more than it saves), but say in the new code's JSDoc which preview it means, and keep the docs page titled "Pane preview" so a reader meets the distinction in prose before code. @@ -313,7 +315,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one, `apps/server/api/src/db/migrations/0062-node-preview-enabled.ts` (+ its migration test, cloning `migrations/__tests__/0047-node-ssh-enabled.test.ts`). - Modify: `packages/subshell-protocol/src/api-key-permissions.ts` (the `node-security` group gains `{ id: "nodes:preview", label: "Preview panes", description: "Turn pane preview on or off on a node." }` beside `nodes:ssh` at :36): the ssh route is annotated `systemPermission: "nodes:ssh"` (`set-node-ssh-enabled.route.ts:82`) and a route's permission string is a catalog member (`auth-guard.ts:194`), so the preview switch needs its OWN id - reusing `nodes:ssh` would let every ssh-scoped key flip preview, and the catalog feeds the key-minting UI's checkboxes, so it is a public-surface entry, not an internal string. - Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416; AND the heartbeat call - `maybeReportPreviewEnabled(ctx)` beside `maybeReportSshEnabled` at :1317), `apps/node/agent/src/commands/report.ts` (`reportPreviewEnabled` + `maybeReportPreviewEnabled`, cloning `reportSshEnabled` at :163 (send the mirror verbatim + stash the memo) and `maybeReportSshEnabled` at :186 (report only when the mirror moved since this connection last spoke about it)), `apps/node/agent/src/commands/context.ts` (the `lastReportedPreviewEnabled` memo beside the ssh one - the memo is what keeps a plane-pushed `set_preview_enabled` from echoing back as an event), `apps/server/api/src/services/nodes/node-events.ts` (the `NodeLifecycleHooks` member `onPreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined)` beside `onSshEnabled` at :114), `apps/server/api/src/services/nodes/node-ws-handler.ts` (the ready-path read beside `hooks.onSshEnabled` at :1099 AND a `case "preview_enabled":` arm cloning :1170 - the switch has no default branch, so an event with no arm lands dead and every other suite stays green), `apps/server/api/src/index.ts` (import + the hook wiring `onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported)` beside :330), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). -- Test: plane route test + `apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts` (clone the ssh one's two layers: the pure decide table, and the hook body with one real row and one scripted node whose push lands in the real wire - the `ssh-enabled.test.ts:21-22` seam, which is what proves the `set_preview_enabled` frame parses against the shipped parser) + the dispatch rows in `apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts` (clone the `ssh_enabled` event row at :937-944: feed `{ type: "preview_enabled", on, changedAt }` through `handleNodeMessage` and assert the lifecycle hook saw it) + `apps/node/agent/src/__tests__/preview-flags.test.ts`. +- Test: plane route test + `apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts` (clone the ssh one's two layers: the pure decide table, and the hook body with one real row and one scripted node whose push lands in the real wire - the `ssh-enabled.test.ts:21-22` seam, which is what proves the `set_preview_enabled` frame parses against the shipped parser) + the dispatch rows in `apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts` (clone the `ssh_enabled` DISPATCH QUARTET, not just the event row - the row titles on disk are the anchors: "a ready CARRYING sshEnabled reconciles it", "a ready WITHOUT it still reconciles" (its `undefined` report IS the fail-closed beat the ssh suite calls load-bearing, and connect-time mirror repair lives or dies there), the EVENT row at :937-944, and "a ready whose sshEnabled field is malformed keeps the ready and drops the field". Feed the `preview_enabled` shapes and the ready variants through `handleNodeMessage` and assert the lifecycle hook saw each - the ready-path read is a Modify line this quartet is the ONLY thing that reaches, and the switch has no default, so an omitted arm passes every other suite) + `apps/node/agent/src/__tests__/preview-flags.test.ts`. **Interfaces:** - Consumes: `SetPreviewEnabledCommand`, `NodePreviewEnabledWire`, the `preview_enabled` NodeEvent (Task 1). @@ -449,12 +451,12 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle`; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. +- [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep - the sweep skips `kind: "screencast"` sessions entirely, their life is refcount/revocation/node-close/lifetime, per Step 1's sweep case; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. --- @@ -468,9 +470,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. -- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, screenLost, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `screenLost` is the binary name when the pane's display died mid-pane - Task 7's marker arrives on the ports answer and passes through named, never inferred, with `screen: null` alongside it (spec §9), and the info schema declares the member as the optional union it is); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). +- Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, screenLost, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed - and the route gets THAT answer without an RPC: a terminated/exited row short-circuits route-side to `{ ports: [], cdpPort: null, screen: null }` (no `screenLost`: a closed pane has no display that could die), while `nodeEnabled`/`reason` still describe the NODE, per the rest of this shape - the broker's refusal-mapping rule applies ONLY to live panes whose node-side answer failed, never to a pane that is gone; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `screenLost` is the binary name when the pane's display died mid-pane - Task 7's marker arrives on the ports answer and passes through named, never inferred, with `screen: null` alongside it (spec §9), and the info schema declares the member as the optional union it is); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads); a node fake answering `screenLost: "x11vnc"` with `screen: null` passes BOTH through unchanged (the named-cause pair spec §9 demands - a route that swallowed the marker would render silence). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads); a node fake answering `screenLost: "x11vnc"` with `screen: null` passes BOTH through unchanged (the named-cause pair spec §9 demands - a route that swallowed the marker would render silence); and the CLOSED-pane row: info on a terminated pane answers `{ ports: [], cdpPort: null, screen: null }` with the broker spy UNTOUCHED (no RPC leaves the route - :471's short-circuit; applying the refusal-mapping rule here would answer `nodeEnabled: false` naming a ports refusal for a pane that simply is not there). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. The Step-1 create-path case turns green here: the preflight's refusal means no pane process and no `launch` on the wire. @@ -499,6 +501,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - `raw` mode (a session opened on the pane's registered screen `rfbPort`, decision 6): the fake node's raw, non-WS TCP target receives the browser's WS frame payloads verbatim and answers in kind, with no HTTP upgrade ever reaching the target (x11vnc would read it as a bogus RFB version); that raw target then dies and the broker fake reports the lane closed `target-gone`: the browser WS's recorded `close` event must carry code 1011 with reason exactly `target-gone` (the word is the contract Task 13's disconnect handling consumes - a close without it passes every pump assertion here while the UI can only say "connection lost"); - unknown `/pv//` -> 404, body leaks nothing; and `/pv/` matches `REQUEST_LOG_IGNORE` (the secret in the path must never reach a request line); - pre-ack buffer: bytes sent before the fake node acks past `PREVIEW_LANE_BUFFER_MAX_BYTES` close the connection `slow-link` (spec §6's no-phantom-success) - `pv-ws.ts` takes an options `{ preAckMaxBytes? }` defaulting to the constant so the test drives the boundary small, the same seam doctrine as Task 5's `laneBufferMaxBytes`. + - lane-cap refusal, browser-facing: a request whose `openLaneForConnection` is refused by the queue overflow answers `503` with `preview-lane-over-cap` in the body, and an upgrade is refused with the same named code - never a hang, a queue-jump, or an anonymous 500 (the refusal itself is Task 8's broker test; this pins what the PROXY does with it, which no other task observes). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store ONLY the rewritten `name=value` pair - never the attributes - in `session.cookieJar` keyed by cookie name (the jar's values ARE the pairs attached to later requests, so a value still wearing `Path=` or `Expires=` would ship as a bogus cookie; same name replaces, and name-only keying with last-write-wins across paths is the deliberate v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). - [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the six verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. @@ -516,7 +519,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 2 frames, Task 8 broker, `loadSubshellAccess` (:72). - Produces: `sendToSockets`; for a holder with `view`+, `{"type":"watch","id":paneId}` attaches that socket, and a running bridge then delivers `{"type":"preview-stream","id","seq","w","h","blob"}` to attached sockets only. -- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected) - and drive that close through a FRESH wrapper object sharing the attached socket's `data`, what Elysia actually hands `close` (`live-ws.ts:36-42`): under socket-identity keying that close detaches NOTHING while every reuse-one-object fake passes, so the swapped wrapper is this rule's regression pin; byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running. +- [ ] **Step 1: Extend `live-ws.test.ts`:** watch from a `view` grant attaches the socket and a stream frame then arrives with `blob` untouched - and it reaches that socket ONLY, not the same holder's second test socket that never watched; watch on a foreign pane attaches nothing (spy) and the socket stays open; `unwatch` stops delivery; socket close detaches its panes (the holder's other socket is unaffected) - and drive that close through a FRESH wrapper object sharing the attached socket's `data`, what Elysia actually hands `close` (`live-ws.ts:36-42`): under socket-identity keying that close detaches NOTHING while every reuse-one-object fake passes, so the swapped wrapper is this rule's regression pin; byte debt from one lagging socket detaches THAT socket (`slow-link` audit), the lane and the bridge keep running; a `watch` the attach cap refuses (pane already at eight sockets) leaves THAT socket open and unregistered - no frames, no close, no throw, the refusal value and its audit are Task 8's, this row pins the swallow. - [ ] **Step 2: Extend `live-consistency.test.ts` (THE control - read every existing row first and keep them; the rule is "never delete or weaken").** For each existing (viewer, access) pair add the watch case: after a `watch`, the sockets that receive `preview-stream` for pane P are EXACTLY the view-+ holders that sent `watch` (not admins without watch, not the owner's sockets that never watched, no extra sockets). A mid-stream grant removal stops frames to that socket while the socket lives (the row survives); a mid-stream `edit`->`view` downgrade keeps those frames flowing (watch is what `view` buys) while it is lane access that closes - Task 8's broker test owns that split, this matrix asserts the stream half. Note before writing it: the matrix file carries NO revocation case today (its two `it()`s are pure snapshot-vs-REST equality) - the socket-alive precedent is `live-ws.ts:284-288`, where a client that received a row it has never seen ASKS for the list again and its socket keeps running. Match THAT behavior; add the preview revocation case without touching the two existing rows. - [ ] **Step 3: Implement** (small) and make the matrix rows pass - the matrix drives broker-side leak fixes, not the other way round; and rewrite `ws.plugin.ts`'s :131 `watch-previews` forward-reference comment to the shipped `watch`/`unwatch` names (its Files entry, landed here - no shipped comment may pin a frame type that does not exist). - [ ] **Step 4: Green + commit** `feat(server): live watch subscriptions through the authorization control`. From 0e492339602ed333f9ee5417d27efdb8112f64d7 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 17:02:40 -0700 Subject: [PATCH 025/139] Fold review round-13 into the plan --- .../plans/2026-10-21-pane-preview.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index d45e2b628..90a62f767 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -28,14 +28,14 @@ 1. Spec §12.4 / §7 said vendor noVNC's `core/` from git. It is published on npm as `@novnc/novnc` (v1.7.0, the official repo's own package: `type: module`, `exports: "./core/rfb.js"`, MPL-2.0 text ships inside, all internal imports relative). We install the pinned dependency instead of copying files: same ruling (pinned, MPL retained), fewer bytes and no drift. 2. Spec §6 describes `POST /preview/open` sending `preview_open` and awaiting the ack (kept as a reachability/gate probe: the ack lands before the node's dial of the target settles, so a refused port surfaces later as `target-gone`, never on the POST - Task 5). One TCP lane cannot carry a dev server's keep-alive HTTP and its permanent HMR WebSocket at once, so **browser connections beyond the probe each open their own node lane** (`preview_open` with a fresh 32-hex ref registered under the same session; the session secret authorizes many lanes; the probe lane closes as soon as it is acked). Spec quotas still count lanes; a burst beyond the cap queues up to 8 waiters instead of failing. -3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`ssh_enabled`, `subshells_report`, `relay`), as is `preview_stream` itself. Spec §7's "polling-routes exclusion" names a mechanism that DOES exist (`REQUEST_LOG_IGNORE`, `apps/server/api/src/plugins/context.plugin.ts:32`, exported and pinned by `plugins/__tests__/request-log-ignore.test.ts`; the reason sits verbatim in `context.plugin.ts:19`: "Every one of these is POLLED by a page somebody leaves open"), and this feature joins it twice: the info poll for noise, and the `/pv` path for a credential reason - the session secret rides the URL, so a debug request line would log a live credential (an earlier revision of this plan claimed the mechanism was missing; that claim was wrong, verified against disk 2026-10-22). Visible throttling (one request per five seconds) is implemented too, as the same sentence asks. +3. Two wire additions the spec text lacks: the node must tell the plane when a lane or bridge dies on its own (the ssh relay never needs this because its targets are agent-managed), so a node->plane event `preview_closed { ref, reason }` joins the grammar; and the close-reason set gains `target-gone` (the proxied service hung up while the pane lives). Both are spelled `snake_case` to match the NodeEvent convention on disk (`ssh_enabled`, `subshells_report`, `relay`), as is `preview_stream` itself. Spec §7 asks for it by its real name ("added to the server's existing `REQUEST_LOG_IGNORE` list") and that mechanism DOES exist (`REQUEST_LOG_IGNORE`, `apps/server/api/src/plugins/context.plugin.ts:32`, exported and pinned by `plugins/__tests__/request-log-ignore.test.ts`; the reason sits verbatim in `context.plugin.ts:19`: "Every one of these is POLLED by a page somebody leaves open"), and this feature joins it twice: the info poll for noise, and the `/pv` path for a credential reason - the session secret rides the URL, so a debug request line would log a live credential (an earlier revision of this plan claimed the mechanism was missing; that claim was wrong, verified against disk 2026-10-22). Visible throttling (one request per five seconds) is implemented too, as the same sentence asks. 4. Spec §12.4 asked for noVNC as a "lazy-loaded chunk". `.claude/rules/code-style.md` forbids dynamic import with one named exception and says a second exception is a design question, so RFB is a STATIC import. Bundle cost (~250 KB pre-gzip, one page's worth) is accepted; if a chunk boundary is wanted later it is a design question for the rules file, not this plan's call. 5. Spec §4's command bullet writes `set_preview_enabled { enabled }` while the same sentence says "the `set_ssh_enabled` shape exactly", and the disk shape is `{ on, changedAt }` (`node-frames.ts:1294` `NodeSshEnabledWire`). The plan follows the disk precedent; the `{ enabled }` shorthand is the outlier. 6. Spec §6's Browser-lanes paragraph calls the `/pv/:session/` upgrade "the raw tunnel for everything websocket-shaped ... bytes pump both directions blind" - but one blind mode cannot serve both of its named consumers. A raw byte pump carries noVNC (x11vnc speaks raw RFB and would read the plane's WS handshake as a bogus version and hang up) but breaks Vite HMR and app sockets, whose targets are WS servers needing their own upgrade. So the browser's upgrade always terminates plane-side, and the TARGET leg branches by `PreviewSession.mode`: `raw` (blind byte pump, websockify semantics) when the session's port is the pane's registered screen `rfbPort`, `http` (a `ws` client redials `ws://127.0.0.1:/` over the lane) for everything else. The broker learns the screen port by consulting the pane's LIVE `getPorts` screen record first and falling back to the launch-time `onDisplayOpened` memory (Task 8), so no REST or protocol field is added and a PLANE RESTART cannot downgrade a live display to `http` (in-process memory is empty after a restart, but the node still reports the screen port; memory-only would re-handshake raw RFB); the spec's §6 body stays `{ port }`. 7. The watch limits, stated where the spec left room: spec §6 caps "max 8 watch subscribers per bridge," and with the bridge pane-keyed (correctness round 4) the cap checks the SUM of the per-viewer counts on the bridge BEFORE the increment - a per-viewer reading of `preview-watch-over-cap` would make the number meaningless, because eight tabs of one account would pass while a crowd of nine accounts could not. The cap also covers the socket half - `attachStream` refuses a pane's 9th attached socket with the `preview-stream-over-cap` refusal code and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in its METADATA - refusal codes keep the distinct `preview-*-over-cap` shape so the bare close-reason word stays reserved for actual closes, and a viewer drop closes nothing) - because the socket fan-out is the same per-frame broadcast path the byte-debt rule guards, and one person's 40 tabs must not multiply every screencast emit; reusing `PREVIEW_WATCH_SUBSCRIBERS_MAX` keeps one honest number in play. -8. Spec §4's screencast pacing sketch ("every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate") is REPLACED by capture-every-repaint plus the emit-boundary clamp: `everyNthFrame: 1` and `clampFps` - one pin-able clamp replaces an untested N-sizing formula, dropped frames cost no base64 and no plane↔node bytes (the spec's own concern), and every capture keeps its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one. Task 6 Step 3 implements it; its inline reference points here. +8. Spec §4's screencast pacing ("`everyNthFrame` tuned to ≤ 5 fps (bridge clamps `fps` to [1, 5])") is REPLACED at the reduction SITE, not the ceiling: capture every repaint (`everyNthFrame: 1`) and let `clampFps` do the rate reduction at the emit boundary - one pin-able clamp replaces the untested `everyNthFrame`-sizing formula, dropped frames cost no base64 and no plane↔node bytes (the spec's own concern), the ≤ 5 fps ceiling and quality 60 survive verbatim, and every capture keeps its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one. Task 6 Step 3 implements it; its inline reference points here. **Commit discipline, restated because EVERY task ends in a commit and neighbors only see their own task:** the working tree shares the git INDEX with concurrent workstreams, so every commit step here means `git commit -m "..." -- ` - never a bare `git commit`, and `git add` takes file paths, never whole directories. Measured 2026-10-22 in this exact tree: a bare commit swallowed a sibling's staged `bun.lock`. Tasks 10 and 13 dirty `bun.lock` themselves (the `ws` add, the noVNC add) - stage it by path with the rest, and `git status --short` before and after must show nothing staged that you did not touch. @@ -322,7 +322,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Produces: node `preview/flags.ts`: `getPreviewEnabled(): NodePreviewEnabledWire | undefined`, `setPreviewEnabled(state: NodePreviewEnabledWire): void` (mirrors `preview_closed`-free naming: this is just the mirror); node `commands/report.ts`: `reportPreviewEnabled(ctx, state)` + `maybeReportPreviewEnabled(ctx)`; plane `preview-enabled.ts`: `getPreviewEnabled(nodeId): Promise` (row absent = OFF, fail-closed), `setPreviewEnabled(nodeId, actorUserId, on): Promise`, and `reconcilePreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined): Promise` - clone `ssh-enabled.ts:174`'s direction rule verbatim: the row is the record, every disagreement answers with a push of the row's own value (that push IS the corruption repair), never an adopt. - [ ] **Step 1: Read the clone source** both halves and both tests; note how it persists plane-side state, sends the command (`sendCommand` from `@/services/nodes/node-rpc.js`), audits `node.ssh_enabled.update`, and survives mirror drift. -- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; the 0062 migration test rides HERE (clone `migrations/__tests__/0047-node-ssh-enabled.test.ts`, which imports its migration module directly - so it goes red now, before Step 3 writes the file; the Files entry names it, this is its step); that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, the same assertions `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` makes for its twin (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). +- [ ] **Step 2: Failing tests, BOTH halves.** Plane: clone the ssh route test - cookie actor flips -> 200 + audit `node.preview_enabled.update` + default read `{on:false}` with no row; the 0062 migration test rides HERE (clone `migrations/__tests__/0047-node-ssh-enabled.test.ts`, which imports its migration module directly - so it goes red now, before Step 3 writes the file; the Files entry names it, this is its step); that route test asserts the ROUTE wired the acting human into the service (its own header at :22-24: no write or push happens in the route, and it names no `sendCommand` - the route fixture node is even `status: "offline"` at :90, so the push cannot be seen there), so the PUSH assertion (`sendCommand` called with `{type:"set_preview_enabled", on, changedAt}`) clones the SERVICE test's scripted-wire pattern (`ssh-enabled.test.ts:21-22`), not the route's. system key 403. Drift repair, the plane half: the scripted-node service test asserts BOTH doors reach the same reconciler - a `ready` carrying a stale `previewEnabled` and a mid-session `preview_enabled` event each end with the row's value pushed back down and the row itself unmoved (the ssh suite's direction property, cloned). Node: a verified `set_preview_enabled` flips the mirror (0600); `getPreviewEnabled` ALLOWS ONLY a parsed `{on:true}` mirror - absent, unreadable, and malformed all read as OFF, cloning the gate-classifier pattern of `apps/node/agent/src/__tests__/ssh-enabled.test.ts:178` - which pins absent and unreadable for its twin, so ADD malformed as the third case (round-13 caught the old wording crediting :178 with a malformed assertion it does not make) (no gated preview command exists yet - each gated entry point lands its own refusal test where it arrives: Task 5's off-mirror `preview_open` case, Task 6's off-mirror screencast case, Task 7's off-mirror display and `launch.screen` cases, each pinning the `commands-ssh-relay.test.ts:127` posture for its branch); an external edit to the mirror file between commands is re-read and re-reported (copy the ssh mirror test). - [ ] **Step 3: Implement the plane half.** "Clone the storage mechanism" concretely means: the `sshEnabled`/`sshEnabledAt` column PAIR on `nodes` cloned as `previewEnabled`/`previewEnabledAt` (integer 0/1 + ISO stamp, 0047's shape) via the new 0062 migration registered in `migrate.ts`, the repository member, then the service. If any curated-`:memory:` suite comes to read or write the new columns, add 0062's `.up()` to that file's hand-curated list (the `0039-subshell-cross-agent` precedent, spelled out in `apps/server/api/AGENTS.md:824`). Annotate the route `systemPermission: "nodes:preview"`, the same position the ssh route's `nodes:ssh` sits, and give `api-key-permissions.ts`'s `node-security` group the catalog member its Files entry names (this is where it lands - the annotation string is a catalog id, and a route annotated with a member the catalog lacks is the bug this step must not ship). Plane tests green. - [ ] **Step 4: Run the node tests to see them fail, then implement the node half:** executor gate-first + mirror file with mode 0600 exactly where the ssh mirror lives; `daemon.ts` ready gains `...(getPreviewEnabled() ? { previewEnabled: getPreviewEnabled() } : {})` (the name Task 3 Produces; the ready-member habit is the ssh one at `daemon.ts:362/:416`). - [ ] **Step 5: Focused green, commit** `feat: per-node preview switch mirroring the ssh enable posture`. @@ -416,7 +416,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Failing tests.** Fake CDP in-proc: `Bun.serve` answering `/json/list` with `[{ type: "page", webSocketDebuggerUrl }]` and a websocket handler that records messages and pushes three `Page.screencastFrame` notifications. Assert: start sends `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (after `Page.enable`); every frame is acked with `Page.screencastFrameAck` (`sessionId` echoed) EVEN when the fps throttle drops its emission (fps clamp 5, three frames in one tick -> at most one stream emission, three acks); `webSocketDebuggerUrl` pointing at a non-loopback host is refused `not-cdp` (the port came from /proc; the URL is answered by a socket - distrust it); CDP socket close mid-stream emits `preview_closed target-gone` once; a target whose `/json/list` 404s closes `not-cdp`; `clampFps(0) === 1`, `clampFps(15) === 5`, absent fps defaults to 5; `preview_close` on a BRIDGE ref stops it exactly once (forward miss -> bridge hit -> `{ closed: true }`) while an unknown-in-both ref keeps `{ closed: false }`; `bridgeCloseAll("node-dropped")` stops every bridge with one emission each; lifetime expiry (fake clock) emits `preview_closed` with `idle`; a bridge whose target sends NO frames stays alive past the old 60 s traffic window (the anti-test for traffic-idle: zero frames, zero closes); with the preview mirror OFF the screencast branch is refused `preview-node-off` before the CDP handshake (the Step-4 gate-first wiring, pinned the way Task 5 case (g) pins its forward twin), and with the fake context's pane registry reporting the `paneId` absent the screencast branch is refused `preview-pane-gone` with ZERO requests reaching the fake CDP server (Step 4's ladder names the rung; Task 5's case (k) is its forward twin); and the buffered cap is DRAINABLE: with `emitStream` injected to stall, production past `PREVIEW_WATCH_FRAMES_BUFFERED` drops frames WITHOUT closing the bridge, and once the stall resolves the bridge emits again (a counter with no decrement would go permanently dark after five frames while every other case here stays under the cap and passes either way - this is the test that tells the two designs apart); and the SHARED node budget: with the fake context's forward registry reporting 15 open lanes (`forwardCount()` faked, the real CDP fake present), the bridge that fills slot 16 starts, and the NEXT open is refused `preview-watch-over-node-cap` with ZERO requests reaching the fake CDP server - the refusal must be proven to precede the dial, not merely to answer; AND the other direction, because the budget is one number, not two habits: with `bridgeCount()` faked at 15, a lane open fills slot 16 and the NEXT lane is refused `preview-lane-over-node-cap` before its fake `connect` runs (this case owns the lane-side sum check that Task 5 could not have written - no bridge registry existed when its executor coded the branch - so nothing here may skip it and leave half the cap silently doubled). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (decision 8, a recorded deviation from spec §4's wording, which sketched "every Nth frame ACKed and forwarded, N sized to ~3 fps at capture rate": capture every repaint and do the rate reduction at the emit boundary instead. The link still carries at most 5 fps; one pin-able clamp (`clampFps`) replaces a second, untested N-sizing formula; dropped frames cost no base64 and no plane↔node bytes, which is the spec's own concern; and every capture gets its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one rather than an N-conditional variant). On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). +- [ ] **Step 3: Implement** with global `fetch` + global `WebSocket` (bun provides both; no new dependency). Sequence per spec §5: `GET http://127.0.0.1:/json/list` -> newest `type === "page"` target (else first) -> validate the ws URL host is loopback per the Global Constraint: `127.0.0.1`, `localhost`, OR `::1` (a URL reports the v6 host bracketed - `[::1]` - so compare the parsed `hostname` without brackets; the constraint already names `::1`, the step's list must not silently narrow it) -> `Page.enable` -> `Page.startScreencast { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }` (decision 8, a recorded deviation at the reduction SITE from spec §4's pacing, which reads "Screencast JPEG at quality 60, `everyNthFrame` tuned to ≤ 5 fps (bridge clamps `fps` to [1, 5])": the quality and the 5 fps ceiling stand; the deviation is WHERE the reduction happens - capture every repaint and do the rate reduction at the emit boundary instead. The link still carries at most 5 fps; one pin-able clamp (`clampFps`) replaces an untested `everyNthFrame` sizing formula; dropped frames cost no base64 and no plane↔node bytes, which is the spec's own concern; and every capture gets its immediate `Page.screencastFrameAck`, so the ack contract Step 1 pins stays the literal CDP one rather than an N-conditional variant). On frame notifications: ack immediately, always; throttle emissions to `clampFps(fps)` (`Math.ceil(1000 / fps)` interval, extras dropped); no more than `PREVIEW_WATCH_FRAMES_BUFFERED` frames queued at the emit boundary: while `emitStream` is behind, frames past the cap are DROPPED, the queue drains as the pump accepts, and emission resumes (the link has no per-event ack - `node-results.ts` freezes `result{data}` as the only ack vocabulary - so the cap counts bridge-held frames, not wire promises; drop, never backpressure the browser - spec §5). NO traffic-based self-close: a static page repaints nothing and must keep its watch, and the spec's 60 s idle (spec §5) is ZERO-SUBSCRIBER idle, which is plane-side refcount state the node cannot see - the plane already closes the bridge on last-unsubscribe. Self-close only on lifetime expiry (emit `preview_closed` with `idle`) and target death (`target-gone`). - [ ] **Step 4: Branch wiring:** `preview-open-exec.ts` screencast branch: gate-first, pane-pid check, then the SHARED node budget: spec §6's "16 per node" is ONE counter over BOTH registries - sixteen of any mix - so the sum check is the EXEC's gate on BOTH branches, before either mechanism starts (the lane path consults `bridgeCount()`, the bridge path consults `forwardCount()`; a sum check that lives only inside the screencast branch is how 32 total sessions come back - Task 5's lane-only check gets AMENDED here, not duplicated): `preview-watch-over-node-cap` BEFORE the CDP handshake, `preview-lane-over-node-cap` BEFORE registration or dial, whenever `forwardCount() + bridgeCount()` is at `PREVIEW_SESSIONS_PER_NODE` (a second per-registry cap would let one node hold 32 preview sessions on the limit the spec named, and a dial-then-refuse would leave a live CDP connection behind for a request that was never going to register; a refusal still names its own kind). The lane-side amendment and the `forwardCount()` export `forward.ts` gains are owned by this task's Files - Step 1's two-direction case pins both halves. Result `{ ref, subscribers: 1 }` per Task 1's validator. - [ ] **Step 5: Green + commit** `feat(node): CDP screencast bridge, always-ack with dropping backpressure`. @@ -451,10 +451,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; bufferedAmount?: number }` (declared and exported in `preview-broker.ts`; `LiveWsSocket` is structurally compatible, carries send/readyState/close today, and Task 11 adds the OPTIONAL `bufferedAmount` in that interface's optional-member habit - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `if ((socket.bufferedAmount ?? 0) > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the member, Task 11 wires the real Bun socket that supplies it); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; getBufferedAmount?: () => number }` (declared and exported in `preview-broker.ts`; what gets STORED is NOT the `LiveWsSocket` wrapper - measured under this repo's Elysia 1.4.24 on Bun 1.4.2, the per-callback wrapper exposes neither a `bufferedAmount` property nor a `getBufferedAmount` method, and the raw Bun `ServerWebSocket` carries the METHOD, never a property; the repo already lives with this asymmetry (`remote-subshell-ws.ts:79`: "Bun's server-side ws exposes `getBufferedAmount`", the type at `:82`, read through `ws.raw` at `:243`), so Task 11 stores a two-member adapter (`send` delegating to `ws.send`, `getBufferedAmount` to that precedent's `?? 0` read over `ws.raw`), safe because detach is keyed by `streamViewerId`, never by socket identity - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `const debt = socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the METHOD (one case builds the fake WITHOUT it, pinning the zero-debt read), Task 11's adapter supplies it from the raw Bun socket); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); the fake carries debt as a METHOD (`getBufferedAmount`), and a sibling fake built WITHOUT it must keep frames flowing at the `?? 0` read (the property spelling is the trap the real platform does not have); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep - the sweep skips `kind: "screencast"` sessions entirely, their life is refcount/revocation/node-close/lifetime, per Step 1's sweep case; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -472,7 +472,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: broker (Task 8), `loadSubshellAccess` (`src/lib/subshell-access.ts:72`), `accessAtLeast` (:47), `getPreviewEnabled` (Task 3), node online/ready facts already carried by the node registry. - Produces (spec §6's exact paths and shape): `GET /api/subshells/:id/preview` -> `{ ports, cdpPort, screen, screenLost, nodeEnabled, reason }` (`nodeEnabled: false` + tooltip `reason` when the node is offline/held/too-old or the switch is off; ports `[]` when the pane is closed - and the route gets THAT answer without an RPC: a terminated/exited row short-circuits route-side to `{ ports: [], cdpPort: null, screen: null }` (no `screenLost`: a closed pane has no display that could die), while `nodeEnabled`/`reason` still describe the NODE, per the rest of this shape - the broker's refusal-mapping rule applies ONLY to live panes whose node-side answer failed, never to a pane that is gone; `nodeEnabled: TRUE` + `reason: "preview-ports-unsupported"` + `ports: []` when the node is online, switch-on, and non-Linux - the ports RPC's named refusal surfaces HERE, truthfully, because Task 14's mac leg asserts exactly this and a blank `[]` with no reason reads as "nothing is listening"); `screenLost` is the binary name when the pane's display died mid-pane - Task 7's marker arrives on the ports answer and passes through named, never inferred, with `screen: null` alongside it (spec §9), and the info schema declares the member as the optional union it is); `POST /:id/preview/open` body `{ port }` -> `{ proxyBase: "/pv//" }`; `POST /:id/preview/watch` body `{ port? }` (port defaults to the cached `cdpPort`; neither -> 400 naming it) -> `{ ok: true }` (no ref is handed out: `stopWatch` is pane-keyed, which is all the DELETE carries); `DELETE /:id/preview/watch` (caller unsubscribes; idempotent); `POST /api/subshells` body gains optional `screen: { width, height }` (t.Integer 320..4096 both, every property described). -- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads); a node fake answering `screenLost: "x11vnc"` with `screen: null` passes BOTH through unchanged (the named-cause pair spec §9 demands - a route that swallowed the marker would render silence); and the CLOSED-pane row: info on a terminated pane answers `{ ports: [], cdpPort: null, screen: null }` with the broker spy UNTOUCHED (no RPC leaves the route - :471's short-circuit; applying the refusal-mapping rule here would answer `nodeEnabled: false` naming a ports refusal for a pane that simply is not there). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). +- [ ] **Step 1: Write the auth matrix test first** (it is the deliverable's spine; clone the ssh route tests' actor tables). Rows: owner and `edit` grant -> 200 on open/watch/info; `view` grant -> 200 on info and watch POST/DELETE, 403 on open; foreign account -> 404 on all four; a pane's own subshell token -> resolves as its OWNER with shares off (the `docs/security.md` §4 sibling posture): 200 on the owner's own rows, 404 on foreign - pin BOTH halves with a comment citing §4; node key -> 401 (`UnauthorizedError`, the guard's explicit node-key rejection; §5.5 - pin against the `auth-guard-bearer.test.ts:167` precedent, the REST guard is a 401 surface, not 403); system key -> nowhere without a deliberate grant, and a granted `edit` row DOES reach it (`docs/security.md` §3; pin the positive too); a SCOPED key lacking the route's annotation permission -> refused at the annotation with the NAMED-permission branch (`This API key needs the ${requiredSystemPermission} permission.`; `auth-guard.ts:201-213` - PR #424 added that branch, and the "isn't available to scoped API keys" sibling message now fires only on UNANNOTATED routes; legacy unversioned keys carry `systemKeyPermissions === null` and pass the gate untouched, which is exactly why the pane-visibility rows above still carry the weight); the pane routes are annotated like their siblings: info/watch GET/POST/DELETE carry `subshells:read`, open carries `subshells:execute`, the same split `get-subshell-log` and `create-subshell` draw. Body bounds: port 0 / 70000 / 1.5 / string -> 400 at the schema; watch with no `cdpPort` cached and no `port` -> 400; watch naming a port the live bridge doesn't hold -> 409 (the `watch-port-conflict` broker refusal, surfaced named - the route test proves the mapping, Task 8's unit test owns the rule). Plus ONE line in `plugins/__tests__/request-log-ignore.test.ts` for the info-poll path - the Files list names `plugins/context.plugin.ts` and that test, and no other step touches either, so without this row the spec §7 ignore entry is a Files entry only; Step 3 makes it pass (clone the shape Task 10 pins for `/^\/pv\//`, whose own test rides ITS Step 1 for exactly this reason). Info: offline node answers the SAME shape with `nodeEnabled: false` and a reason (never an error); online switch-on non-Linux fake node answers `nodeEnabled: true` with `reason: "preview-ports-unsupported"` and `ports: []` (the truth the mac leg of Task 14 reads); a node fake answering `screenLost: "x11vnc"` with `screen: null` passes BOTH through unchanged (the named-cause pair spec §9 demands - a route that swallowed the marker would render silence); and the CLOSED-pane row: info on a terminated pane answers `{ ports: [], cdpPort: null, screen: null }` with the broker spy UNTOUCHED (no RPC leaves the route - this task's Produces short-circuit; applying the refusal-mapping rule here would answer `nodeEnabled: false` naming a ports refusal for a pane that simply is not there). Plus the create-path case Step 4 turns green: a POST `/api/subshells` carrying `screen` against a fake node whose preview mirror is OFF refuses with the named 400 remedy while the fake's sendCommand spy records NO `launch` command sent (the preflight runs BEFORE the RPC - pinned as a failing test here so it can actually go red). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement routes** (schemas as named constants, `detail: { operationId, tags: ["subshells"] }`, mounted into the already-mounted subshells instance in `index.ts` - the Elysia `App` TS2589 trap says fold, do not re-mount). - [ ] **Step 4: create + exit paths:** `screen` optional body -> `remote-launcher.ts` includes it on the `launch` command (preflight `getPreviewEnabled(nodeId)` false -> 400 naming the remedy BEFORE the RPC, mirroring spec §6's refusal wording; the node gate in Task 7 remains the authority); ack with `screen` echo -> `broker.onDisplayOpened`; `subshell-exit.route.ts` -> `broker.closeForPane(paneId, "pane-gone")`. The Step-1 create-path case turns green here: the preflight's refusal means no pane process and no `launch` on the wire. @@ -512,7 +512,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc ### Task 11: Live feed plumbing and the §11.14 control **Files:** -- Modify: `ws/live-ws.ts` (the OPEN handler mints the per-connection attach key beside `liveViewerId`: `ws.data.streamViewerId = crypto.randomUUID()` (the socket-lifetime id habit at `subshell-ws.ts:173` - a reconnect is legitimately a new viewer; `data` is the only thing that survives Elysia's per-callback wrapper rebuild, `live-ws.ts:36-42`, and Task 8's registry keys on THIS, never on the socket object and never on `liveViewerId` - that one is the user id at :147, and two tabs of one account are two viewers); client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, ws.data.streamViewerId, holderId, ws)` / `detachStream(paneId, ws.data.streamViewerId)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(ws.data.streamViewerId)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (two things: the `LiveWsSocket` interface gains `readonly bufferedAmount?: number` with a comment in the readyState habit at :60-61: Bun's ServerWebSocket supplies it, a hand-rolled double may omit it and reads as zero debt; its `data` block gains `streamViewerId?: string` beside `liveViewerId` (the open handler's mint and every detach call site read the field; without it the type refuses both); and the module holds the broker's production wiring - at module init, before any frame can attach a socket, it calls the Task 8 setter `configurePreviewStreamSender(sendToSockets)`, imported from `./live-registry.js`, so THIS file is the broker's ONLY crossing to the ws layer - a single VALUE import the structural type keeps minimal; `services/preview-broker.ts` is NOT an edit site: it imports nothing from `ws/`, and unconfigured it never fans out), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). +- Modify: `ws/live-ws.ts` (the OPEN handler mints the per-connection attach key beside `liveViewerId`: `ws.data.streamViewerId = crypto.randomUUID()` (the socket-lifetime id habit at `subshell-ws.ts:173` - a reconnect is legitimately a new viewer; `data` is the only thing that survives Elysia's per-callback wrapper rebuild, `live-ws.ts:36-42`, and Task 8's registry keys on THIS, never on the socket object and never on `liveViewerId` - that one is the user id at :147, and two tabs of one account are two viewers); client-frame switch: `watch`/`unwatch` -> `broker.attachStream(paneId, ws.data.streamViewerId, holderId, { send: (d) => ws.send(d), getBufferedAmount: () => (ws.raw as { getBufferedAmount?: () => number } | undefined)?.getBufferedAmount?.() ?? 0 })` (the adapter from Task 8's interface note; the `ws.raw` cast is the `remote-subshell-ws.ts:82-243` precedent, restated inline because that type is not exported) / `detachStream(paneId, ws.data.streamViewerId)` after a `loadSubshellAccess` >= view check against the socket's holder - the frame carries only the pane id and attach needs nothing more than the holder id that check already produced (Task 8's `closeUnauthorizedForPane` re-asks by holder), bridge lifecycle stays on the REST path so no nodeId/port resolution lives here; socket close -> `broker.detachSocketsFor(ws.data.streamViewerId)`), `ws/live-registry.ts` (add `sendToSockets(sockets: Iterable, frame: PreviewStreamFrame)` - the two named types Task 8 and Task 2 export (a bare `LiveServerFrame` is a TS2314: it takes two type params with no defaults, `live-frames.ts:26`; and these are not DOM `WebSocket`s) - per-SOCKET, because the §11.14 matrix row demands "not the owner's sockets that never watched" and a user-keyed send cannot express that; leave the existing publish path untouched), `ws/live-ws.ts` again (one interface note, then the changes: `LiveWsSocket` gains NO byte-debt member: the wrapper supplies neither the property nor the method (Task 8's interface note carries the measurement), so debt reaches the broker only through Task 11's adapter; its `data` block gains `streamViewerId?: string` beside `liveViewerId` (the open handler's mint and every detach call site read the field; without it the type refuses both); and the module holds the broker's production wiring - at module init, before any frame can attach a socket, it calls the Task 8 setter `configurePreviewStreamSender(sendToSockets)`, imported from `./live-registry.js`, so THIS file is the broker's ONLY crossing to the ws layer - a single VALUE import the structural type keeps minimal; `services/preview-broker.ts` is NOT an edit site: it imports nothing from `ws/`, and unconfigured it never fans out), `ws/__tests__/live-ws.test.ts`, `ws/__tests__/live-consistency.test.ts`, `ws/ws.plugin.ts` (its `/ws/live` registration comment promises "`watch-previews` arrives with the preview work" - that forward-reference names a label the shipped code never uses; update the sentence to the frame names this task actually implements, `watch`/`unwatch`, so no shipped comment pins a type that does not exist). - Test: extend both files. **Interfaces:** @@ -537,7 +537,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Consumes: Task 9 routes via the Eden treaty client (`bunx turbo build --filter=@internal/server` after routes land), Task 11 frames via the bus, row `access` exactly as `subshell-terminal.tsx` reads it. - Produces: bus `subscribePreview(paneId, cb: (frame) => void): () => void`; hook `{ info, openPort, startWatch, stopWatch, refresh }` where `startWatch` does BOTH halves of a watch (REST POST for the bridge, then `send({"type":"watch","id":paneId})` on the live socket for THIS device's stream registration - and ONLY if the REST half returned ok: a 400/409 refusal (`watch-port-conflict` among them) stops there, sends no live frame, and renders the named error, because a socket attached to a bridge this tab didn't join would paint one browser's canvas from another port's; the next info refresh carries the pane's current `cdpPort`) and `stopWatch` mirrors it (DELETE + `unwatch`) - the REST half alone starts a bridge whose frames have no recipient socket, and unit tests pass while the real browser sees nothing. -- [ ] **Step 1: Tests** (render-level): `view` render shows Watch toggles and no Open/Screen controls, each Open control tooltip-disabled with the REAL reason string (`nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` adds Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age and rate read the ARRIVAL clock - age is now minus the newest frame RECEIVED - because the live frame carries no wall-clock field: `ts` exists on the node->plane `preview_stream` EVENT only, and spec §6's browser shape is `{ id, seq, w, h, blob }` (round 11 caught this clause citing a field the shape does not have); one clock also makes the badge agree with the 5 s stale rule, which has always been arrival-based. Frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). +- [ ] **Step 1: Tests** (render-level): `view` render shows the Watch toggles, and the Open and Screen controls on their rows PRESENT but tooltip-disabled with the REAL reason (spec §7 pins Open "tooltip-disabled with the real reason when `access === \"view\"` or the node switch is off", and its gating trio - disabled control + `disabledReason` tooltip - means a gated control must EXPLAIN itself, never vanish; a `screen: null` row's missing Screen control is state-driven ABSENCE, not gating, and the screenLost case owns that); the disabled-reason clause covers both spec conditions (`access === \"view\"` -> the view-tier wording; `nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` UNLOCKS Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age and rate read the ARRIVAL clock - age is now minus the newest frame RECEIVED - because the live frame carries no wall-clock field: `ts` exists on the node->plane `preview_stream` EVENT only, and spec §6's browser shape is `{ id, seq, w, h, blob }` (round 11 caught this clause citing a field the shape does not have); one clock also makes the badge agree with the 5 s stale rule, which has always been arrival-based. Frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7); Watch paints the fps/age badge beside the canvas, and a named `screenLost` (with `screen` null) reads as the loss line - there is no hint to swap, no `screen` means no Screen control. - [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). From 256c9830fa9ce1708b075eb3db861b10f6c84887 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 18:02:57 -0700 Subject: [PATCH 026/139] Fold review round-14 decomposition into the plan --- docs/superpowers/plans/2026-10-21-pane-preview.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 90a62f767..624d8b101 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -314,7 +314,7 @@ git commit -m "feat(protocol): preview lanes, screencast events, and the owner s - Read: `apps/server/api/src/services/nodes/ssh-enabled.ts`, its route and test (`grep -rln "set_ssh_enabled" apps/server/api/src/api apps/server/api/src/services`), node half: `grep -rn "sshEnabled" apps/node/agent/src`, the `set_ssh_enabled` executor in `apps/node/agent/src/commands/`. - Create: `apps/node/agent/src/preview/flags.ts`, `apps/node/agent/src/commands/set-preview-enabled-exec.ts`, `apps/server/api/src/services/nodes/preview-enabled.ts`, the route file beside the ssh one, `apps/server/api/src/db/migrations/0062-node-preview-enabled.ts` (+ its migration test, cloning `migrations/__tests__/0047-node-ssh-enabled.test.ts`). - Modify: `packages/subshell-protocol/src/api-key-permissions.ts` (the `node-security` group gains `{ id: "nodes:preview", label: "Preview panes", description: "Turn pane preview on or off on a node." }` beside `nodes:ssh` at :36): the ssh route is annotated `systemPermission: "nodes:ssh"` (`set-node-ssh-enabled.route.ts:82`) and a route's permission string is a catalog member (`auth-guard.ts:194`), so the preview switch needs its OWN id - reusing `nodes:ssh` would let every ssh-scoped key flip preview, and the catalog feeds the key-minting UI's checkboxes, so it is a public-surface entry, not an internal string. -- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416; AND the heartbeat call - `maybeReportPreviewEnabled(ctx)` beside `maybeReportSshEnabled` at :1317), `apps/node/agent/src/commands/report.ts` (`reportPreviewEnabled` + `maybeReportPreviewEnabled`, cloning `reportSshEnabled` at :163 (send the mirror verbatim + stash the memo) and `maybeReportSshEnabled` at :186 (report only when the mirror moved since this connection last spoke about it)), `apps/node/agent/src/commands/context.ts` (the `lastReportedPreviewEnabled` memo beside the ssh one - the memo is what keeps a plane-pushed `set_preview_enabled` from echoing back as an event), `apps/server/api/src/services/nodes/node-events.ts` (the `NodeLifecycleHooks` member `onPreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined)` beside `onSshEnabled` at :114), `apps/server/api/src/services/nodes/node-ws-handler.ts` (the ready-path read beside `hooks.onSshEnabled` at :1099 AND a `case "preview_enabled":` arm cloning :1170 - the switch has no default branch, so an event with no arm lands dead and every other suite stays green), `apps/server/api/src/index.ts` (import + the hook wiring `onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported)` beside :330), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp). +- Modify: `apps/node/agent/src/commands/index.ts`, `apps/node/agent/src/daemon.ts` (ready field, mirror the `sshEnabled` wiring at :362/:416; AND the heartbeat call - `maybeReportPreviewEnabled(ctx)` beside `maybeReportSshEnabled` at :1317), `apps/node/agent/src/commands/report.ts` (`reportPreviewEnabled` + `maybeReportPreviewEnabled`, cloning `reportSshEnabled` at :163 (send the mirror verbatim + stash the memo) and `maybeReportSshEnabled` at :186 (report only when the mirror moved since this connection last spoke about it)), `apps/node/agent/src/commands/context.ts` (the `lastReportedPreviewEnabled` memo beside the ssh one - the memo is what keeps a plane-pushed `set_preview_enabled` from echoing back as an event), `apps/server/api/src/services/nodes/node-events.ts` (the `NodeLifecycleHooks` member `onPreviewEnabled(nodeId, reported: NodePreviewEnabledWire | undefined)` beside `onSshEnabled` at :114), `apps/server/api/src/services/nodes/node-ws-handler.ts` (the ready-path read beside `hooks.onSshEnabled` at :1099 AND a `case "preview_enabled":` arm cloning :1170 - the switch has no default branch, so an event with no arm lands dead and every other suite stays green), `apps/server/api/src/index.ts` (import + the hook wiring `onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported)` beside :330), the route mount, `apps/server/api/src/db/migrate.ts` (register `0062-node-preview-enabled` in the provider map - name and key must match), `apps/server/api/src/db/types/` node row type + `apps/server/api/src/db/repositories/nodes.repository.ts` (`setPreviewEnabled` beside `setSshEnabled` at :279, same `{on, changedAt}` contract whose comment explains the caller-supplied stamp), `apps/server/api/src/api/nodes/node-view.ts` (the NodeView schema member + `toNodeView` mapping beside `sshEnabled` at :180/:485 - the route's 200 body serializes through it, so the `previewEnabled` the client card reads exists nowhere else). - Test: plane route test + `apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts` (clone the ssh one's two layers: the pure decide table, and the hook body with one real row and one scripted node whose push lands in the real wire - the `ssh-enabled.test.ts:21-22` seam, which is what proves the `set_preview_enabled` frame parses against the shipped parser) + the dispatch rows in `apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts` (clone the `ssh_enabled` DISPATCH QUARTET, not just the event row - the row titles on disk are the anchors: "a ready CARRYING sshEnabled reconciles it", "a ready WITHOUT it still reconciles" (its `undefined` report IS the fail-closed beat the ssh suite calls load-bearing, and connect-time mirror repair lives or dies there), the EVENT row at :937-944, and "a ready whose sshEnabled field is malformed keeps the ready and drops the field". Feed the `preview_enabled` shapes and the ready variants through `handleNodeMessage` and assert the lifecycle hook saw each - the ready-path read is a Modify line this quartet is the ONLY thing that reaches, and the switch has no default, so an omitted arm passes every other suite) + `apps/node/agent/src/__tests__/preview-flags.test.ts`. **Interfaces:** @@ -451,10 +451,10 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what Global Constraint :22 actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; getBufferedAmount?: () => number }` (declared and exported in `preview-broker.ts`; what gets STORED is NOT the `LiveWsSocket` wrapper - measured under this repo's Elysia 1.4.24 on Bun 1.4.2, the per-callback wrapper exposes neither a `bufferedAmount` property nor a `getBufferedAmount` method, and the raw Bun `ServerWebSocket` carries the METHOD, never a property; the repo already lives with this asymmetry (`remote-subshell-ws.ts:79`: "Bun's server-side ws exposes `getBufferedAmount`", the type at `:82`, read through `ws.raw` at `:243`), so Task 11 stores a two-member adapter (`send` delegating to `ws.send`, `getBufferedAmount` to that precedent's `?? 0` read over `ws.raw`), safe because detach is keyed by `streamViewerId`, never by socket identity - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `const debt = socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the METHOD (one case builds the fake WITHOUT it, pinning the zero-debt read), Task 11's adapter supplies it from the raw Bun socket); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what the Authz Global Constraint actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; getBufferedAmount?: () => number }` (declared and exported in `preview-broker.ts`; what gets STORED is NOT the `LiveWsSocket` wrapper - measured under this repo's Elysia 1.4.24 on Bun 1.4.2, the per-callback wrapper exposes neither a `bufferedAmount` property nor a `getBufferedAmount` method, and the raw Bun `ServerWebSocket` carries the METHOD, never a property; the repo already lives with this asymmetry (`remote-subshell-ws.ts:79`: "Bun's server-side ws exposes `getBufferedAmount`", the type at `:82`, read through `ws.raw` at `:243`), so Task 11 stores a two-member adapter (`send` delegating to `ws.send`, `getBufferedAmount` to that precedent's `?? 0` read over `ws.raw`), safe because detach is keyed by `streamViewerId`, never by socket identity - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `const debt = socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the METHOD (one case builds the fake WITHOUT it, pinning the zero-debt read), Task 11's adapter supplies it from the raw Bun socket); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. -- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref, audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); the fake carries debt as a METHOD (`getBufferedAmount`), and a sibling fake built WITHOUT it must keep frames flowing at the `?? 0` read (the property spelling is the trap the real platform does not have); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. +- [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref from `crypto.randomBytes(16).toString("hex")` - the ref doubles as the `/pv` path credential, so spec §10's "secret unguessability" is a property of THIS mint, not of the format regex (a `Math.random()` hex still matches `PREVIEW_REF_RE`), and a dedicated case asserts two opens for one pane mint DIFFERENT refs - audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); the fake carries debt as a METHOD (`getBufferedAmount`), and a sibling fake built WITHOUT it must keep frames flowing at the `?? 0` read (the property spelling is the trap the real platform does not have); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement** as a module singleton with `resetForTests({ now?, sendToSockets?, send?, linkSend? })` per `.claude/rules/code-style.md`, and EXPORT the production twin `configurePreviewStreamSender(send)` beside the accessor - one-shot, filling only the fan-out slot (tests inject with `resetForTests`, production wires with the setter; Task 11 owns the call) (fake clock for the idle sweep - the sweep skips `kind: "screencast"` sessions entirely, their life is refcount/revocation/node-close/lifetime, per Step 1's sweep case; `send` fakes the command RPC; `linkSend` fakes `preview-frames.ts`, the byte-frame path Task 10's fake node hangs off), timers self-expiring (read `ssh-relay.service.ts`'s broker for the habit); every SESSION-level close path (forward session, watch bridge, display) audits its pair with the named reason - a lane close is a session's plumbing and audits nothing by itself - the one exception is the sender-cap `over-cap` death named above, which audits `node.preview_lane.over_cap` because the spec names that close in its audit-row sentence; a NODE-reported lane close (the node enforcing its own pump) stays wire-only, since spec §9's actions are the plane's own acts and the reason word already reaches the viewer on the wire. A node-reported `preview_closed` on a screencast ref tears the plane's half down WITH it, not just the subscribers: drop the pane's `PreviewSession`, zero its viewer counts, `detachStream` its attached sockets (never `close` - frames simply stop, and the client's stale-frame wording is the user's signal and the watch toggle the remedy), audit `node.preview_watch.close` once with the reported reason; the next `startWatch` then hits the zero->one edge and re-sends `preview_open`, because "a matching port just joins" may only ever join a LIVE bridge. - [ ] **Step 4: Green + commit** `feat(server): preview broker with lane pools, refcounted watches, and revocation`. @@ -504,7 +504,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - lane-cap refusal, browser-facing: a request whose `openLaneForConnection` is refused by the queue overflow answers `503` with `preview-lane-over-cap` in the body, and an upgrade is refused with the same named code - never a hang, a queue-jump, or an anonymous 500 (the refusal itself is Task 8's broker test; this pins what the PROXY does with it, which no other task observes). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store ONLY the rewritten `name=value` pair - never the attributes - in `session.cookieJar` keyed by cookie name (the jar's values ARE the pairs attached to later requests, so a value still wearing `Path=` or `Expires=` would ship as a bogus cookie; same name replaces, and name-only keying with last-write-wins across paths is the deliberate v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the six verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the seven verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- From 84abc6df4d84e98b5b46d41f62cd7c0f41dd3a37 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 19:01:57 -0700 Subject: [PATCH 027/139] feat(protocol): preview lanes, screencast events, and the owner switch (protocol 23, floor 2.4.0) --- apps/node/agent/package.json | 2 +- .../agent/src/__tests__/commands-ssh.test.ts | 6 +- .../__tests__/ssh-relay.service.test.ts | 4 +- bun.lock | 2 +- .../src/__tests__/node-frames.test.ts | 289 ++++++++++++- .../src/__tests__/node-results.test.ts | 91 +++++ .../src/__tests__/relay-frames.test.ts | 12 +- packages/subshell-protocol/src/index.ts | 39 ++ packages/subshell-protocol/src/node-frames.ts | 382 +++++++++++++++++- .../subshell-protocol/src/node-results.ts | 180 ++++++++- packages/subshell-protocol/src/ssh-limits.ts | 31 ++ packages/subshell-protocol/src/versions.ts | 8 +- 12 files changed, 1018 insertions(+), 28 deletions(-) diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index 171887796..5f2b60da9 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -1,7 +1,7 @@ { "name": "@internal/node", "description": "subshell — node daemon: enrolls with the control plane and executes signed commands", - "version": "2.3.3", + "version": "2.4.0", "license": "Apache-2.0", "type": "module", "private": true, diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts index 08af474f5..2ca253c71 100644 --- a/apps/node/agent/src/__tests__/commands-ssh.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -238,7 +238,7 @@ describe("ssh_key_bridge: a closed gate or maintenance destroys live signing, no }); }); -/** Desktop signing requires the temporary key bridge command on the connecting node. */ -it("NODE_PROTOCOL_VERSION is 22 (temporary desktop key bridge)", () => { - expect(NODE_PROTOCOL_VERSION).toBe(22); +/** Follows each protocol bump: the exact-match gate refuses stale binaries outright. */ +it("NODE_PROTOCOL_VERSION is 23 (current bump: the preview surface)", () => { + expect(NODE_PROTOCOL_VERSION).toBe(23); }); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index 87a4ca3b9..90450521b 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -704,10 +704,10 @@ describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptan }); describe("grammar/version posture", () => { - it("relays a protocol-22-only world: the constant is pinned here as the broker's precondition", () => { + it("relays a protocol-23 world: the constant is pinned here as the broker's precondition", () => { // The handler refuses every relay frame from a pre-18 agent upstream; // this tripwire documents the dependency at the broker. - expect(NODE_PROTOCOL_VERSION).toBe(22); + expect(NODE_PROTOCOL_VERSION).toBe(23); }); }); diff --git a/bun.lock b/bun.lock index 90eb719d5..1bd5372f9 100644 --- a/bun.lock +++ b/bun.lock @@ -127,7 +127,7 @@ }, "apps/node/agent": { "name": "@internal/node", - "version": "2.3.3", + "version": "2.4.0", "dependencies": { "@clack/prompts": "1.8.1", "@internal/backend-errors": "workspace:*", diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index c06e703d8..4a934ed6c 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -19,10 +19,15 @@ import { NODE_RESULT_VERSION_MISMATCH, NODE_SERVICE_DESTRUCTIVE, NODE_SERVICE_VERBS, + PREVIEW_CLOSE_REASONS, + PREVIEW_COMMAND_TYPES, + type PreviewDataFrame, + type PreviewOpenCommand, parseNodeCommandBody, parseNodeEvent, parseNodeRuntimeReport, parseNodeSshFingerprintReport, + parsePreviewFrame, partPathOf, } from "../node-frames.js"; import { @@ -36,6 +41,16 @@ import { type SshRelayOpenCommand, } from "../ssh-frames.js"; import { + PREVIEW_FPS_GRAMMAR_MAX, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_LIFETIME_MIN_MS, + PREVIEW_REF_RE, + PREVIEW_WATCH_FPS_DEFAULT, + PREVIEW_WATCH_FPS_MAX, + PREVIEW_WATCH_FPS_MIN, SSH_CONFIG_FILE_MAX_BYTES, SSH_EXEC_COMMAND_MAX_CHARS, SSH_EXEC_MAX_PRESET_FLAGS, @@ -395,7 +410,15 @@ describe("parseNodeCommandBody", () => { // / `ssh_relay_close` signed commands. A tier-17 agent understands no relay // frame, and the exact-match gate refuses it BEFORE any relay command // (spec §11: never half-relaying). - expect(NODE_PROTOCOL_VERSION).toBe(22); + // 23 is the preview surface (pane-preview spec 2026-10-21 §4): the + // `preview_open` / `preview_close` signed commands and the `preview_data` + // link frame carry a gated byte lane to a pane's loopback port, the + // `preview_stream` event carries read-only screencast frames, + // `preview_ports_get` reports a pane's listening ports, `set_preview_enabled` + // mirrors a per-machine owner switch, and a launch may carry a `screen` + // request. A pre-23 agent understands none of it, and the exact-match gate + // refuses pairing before any of it is sent. + expect(NODE_PROTOCOL_VERSION).toBe(23); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -1788,3 +1811,267 @@ describe("launch mcp companion files", () => { expect(launchWith("bridge.mjs")).toBeNull(); }); }); + +// Protocol 23 (pane-preview spec 2026-10-21 §4): the preview surface. What +// this file pins is what BOTH ends must agree on before anything is sent. The +// bridge's clamp, the lane quotas and the idle timer are enforced by Tasks 6 +// and 8; the grammar here is deliberately looser where the spec says so. +describe("preview lanes (arrived at protocol 23)", () => { + const REF = "a".repeat(32); + + it("preview_open round trips, both kinds", () => { + const fwd: PreviewOpenCommand = { + type: "preview_open", + ref: REF, + kind: "forward", + paneId: "p1", + port: 5173, + lifetimeMs: 3_600_000, + }; + expect(parseNodeCommandBody(JSON.stringify(fwd))).toEqual(fwd); + const sc: PreviewOpenCommand = { + type: "preview_open", + ref: REF, + kind: "screencast", + paneId: "p1", + port: 9222, + fps: 5, + lifetimeMs: 3_600_000, + }; + expect(parseNodeCommandBody(JSON.stringify(sc))).toEqual(sc); + }); + + it("the kind decides whether fps may appear, and fps stays optional", () => { + const noFps: PreviewOpenCommand = { + type: "preview_open", + ref: REF, + kind: "screencast", + paneId: "p", + port: 9222, + lifetimeMs: 60_000, + }; + const parsed = parseNodeCommandBody(JSON.stringify(noFps)); + expect(parsed).toEqual(noFps); + // Absent stays absent: the grammar does not default fps. The default to + // PREVIEW_WATCH_FPS_DEFAULT belongs at the bridge (Task 6), where the + // consumer is. + expect(parsed && "fps" in parsed).toBe(false); + expect(parseNodeCommandBody(JSON.stringify({ ...noFps, kind: "forward", fps: 5 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...noFps, kind: "nonsense" }))).toBeNull(); + }); + + it("refuses out-of-range numbers", () => { + const base = { type: "preview_open", ref: REF, kind: "forward", paneId: "p", port: 8080, lifetimeMs: 60_000 }; + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 70_000 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 0 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 65_535 }))).not.toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, port: 1.5 }))).toBeNull(); + // The grammar allows up to PREVIEW_FPS_GRAMMAR_MAX; the bridge clamps into + // [PREVIEW_WATCH_FPS_MIN, PREVIEW_WATCH_FPS_MAX] (Task 6). 30 is a rate the + // grammar accepts and the clamp drops, 61 is nonsense even for the grammar. + expect(parseNodeCommandBody(JSON.stringify({ ...base, kind: "screencast", fps: 30 }))).not.toBeNull(); + expect( + parseNodeCommandBody(JSON.stringify({ ...base, kind: "screencast", fps: PREVIEW_FPS_GRAMMAR_MAX + 1 })), + ).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, kind: "screencast", fps: 0 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, lifetimeMs: PREVIEW_LIFETIME_MIN_MS - 1 }))).toBeNull(); + expect(parseNodeCommandBody(JSON.stringify({ ...base, lifetimeMs: PREVIEW_LIFETIME_MAX_MS + 1 }))).toBeNull(); + }); + + it("refuses a short or malformed ref", () => { + // Lane refs double as proxy path secrets: 32 lowercase hex, nothing else. + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "x", reason: "viewer-left" }))).toBeNull(); + expect( + parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: "A".repeat(32), reason: "viewer-left" })), + ).toBeNull(); + }); + + it("launch accepts a screen size and bounds it", () => { + // A screen request rides beside cols/rows. The grammar only bounds the + // size (320..4096 on both dims); a machine with the owner switch off + // refuses at EXECUTION, before spawning (spec §4). + expect(parseNodeCommandBody({ ...launchCmd, screen: { width: 1280, height: 720 } })).toMatchObject({ + screen: { width: 1280, height: 720 }, + }); + expect(parseNodeCommandBody({ ...launchCmd, screen: { width: 100, height: 720 } })).toBeNull(); + expect(parseNodeCommandBody({ ...launchCmd, screen: { width: 1280, height: 5000 } })).toBeNull(); + expect(parseNodeCommandBody({ ...launchCmd, screen: { width: 1280 } })).toBeNull(); + const bare = parseNodeCommandBody(launchCmd); + expect(bare).toMatchObject({ type: "launch" }); + expect(bare && "screen" in bare).toBe(false); // absent is not defaulted + }); + + it("the close-reason census is pinned", () => { + // The plane maps a refusal per reason, so the set is closed on purpose: a + // new member is a wire change, and "because" must never reach a UI. + // `target-gone` is plan decision 3's member: the node's own answer for a + // lane whose target process died. + for (const reason of PREVIEW_CLOSE_REASONS) { + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: REF, reason }))).toEqual({ + type: "preview_close", + ref: REF, + reason, + }); + } + expect(parseNodeCommandBody(JSON.stringify({ type: "preview_close", ref: REF, reason: "because" }))).toBeNull(); + expect(PREVIEW_CLOSE_REASONS).toHaveLength(9); + }); + + it("the preview command census is hand-pinned, four types", () => { + // The SSH_COMMAND_TYPES tripwire again: a fifth preview command must be + // added HERE as well as to the dispatcher, or this fails. + expect([...PREVIEW_COMMAND_TYPES].sort()).toEqual([ + "preview_close", + "preview_open", + "preview_ports_get", + "set_preview_enabled", + ]); + expect(PREVIEW_COMMAND_TYPES).toHaveLength(4); + for (const type of PREVIEW_COMMAND_TYPES) { + const body = + type === "preview_open" + ? { type, ref: REF, kind: "forward", paneId: "p1", port: 5173, lifetimeMs: 60_000 } + : type === "preview_close" + ? { type, ref: REF, reason: "viewer-left" } + : type === "preview_ports_get" + ? { type, paneId: "p1" } + : { type, on: true, changedAt: "2026-10-22T00:00:00.000Z" }; + expect(parseNodeCommandBody(JSON.stringify(body))).not.toBeNull(); + } + }); + + it("pins the numbers both ends quote", () => { + // These live in the grammar now because BOTH ends read them, so a typo is + // a wire change: the clamp band and its default, the slow-link byte + // ceiling, the idle self-close, the ref shape. + expect([PREVIEW_WATCH_FPS_MIN, PREVIEW_WATCH_FPS_DEFAULT, PREVIEW_WATCH_FPS_MAX]).toEqual([1, 5, 5]); + expect(PREVIEW_WATCH_FPS_MAX).toBeLessThanOrEqual(PREVIEW_FPS_GRAMMAR_MAX); + expect(PREVIEW_LANE_BUFFER_MAX_BYTES).toBe(1_048_576); + expect(PREVIEW_IDLE_MS).toBe(900_000); + expect(PREVIEW_REF_RE.test("a".repeat(32))).toBe(true); + expect(PREVIEW_REF_RE.test("A".repeat(32))).toBe(false); + }); +}); + +// The ssh gate's mirror twin (plan decision 5): same {on, changedAt} shape, +// same division of strictness. The standalone carriers are STRICT because that +// frame IS the change, and a silently dropped one would leave the plane +// holding the opposite of what the machine says. `ready` is LENIENT because +// the rest of that frame is what brings the node online. +describe("preview owner switch (arrived at protocol 23)", () => { + const ready = { + type: "ready", + agentVersion: MIN_NODE_VERSION, + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const state = { on: true, changedAt: "2026-10-22T10:00:00.000Z" }; + + it("parses the set_preview_enabled command and refuses a partial one", () => { + expect(parseNodeCommandBody({ type: "set_preview_enabled", ...state })).toEqual({ + type: "set_preview_enabled", + ...state, + }); + expect(parseNodeCommandBody({ type: "set_preview_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "set_preview_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeCommandBody({ type: "set_preview_enabled", on: true })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_preview_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_preview_enabled", on: 1, changedAt: state.changedAt })).toBeNull(); + }); + + it("parses the preview_enabled event strictly", () => { + expect(parseNodeEvent({ type: "preview_enabled", ...state })).toEqual({ type: "preview_enabled", ...state }); + expect(parseNodeEvent({ type: "preview_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "preview_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeEvent({ type: "preview_enabled", on: "yes", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeEvent({ type: "preview_enabled", on: true })).toBeNull(); + }); + + it("carries previewEnabled on ready, and drops a malformed one without refusing the ready", () => { + expect(parseNodeEvent({ ...ready, previewEnabled: state })).toMatchObject({ type: "ready", previewEnabled: state }); + expect(parseNodeEvent(ready)).toMatchObject({ type: "ready" }); + const bad = parseNodeEvent({ ...ready, previewEnabled: { on: "yes" } }); + expect(bad?.type).toBe("ready"); + expect(bad && "previewEnabled" in bad ? bad.previewEnabled : undefined).toBeUndefined(); + }); +}); + +describe("preview link frames and events (arrived at protocol 23)", () => { + const REF = "a".repeat(32); + const frame: PreviewDataFrame = { + type: "preview_data", + ref: REF, + dir: "in", + blob: Buffer.alloc(10).toString("base64"), + }; + + it("round trips and enforces the raw blob cap", () => { + expect(parsePreviewFrame(frame)).toEqual(frame); + expect(parsePreviewFrame(JSON.stringify(frame))).toEqual(frame); + expect(parsePreviewFrame({ ...frame, dir: "out" })).toEqual({ ...frame, dir: "out" }); + // The cap is on the RAW payload bytes, not the base64 chars, exactly like + // RelayFrame's: the limit protects the pipes, and the encoding cost is not + // the thing being capped. + expect( + parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES + 1).toString("base64") }), + ).toBeNull(); + expect( + parsePreviewFrame({ ...frame, blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES).toString("base64") }), + ).not.toBeNull(); + expect(parsePreviewFrame({ ...frame, dir: "sideways" })).toBeNull(); + expect(parsePreviewFrame({ ...frame, ref: "x" })).toBeNull(); + expect(parsePreviewFrame({ ...frame, blob: "!!!" })).toBeNull(); + expect(parsePreviewFrame({ ...frame, blob: "" })).toBeNull(); + expect(parsePreviewFrame({ type: "pane_input", ref: REF, dir: "in", blob: "AAA=" })).toBeNull(); + }); + + it("the lane frame reaches the plane through the event union", () => { + // Inbound on the established link, so the event side must parse it too. + expect(parseNodeEvent({ ...frame })).toEqual(frame); + }); + + it("the screencast event parses and refuses junk", () => { + expect(parseNodeEvent({ type: "preview_stream", ref: REF, seq: 1, w: 800, h: 600, ts: 123, blob: "AAA=" })).toEqual( + { + type: "preview_stream", + ref: REF, + seq: 1, + w: 800, + h: 600, + ts: 123, + blob: "AAA=", + }, + ); + expect( + parseNodeEvent({ type: "preview_stream", ref: REF, seq: -1, w: 800, h: 600, ts: 123, blob: "AAA=" }), + ).toBeNull(); + expect( + parseNodeEvent({ type: "preview_stream", ref: REF, seq: 1, w: 0, h: 600, ts: 123, blob: "AAA=" }), + ).toBeNull(); + expect( + parseNodeEvent({ type: "preview_stream", ref: REF, seq: 1, w: 800, h: 600, ts: 123, blob: "!!" }), + ).toBeNull(); + }); + + it("the self-close event names a reason from the closed set", () => { + // Plan decision 3: a lane the node kills itself (target died, idle timer, + // revoked switch) must reach the plane, or the viewer waits on a lane + // nobody will ever answer. + expect(parseNodeEvent({ type: "preview_closed", ref: REF, reason: "target-gone" })).toEqual({ + type: "preview_closed", + ref: REF, + reason: "target-gone", + }); + expect(parseNodeEvent({ type: "preview_closed", ref: REF, reason: "because" })).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/node-results.test.ts b/packages/subshell-protocol/src/__tests__/node-results.test.ts index 331f7a94a..abcf965c6 100644 --- a/packages/subshell-protocol/src/__tests__/node-results.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results.test.ts @@ -5,6 +5,7 @@ import { MAX_MANIFEST_PAGE_ENTRIES, NODE_CLOSE_SUPERSEDED, NODE_CLOSE_UPDATE_REQUIRED, + type NodePreviewPortWire, parseNodeArchiveCreateResult, parseNodeArchiveExtractResult, parseNodeCaptureResult, @@ -13,10 +14,14 @@ import { parseNodeEvent, parseNodeFileReadResult, parseNodeFsLsResult, + parseNodeLaunchResult, parseNodeLogReadResult, parseNodePaneCursorResult, parseNodePaneSizeResult, parseNodePathExistsResult, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, + parseNodePreviewPortsResult, parseNodeProbeEntries, parseNodePromptDeliver, parseNodeStatDirResult, @@ -321,3 +326,89 @@ describe("detect result contract (inversion spec §4)", () => { } }); }); + +// Protocol 23 (pane-preview spec 2026-10-21 §4). Imported through the barrel: +// these are the shapes Task 4's node answers and the plane reads, and both +// ends validate them, so a drift here is a node that cannot report. +describe("preview results (protocol 23)", () => { + const REF = "a".repeat(32); + + it("preview_open answers by kind: a pending forward, a subscriber count for a screencast", () => { + expect(parseNodePreviewOpenResult({ ref: REF, pending: true })).toEqual({ ref: REF, pending: true }); + expect(parseNodePreviewOpenResult({ ref: REF, subscribers: 2 })).toEqual({ ref: REF, subscribers: 2 }); + expect(parseNodePreviewOpenResult({ ref: REF, subscribers: 0 })).toEqual({ ref: REF, subscribers: 0 }); // first, nobody else watching yet + expect(parseNodePreviewOpenResult({ ref: REF, pending: false })).toBeNull(); // no such answer exists + expect(parseNodePreviewOpenResult({ ref: REF })).toBeNull(); // neither shape + expect(parseNodePreviewOpenResult({ ref: "short", pending: true })).toBeNull(); + expect(parseNodePreviewOpenResult({ ref: REF, subscribers: 1.5 })).toBeNull(); + expect(parseNodePreviewOpenResult(null)).toBeNull(); + }); + + it("preview_close says whether a lane was actually closed", () => { + expect(parseNodePreviewCloseResult({ ref: REF, closed: true })).toEqual({ ref: REF, closed: true }); + expect(parseNodePreviewCloseResult({ ref: REF, closed: false })).toEqual({ ref: REF, closed: false }); // already gone is still an answer + expect(parseNodePreviewCloseResult({ ref: REF })).toBeNull(); + expect(parseNodePreviewCloseResult({ closed: true })).toBeNull(); + expect(parseNodePreviewCloseResult({ ref: REF, closed: "yes" })).toBeNull(); + }); + + it("ports_get reports ports, a nullable CDP hint, a nullable screen, and an optional screenLost", () => { + const ports: NodePreviewPortWire[] = [{ proto: "tcp", port: 5173, pid: 42 }]; + expect(parseNodePreviewPortsResult({ ports, cdpPort: null, screen: null })).toEqual({ + ports, + cdpPort: null, + screen: null, + }); + expect( + parseNodePreviewPortsResult({ ports: [{ proto: "tcp6", port: 3000, pid: 7 }], cdpPort: 9222, screen: null }), + ).not.toBeNull(); + expect( + parseNodePreviewPortsResult({ ports: [], cdpPort: null, screen: { display: ":1", rfbPort: 5901 } }), + ).not.toBeNull(); + // screenLost rides ONLY with a null screen: the answer says the display + // died and there is nothing left to report, never both at once. + expect(parseNodePreviewPortsResult({ ports, cdpPort: null, screen: null, screenLost: "Xvfb" })).toEqual({ + ports, + cdpPort: null, + screen: null, + screenLost: "Xvfb", + }); + expect( + parseNodePreviewPortsResult({ + ports, + cdpPort: null, + screen: { display: ":1", rfbPort: 5901 }, + screenLost: "x11vnc", + }), + ).toBeNull(); + expect(parseNodePreviewPortsResult({ ports, cdpPort: null, screen: null, screenLost: "desktop" })).toBeNull(); + expect( + parseNodePreviewPortsResult({ ports: [{ proto: "udp", port: 53, pid: 1 }], cdpPort: null, screen: null }), + ).toBeNull(); + expect( + parseNodePreviewPortsResult({ ports: [{ proto: "tcp", port: 0, pid: 1 }], cdpPort: null, screen: null }), + ).toBeNull(); + expect(parseNodePreviewPortsResult({ ports, cdpPort: null })).toBeNull(); // screen is required and nullable, not absent + expect(parseNodePreviewPortsResult({ ports, cdpPort: 0, screen: null })).toBeNull(); + expect(parseNodePreviewPortsResult({ ports, cdpPort: null, screen: { display: "", rfbPort: 5901 } })).toBeNull(); + expect( + parseNodePreviewPortsResult({ ports, cdpPort: null, screen: { display: ":1", rfbPort: 70_000 } }), + ).toBeNull(); + }); + + it("launch echoes a screen when it allocated one, and the absent answer stays absent", () => { + // This optional echo is why `launch` leaves the header's dataless set. + // null/undefined/{} all read as "no screen": the command carries data only + // when a display was allocated. + expect(parseNodeLaunchResult(undefined)).toEqual({}); + expect(parseNodeLaunchResult(null)).toEqual({}); + expect(parseNodeLaunchResult({})).toEqual({}); + expect(parseNodeLaunchResult({ screen: { display: ":1", rfbPort: 5901 } })).toEqual({ + screen: { display: ":1", rfbPort: 5901 }, + }); + expect(parseNodeLaunchResult({ screen: { display: ":1" } })).toBeNull(); + expect(parseNodeLaunchResult({ screen: { display: ":1", rfbPort: 0 } })).toBeNull(); + expect(parseNodeLaunchResult({ screen: "yes" })).toBeNull(); + expect(parseNodeLaunchResult("screen")).toBeNull(); + }); +}); diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts index bff181d8a..b6902c7a8 100644 --- a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -32,13 +32,13 @@ const good = { blob: b64OfRawLength(1024), } as const; -describe("NODE_PROTOCOL_VERSION 22 (companion integration files)", () => { +describe("NODE_PROTOCOL_VERSION 23 (current bump: the preview surface)", () => { it("pins the current version so a stale build cannot ride the exact-match gate", () => { - // Every capability added since relay frames (20) and the desktop signer - // bridge (21) depends on this number: the plane and the agent only speak - // on an EXACT match, so a binary that never learned companion files - // (22) is refused BEFORE any frame is accepted. Never half-relay. - expect(NODE_PROTOCOL_VERSION).toBe(22); + // Every capability added since relay frames (20) depends on this number: + // the plane and the agent only speak on an EXACT match, so a binary that + // never learned the preview surface (23) is refused BEFORE any frame is + // accepted. Never half-relay. + expect(NODE_PROTOCOL_VERSION).toBe(23); }); }); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 2ea5d2719..d054c6cb2 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -110,6 +110,7 @@ export { type NodeCommandBody, type NodeEvent, type NodeMaintenanceWire, + type NodePreviewEnabledWire, type NodeRuntimeReport, type NodeServiceVerb, type NodeSshEnabledWire, @@ -117,13 +118,23 @@ export { type NodeSshFingerprintReport, type NodeSshPeerFingerprint, type PluginReportWire, + PREVIEW_CLOSE_REASONS, + PREVIEW_COMMAND_TYPES, type PresetDefinitionWire, + type PreviewCloseCommand, + type PreviewCloseReason, + type PreviewDataFrame, + type PreviewLaneKind, + type PreviewOpenCommand, + type PreviewPortsGetCommand, parseNodeCommandBody, parseNodeEvent, parseNodeMaintenance, + parseNodePreviewEnabled, parseNodeRuntimeReport, parseNodeSshEnabled, parseNodeSshFingerprintReport, + parsePreviewFrame, parseRelayFrame, partPathOf, RELAY_DIRECTIONS, @@ -131,6 +142,7 @@ export { type RelayFrame, relayFrameOverCap, relayFrameRefIfOverCap, + type SetPreviewEnabledCommand, } from "./node-frames.js"; export { type DetectResultWire, @@ -140,11 +152,18 @@ export { type NodeArchiveExtractResult, type NodeFileReadResult, type NodeFsLsResult, + type NodeLaunchResult, type NodeLogReadResult, type NodeManifestEntryWire, type NodePaneCursorResult, type NodePaneSizeResult, type NodePathExistsResult, + type NodePreviewCloseResult, + type NodePreviewOpenResult, + type NodePreviewPortsResult, + type NodePreviewPortWire, + type NodePreviewScreenLost, + type NodePreviewScreenWire, type NodeProbeEntry, type NodePromptDeliverResult, type NodeStatDirResult, @@ -157,10 +176,14 @@ export { parseNodeDetectResults, parseNodeFileReadResult, parseNodeFsLsResult, + parseNodeLaunchResult, parseNodeLogReadResult, parseNodePaneCursorResult, parseNodePaneSizeResult, parseNodePathExistsResult, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, + parseNodePreviewPortsResult, parseNodeProbeEntries, parseNodePromptDeliver, parseNodeSshAgentIdentities, @@ -336,6 +359,22 @@ export { type SshKeyBridgeResult, } from "./ssh-key-bridge.js"; export { + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_FPS_GRAMMAR_MAX, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LANE_QUEUE_MAX, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_LIFETIME_MIN_MS, + PREVIEW_REF_RE, + PREVIEW_SESSIONS_PER_NODE, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_FPS_DEFAULT, + PREVIEW_WATCH_FPS_MAX, + PREVIEW_WATCH_FPS_MIN, + PREVIEW_WATCH_FRAMES_BUFFERED, + PREVIEW_WATCH_JPEG_QUALITY, SSH_CONFIG_FILE_MAX_BYTES, SSH_EXEC_COMMAND_MAX_CHARS, SSH_EXEC_MAX_PRESET_FLAGS, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index 1712cd87c..c37740a4c 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -2,7 +2,16 @@ import { BASE64_RE, isBool, isInt, isNum, isRecord, isStr, isStrArray, isStringM import type { JsonValue } from "./json.js"; import { type NodeSystemSpecs, parseNodeSystemSpecs } from "./node-system-specs.js"; import { isSshFingerprint, parseSshNodeCommandBody, type SshNodeCommandBody } from "./ssh-frames.js"; -import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS, SSH_RELAY_FRAME_MAX_BYTES } from "./ssh-limits.js"; +import { + PREVIEW_FPS_GRAMMAR_MAX, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_LIFETIME_MIN_MS, + PREVIEW_REF_RE, + SSH_CONFIG_FILE_MAX_BYTES, + SSH_PATH_MAX_CHARS, + SSH_RELAY_FRAME_MAX_BYTES, +} from "./ssh-limits.js"; /** * Node ↔ control-plane wire contract (spec 2026-08-31 §3). @@ -190,8 +199,22 @@ import { SSH_CONFIG_FILE_MAX_BYTES, SSH_PATH_MAX_CHARS, SSH_RELAY_FRAME_MAX_BYTE * * **21 -> 22:** a launch may carry bounded companion integration files beside * its MCP config, which only nodes that materialize them can run. + * + * **22 -> 23:** the preview surface. `preview_open` / `preview_close` signed + * commands and the `preview_data` link frame carry a gated byte lane between + * a dashboard viewer and a pane's loopback port (forward and CDP bridge + * kinds); the `preview_stream` event carries read-only screencast frames; + * `preview_ports_get` reports a pane's listening ports and CDP/screen hints; + * `set_preview_enabled` mirrors a per-machine owner switch, and the + * `preview_enabled` event reports a mirror that moved underneath the + * connection (ssh's `ssh_enabled` twin); a launch may carry a `screen` + * request. A lane the node ends on its own (target process gone, idle timer, + * revoked switch) says so through the `preview_closed` event, so the + * close-reason set includes `target-gone` (plan decision 3). An agent below + * 23 understands none of it, and the exact-match gate is what refuses pairing + * before any of it is sent. */ -export const NODE_PROTOCOL_VERSION = 22; +export const NODE_PROTOCOL_VERSION = 23; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -834,6 +857,8 @@ export type NodeCommandBody = cols?: number; /** Initial terminal geometry */ rows?: number; + /** Allocates a virtual display and starts the pane on it. Linux only; a machine with the switch off refuses before spawning (spec §4). */ + screen?: { width: number; height: number }; /** * Revive parity (phase-2): when true the agent downgrades a log-attach * failure (subshells-dir mkdir + pipe-pane) to a logged note and still @@ -1269,7 +1294,15 @@ export type NodeCommandBody = // The SSH commands: three members folded in as one union member so the SSH // grammar lives in `ssh-frames.ts` beside the commands it narrows; their // answers are validated by the ssh result parsers in `node-results.ts`. - | SshNodeCommandBody; + | SshNodeCommandBody + // The preview surface (protocol 23, spec 2026-10-21 §4): lane open/close, + // the one-pass ports walk, and the owner-switch mirror. Their answers are + // validated by the preview result parsers in `node-results.ts`; + // `set_preview_enabled`'s ack is never read (the ssh doctrine). + | PreviewOpenCommand + | PreviewCloseCommand + | PreviewPortsGetCommand + | SetPreviewEnabledCommand; /** * One node's maintenance state, as it travels in either direction. @@ -1319,6 +1352,28 @@ export function parseNodeSshEnabled(value: unknown): NodeSshEnabledWire | null { return { on: value.on, changedAt: value.changedAt }; } +/** + * One machine's preview capability, as it travels in either direction (spec + * 2026-10-21 §4): the same `{ on, changedAt }` shape as maintenance and the + * ssh gate, and the same division of authorship. `on` means this machine may + * serve preview lanes, and only the plane writes it; `changedAt` rides + * verbatim so a relay never outranks the decision it carries. Nothing here + * compares stamps: the plane is authoritative, and every disagreement ends + * with the plane's value pushed down. + */ +export interface NodePreviewEnabledWire { + /** True = this machine may serve preview lanes. */ + on: boolean; + /** ISO 8601 stamp of the write that produced this value. */ + changedAt: string; +} + +/** A {@link NodePreviewEnabledWire}, or null when the value is not one. */ +export function parseNodePreviewEnabled(value: unknown): NodePreviewEnabledWire | null { + if (!isRecord(value) || !isBool(value.on) || !isStr(value.changedAt)) return null; + return { on: value.on, changedAt: value.changedAt }; +} + /** Agent → control events, unsigned (socket-authed; spec §3.3). */ /** * One field of a plugin's preset-editor schema, as it travels. @@ -1552,6 +1607,131 @@ export function relayFrameOverCap(value: unknown): boolean { return relayFrameRefIfOverCap(value) !== null; } +/* ------------------------------------------------------------------ */ +/* preview surface (pane-preview spec 2026-10-21 §4) */ +/* ------------------------------------------------------------------ */ + +/** What a preview_open asks for: the raw loopback lane, or the read-only CDP screencast (spec §4). */ +export type PreviewLaneKind = "forward" | "screencast"; + +/** Opens one preview lane inside the pane's node. */ +export interface PreviewOpenCommand { + type: "preview_open"; + /** Opaque lane ref: 32 lowercase hex, also the proxy path secret family. */ + ref: string; + kind: PreviewLaneKind; + /** Attribution + liveness check on the node, never a routing target. */ + paneId: string; + /** Dial target (forward) or devtools port (screencast). The node dials loopback only. */ + port: number; + /** Screencast only; the bridge clamps it to [1, 5] and defaults it. Forward forbids it. */ + fps?: number; + /** Self-expiry: the node closes the lane by this deadline whatever the plane does. */ + lifetimeMs: number; +} + +/** Why a lane or bridge ended. Closed set; the UI maps them (spec §6/§9). */ +export type PreviewCloseReason = + | "viewer-left" + | "pane-gone" + | "node-dropped" + | "slow-link" + | "over-cap" + | "idle" + | "revoked" + | "not-cdp" + | "target-gone"; + +/** The runtime list beside the union, per the enums rule: one edit site, and the tests' census iterates it. */ +export const PREVIEW_CLOSE_REASONS: readonly PreviewCloseReason[] = [ + "viewer-left", + "pane-gone", + "node-dropped", + "slow-link", + "over-cap", + "idle", + "revoked", + "not-cdp", + "target-gone", +]; + +/** Whether `value` is one of the close reasons (narrows to the type). */ +function isPreviewCloseReason(value: unknown): value is PreviewCloseReason { + return (PREVIEW_CLOSE_REASONS as readonly unknown[]).includes(value); +} + +/** Lane teardown. Never access-gated (the set_ssh_enabled doctrine); an unknown ref answers closed:false. */ +export interface PreviewCloseCommand { + type: "preview_close"; + ref: string; + reason: PreviewCloseReason; +} + +/** One pass: what the pane's process tree is listening on, plus the CDP and display hints (spec §4). */ +export interface PreviewPortsGetCommand { + type: "preview_ports_get"; + paneId: string; +} + +/** The per-machine owner switch, plane sole author. Mirrors set_ssh_enabled's shape (decision 5). */ +export interface SetPreviewEnabledCommand { + type: "set_preview_enabled"; + on: boolean; + changedAt: string; +} + +/** + * The preview commands as a runtime list (the SSH_COMMAND_TYPES precedent): + * the pinned census test iterates it, so a fifth preview command must be + * added HERE as well as to the dispatcher, or that test fails. + */ +export const PREVIEW_COMMAND_TYPES = [ + "preview_open", + "preview_close", + "preview_ports_get", + "set_preview_enabled", +] as const; + +/** The preview lane frame: one capped base64 blob, both legs, parsed like RelayFrame (no seq; order is the link's). */ +export interface PreviewDataFrame { + type: "preview_data"; + ref: string; + /** in = toward the node's dialled socket, out = back toward the plane. */ + dir: "in" | "out"; + blob: string; +} + +/** + * Validates and narrows a preview lane frame (raw JSON text or the already- + * parsed object, same intake as {@link parseRelayFrame}). One capped base64 + * blob per frame keyed by the lane ref, and NO seq: lane order is the link's + * order, so a frame is identified by its ref and its position in the stream, + * never a per-frame counter (spec §4). The blob is opaque to the grammar - + * SHAPE and the RAW-byte cap are law (spec §4); what the bytes mean belongs + * to the lane's endpoint. An over-cap blob is refused here, quietly: unlike + * the relay's sealed session there is no named close the grammar owes, the + * broker (Task 8) owns lane lifecycle either way. + * + * @param raw - candidate frame as received + * @returns the narrowed frame, or null when malformed / not a lane frame + */ +export function parsePreviewFrame(raw: string | object): PreviewDataFrame | null { + let value: unknown = raw; + if (typeof raw === "string") { + try { + value = JSON.parse(raw); + } catch { + return null; + } + } + if (!isRecord(value) || value.type !== "preview_data") return null; + if (!isStr(value.ref) || !PREVIEW_REF_RE.test(value.ref)) return null; + if (value.dir !== "in" && value.dir !== "out") return null; + if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; + if (base64RawLength(value.blob) > PREVIEW_FRAME_MAX_BYTES) return null; + return { type: "preview_data", ref: value.ref, dir: value.dir, blob: value.blob }; +} + export type NodeEvent = | { type: "ready"; @@ -1618,6 +1798,17 @@ export type NodeEvent = * disagreement from its own row on the very same `ready`. */ sshEnabled?: NodeSshEnabledWire; + /** + * This machine's preview mirror, as its file reads at connect (spec + * 2026-10-21 §4). Absent means the node has no file, which for preview + * reads as REFUSED (fail-closed, ssh's rule). + * + * Dropped rather than fatal when malformed, exactly like `sshEnabled`: + * the plane answers silence with its row (a row that says on pushes + * down), so a bad mirror cannot strand a machine on the wrong side of + * the switch. + */ + previewEnabled?: NodePreviewEnabledWire; } | { /** @@ -1697,7 +1888,58 @@ export type NodeEvent = * same shape with {@link parseRelayFrame} directly; "event" here names the * inbound-to-plane position, not a claim that the node volunteered it. */ - | RelayFrame; + | RelayFrame + /** + * The preview lane leg (spec 2026-10-21 §4). Folded in for the PLANE's + * inbound path exactly like {@link RelayFrame}: a lane's `out` leg arrives + * on an agent socket, where `parseNodeEvent` is the one kind dispatcher. + * On the plane-to-node leg the node parses the same shape with + * {@link parsePreviewFrame} directly. + */ + | PreviewDataFrame + | { + /** + * One screencast frame for a lane (spec §4). The bridge (Task 6) + * produces these from the pane's CDP screencast; the plane fans them + * out read-only (Task 8). `seq` is the bridge's own frame counter for + * gap reporting, NOT the anti-replay slot the relay's is: the socket + * already authenticates the machine. + */ + type: "preview_stream"; + ref: string; + seq: number; + w: number; + h: number; + ts: number; + blob: string; + } + | { + /** + * A lane or bridge the NODE ended on its own (plan decision 3): target + * process gone, idle timer, revoked switch. Without this event a lane + * that died machine-side would leave every viewer waiting on a stream + * nothing will ever send again. The plane's own closes need no report; + * this is the node volunteering a death it observed. + */ + type: "preview_closed"; + ref: string; + reason: PreviewCloseReason; + } + | { + /** + * This machine's preview mirror moved underneath the connection (spec + * §4) - the heartbeat belt's carrier, and ssh's `ssh_enabled` twin with + * the same strictness: this frame IS the change, so half of it is not + * an answer. Chiefly CORRUPTION, which flips a machine from "allowed" + * to "refused" silently; the plane's answer to any disagreement is a + * push of its row, which is also the repair. + */ + type: "preview_enabled"; + /** The refusal/allowance this machine is acting on, as its mirror reads. */ + on: boolean; + /** ISO 8601 stamp from the file (its own mtime when the file is unreadable). */ + changedAt: string; + }; /* ------------------------------------------------------------------ */ /* validators (hand-rolled, parseClientFrame style — spec §3) */ @@ -1790,6 +2032,21 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { } if ("cols" in value && !(isInt(value.cols) && (value.cols as number) > 0)) return null; if ("rows" in value && !(isInt(value.rows) && (value.rows as number) > 0)) return null; + // A requested virtual display (spec 2026-10-21 §4): bounded on both + // dims at the parse, because the node allocates whatever it accepts and + // an unbounded request is an unbounded Xvfb. Whether THIS machine may + // serve it is execution's question (the owner switch), not the + // grammar's. 320..4096: a smaller screen cannot host a terminal plus a + // browser, a larger one no viewer can usefully watch. + if ("screen" in value) { + const screen = value.screen; + if ( + !isRecord(screen) || + !(isInt(screen.width) && (screen.width as number) >= 320 && (screen.width as number) <= 4096) || + !(isInt(screen.height) && (screen.height as number) >= 320 && (screen.height as number) <= 4096) + ) + return null; + } if ("bestEffortLog" in value && !isBool(value.bestEffortLog)) return null; // Server-built argv and its resolve rule (inversion spec §5): REQUIRED // since protocol 3. The node holds no plugin and builds nothing itself, @@ -2052,6 +2309,62 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { // Delegation, not a second parser: the SSH grammar (all arms, one // file) lives in ssh-frames.ts beside the commands it narrows. return parseSshNodeCommandBody(value); + case "preview_open": { + // The kind decides which optional members may appear at all, so the + // rebuild branches on it: forward forbids `fps`, screencast carries it + // OPTIONALLY (absent stays absent - the grammar does not default it; + // the default belongs to the bridge, Task 6), and the grammar accepts + // any rate up to PREVIEW_FPS_GRAMMAR_MAX because the clamp into [1, 5] + // is the bridge's, not the wire's. Stray members drop with the rebuild. + if (!isStr(value.ref) || !PREVIEW_REF_RE.test(value.ref)) return null; + if (!isStr(value.paneId)) return null; + if (!isInt(value.port) || (value.port as number) < 1 || (value.port as number) > 65_535) return null; + if ( + !isInt(value.lifetimeMs) || + (value.lifetimeMs as number) < PREVIEW_LIFETIME_MIN_MS || + (value.lifetimeMs as number) > PREVIEW_LIFETIME_MAX_MS + ) + return null; + if (value.kind === "forward" && "fps" in value) return null; + if ( + "fps" in value && + !(isInt(value.fps) && (value.fps as number) >= 1 && (value.fps as number) <= PREVIEW_FPS_GRAMMAR_MAX) + ) + return null; + if (value.kind === "forward") + return { + type: "preview_open", + ref: value.ref, + kind: "forward", + paneId: value.paneId, + port: value.port as number, + lifetimeMs: value.lifetimeMs as number, + }; + if (value.kind === "screencast") + return { + type: "preview_open", + ref: value.ref, + kind: "screencast", + paneId: value.paneId, + port: value.port as number, + lifetimeMs: value.lifetimeMs as number, + ...("fps" in value ? { fps: value.fps as number } : {}), + }; + return null; + } + case "preview_close": { + if (!isStr(value.ref) || !PREVIEW_REF_RE.test(value.ref)) return null; + if (!isPreviewCloseReason(value.reason)) return null; + return { type: "preview_close", ref: value.ref, reason: value.reason }; + } + case "preview_ports_get": + return isStr(value.paneId) ? { type: "preview_ports_get", paneId: value.paneId } : null; + case "set_preview_enabled": { + // Same shape as set_maintenance / set_ssh_enabled, strict for the same + // reason: half a stamp pair is not a decision. + const state = parseNodePreviewEnabled(value); + return state ? { type: "set_preview_enabled", ...state } : null; + } default: return null; } @@ -2091,29 +2404,37 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { ) { return null; } - // `runtime`, `maintenance` and `sshEnabled` are additive: a malformed - // one is dropped so the connection still comes up without it, rather - // than refused. For `sshEnabled` the drop is also SAFE in a way the - // others are not quite: the plane answers silence with its row (a row - // that says on pushes down), so a malformed field cannot strand a - // machine on the wrong side of the gate. + // `runtime`, `maintenance`, `sshEnabled` and `previewEnabled` are + // additive: a malformed one is dropped so the connection still comes up + // without it, rather than refused. For the two gate mirrors the drop is + // also SAFE in a way the others are not quite: the plane answers + // silence with its row (a row that says on pushes down), so a malformed + // field cannot strand a machine on the wrong side of the gate. const { systemSpecs: rawSystemSpecs, runtime: rawRuntime, maintenance: rawMaintenance, sshEnabled: rawSshEnabled, + previewEnabled: rawPreviewEnabled, ...rest - } = value as Record & { runtime?: unknown; maintenance?: unknown; sshEnabled?: unknown }; + } = value as Record & { + runtime?: unknown; + maintenance?: unknown; + sshEnabled?: unknown; + previewEnabled?: unknown; + }; const systemSpecs = parseNodeSystemSpecs(rawSystemSpecs); const runtime = rawRuntime === undefined ? null : parseNodeRuntimeReport(rawRuntime); const maintenance = rawMaintenance === undefined ? null : parseNodeMaintenance(rawMaintenance); const sshEnabled = rawSshEnabled === undefined ? null : parseNodeSshEnabled(rawSshEnabled); + const previewEnabled = rawPreviewEnabled === undefined ? null : parseNodePreviewEnabled(rawPreviewEnabled); return { ...(rest as unknown as Extract), ...(systemSpecs ? { systemSpecs } : {}), ...(runtime ? { runtime } : {}), ...(maintenance ? { maintenance } : {}), ...(sshEnabled ? { sshEnabled } : {}), + ...(previewEnabled ? { previewEnabled } : {}), }; } case "inventory": { @@ -2183,6 +2504,45 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { // (ref, seq, direction, capped opaque blob) lives in parseRelayFrame // beside the type it narrows. return parseRelayFrame(value); + case "preview_data": + // Same delegation for the lane frame: shape and the raw cap live in + // parsePreviewFrame beside the type it narrows. + return parsePreviewFrame(value); + case "preview_stream": { + // One screencast frame on its way to the plane's fan-out (Task 8). No + // blob cap here beyond strict base64: the frame ceiling is what bounds + // a link frame, and PREVIEW_FRAME_MAX_BYTES is the LANE's quota, not + // the bridge's. `seq` starts where a counter starts; dims are positive + // because a zero-pixel frame is not a screen. + if (!isStr(value.ref) || !PREVIEW_REF_RE.test(value.ref)) return null; + if (!isInt(value.seq) || (value.seq as number) < 0) return null; + if (!isInt(value.w) || (value.w as number) <= 0 || !isInt(value.h) || (value.h as number) <= 0) return null; + if (!isInt(value.ts) || (value.ts as number) < 0) return null; + if (!isStr(value.blob) || value.blob.length === 0 || !BASE64_RE.test(value.blob)) return null; + return { + type: "preview_stream", + ref: value.ref, + seq: value.seq as number, + w: value.w as number, + h: value.h as number, + ts: value.ts as number, + blob: value.blob, + }; + } + case "preview_closed": { + // Plan decision 3: the node's own report of a lane it ended. The reason + // must come from the closed set - the plane maps it onto viewer copy, + // so a reason outside the set is not a half-answer, it is junk. + if (!isStr(value.ref) || !PREVIEW_REF_RE.test(value.ref)) return null; + if (!isPreviewCloseReason(value.reason)) return null; + return { type: "preview_closed", ref: value.ref, reason: value.reason }; + } + case "preview_enabled": { + // Strict for the same reason `ssh_enabled` is: this frame is the + // machine reporting what it is acting on; half of it is not an answer. + const state = parseNodePreviewEnabled(value); + return state ? { type: "preview_enabled", ...state } : null; + } default: return null; } diff --git a/packages/subshell-protocol/src/node-results.ts b/packages/subshell-protocol/src/node-results.ts index 9f9f4a69d..48c47d7e8 100644 --- a/packages/subshell-protocol/src/node-results.ts +++ b/packages/subshell-protocol/src/node-results.ts @@ -10,9 +10,15 @@ * history of the pre-restart numbering is in git; the numbering itself * restarted at 1 on 2026-09-09 — see `NODE_PROTOCOL_VERSION`.) * - * `launch` / `terminate` / `kill` / `input` / `resize` / `tail_start` / - * `tail_stop` / `remove_paths` / `inventory` / `ping` carry no data — their + * `terminate` / `kill` / `input` / `resize` / `tail_start` / `tail_stop` / + * `remove_paths` / `inventory` / `ping` carry no data — their * success result is just `{ ok: true }`, so they need no validator here. + * `launch` left that list at protocol 23: it may echo the `screen` it + * allocated (spec 2026-10-21 §4), so it has {@link parseNodeLaunchResult}, + * whose common answer is the one with no echo at all. `set_preview_enabled` + * joins `set_ssh_enabled` in firing best-effort with an ack nobody reads: + * the plane's truth is its own DB row, and a dataless command has no entry + * here. * * Validators are hand-rolled in the `parseNodeEvent` style (this package stays * schema-lib-free); a NON-null return is safe to cast. The primitives the @@ -25,6 +31,7 @@ import { parseSshConnectionSnapshot } from "./ssh-config.js"; import { isSshErrorCode } from "./ssh-errors.js"; import { isSshFingerprint, isSshKnownHostsPinLine, isSshPaneId } from "./ssh-frames.js"; import { + PREVIEW_REF_RE, SSH_EXEC_RESULT_MAX_CHARS, SSH_MAX_DISCOVERED_ALIASES, SSH_MAX_HOST_KEY_LINES, @@ -768,3 +775,172 @@ function isExecStreamStr(value: unknown): value is string { if (!isStr(value) || value.length > SSH_EXEC_RESULT_MAX_CHARS) return false; return !/\p{Cc}/u.test(value.replace(/[\n\r\t]/g, "")); } + +/* ------------------------------------------------------------------ */ +/* preview (pane-preview spec 2026-10-21 §4) */ +/* ------------------------------------------------------------------ */ + +/** The virtual display a node allocated: named on the launch ack, reported again by the ports walk. */ +export interface NodePreviewScreenWire { + /** X display name the node allocated (e.g. `:1`). */ + display: string; + /** rfb (VNC) port the node started for this display. */ + rfbPort: number; +} + +/** Which half of the screen machinery died after the display came up (spec §9; Task 7's child-exit watch is the only writer). */ +export type NodePreviewScreenLost = "Xvfb" | "x11vnc"; + +/** A display pair is complete or it is not an answer: a half-named screen is worse than none. */ +function parsePreviewScreenWire(value: unknown): NodePreviewScreenWire | null { + if (!isRecord(value) || !isNonEmptyStr(value.display)) return null; + if (!isInt(value.rfbPort) || (value.rfbPort as number) < 1 || (value.rfbPort as number) > 65_535) return null; + return { display: value.display, rfbPort: value.rfbPort as number }; +} + +/** + * `preview_open`'s answer, by kind: the forward lane acknowledges the + * registration (`pending: true` is the only shape that means "dialing"), and + * the screencast reports how many subscribers it has after the join, so the + * plane's copy of the per-bridge cap and the node's agree without a second + * round trip. + */ +export type NodePreviewOpenResult = { ref: string; pending: true } | { ref: string; subscribers: number }; + +/** + * Validates and narrows a `preview_open` answer. The two kinds answer + * differently and the answer names its own kind: `pending: false` is not a + * lane that failed to dial (that is an `ok:false` refusal), it is a value + * this grammar does not have. + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodePreviewOpenResult(data: unknown): NodePreviewOpenResult | null { + if (!isRecord(data) || !isStr(data.ref) || !PREVIEW_REF_RE.test(data.ref)) return null; + if (data.pending === true) return { ref: data.ref, pending: true }; + if (isInt(data.subscribers) && (data.subscribers as number) >= 0) { + return { ref: data.ref, subscribers: data.subscribers as number }; + } + return null; +} + +/** `preview_close`'s answer: whether a lane actually died from this command. */ +export interface NodePreviewCloseResult { + /** Echo of the lane ref the command named. */ + ref: string; + /** False means the ref matched no lane - already gone, which is an answer, not a failure. */ + closed: boolean; +} + +/** + * Validates and narrows a `preview_close` answer (the lane-teardown echo). + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodePreviewCloseResult(data: unknown): NodePreviewCloseResult | null { + if (!isRecord(data) || !isStr(data.ref) || !PREVIEW_REF_RE.test(data.ref)) return null; + if (!isBool(data.closed)) return null; + return { ref: data.ref, closed: data.closed }; +} + +/** One listening socket of the pane's process tree (spec §4's ports answer rows). */ +export interface NodePreviewPortWire { + /** Socket family, as the walker found it; the proxy dials both spellings of loopback. */ + proto: "tcp" | "tcp6"; + /** Listening port. */ + port: number; + /** Owning pid inside the pane's process tree. */ + pid: number; +} + +/** + * `preview_ports_get`'s one-pass answer (spec §4): what the pane's process + * tree is listening on, the CDP port if one is open, and the display the node + * allocated if the pane has one. `screen` is REQUIRED and nullable, never + * absent: a walker that forgot the field is malformed, a machine with no + * display says so with null. `screenLost` rides ONLY beside that null (spec + * §9): it reports WHICH half of the machinery died, so the viewer copy can + * name the loss instead of showing a dead frame. + */ +export interface NodePreviewPortsResult { + /** One row per listening socket of the pane's tree; empty is a real answer. */ + ports: NodePreviewPortWire[]; + /** The open devtools port, or null when nothing in the tree speaks CDP. */ + cdpPort: number | null; + /** The allocated display, or null when the pane has none (or it died). */ + screen: NodePreviewScreenWire | null; + /** Set only when `screen` is null: the display process that died since it came up. */ + screenLost?: NodePreviewScreenLost; +} + +/** + * Validates and narrows a `preview_ports_get` answer. The hand-rolled style + * REBUILDS what it validates, so `screenLost` is copied explicitly here: a + * validator that never names the member would strip it silently between + * Task 7's writer and Task 9's reader, green at every unit layer. + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodePreviewPortsResult(data: unknown): NodePreviewPortsResult | null { + if (!isRecord(data) || !Array.isArray(data.ports)) return null; + const ports: NodePreviewPortWire[] = []; + for (const p of data.ports) { + if (!isRecord(p) || (p.proto !== "tcp" && p.proto !== "tcp6")) return null; + if (!isInt(p.port) || (p.port as number) < 1 || (p.port as number) > 65_535) return null; + if (!isInt(p.pid) || (p.pid as number) < 0) return null; + ports.push({ proto: p.proto, port: p.port as number, pid: p.pid as number }); + } + if ( + !( + data.cdpPort === null || + (isInt(data.cdpPort) && (data.cdpPort as number) >= 1 && (data.cdpPort as number) <= 65_535) + ) + ) + return null; + if (!("screen" in data)) return null; + let screen: NodePreviewScreenWire | null = null; + if (data.screen !== null) { + const parsed = parsePreviewScreenWire(data.screen); + if (parsed === null) return null; + screen = parsed; + } + if ("screenLost" in data) { + // The pairing is law (spec §9): the marker reports the ABSENCE of a + // screen, so an answer carrying both claims both. + if (screen !== null) return null; + if (data.screenLost !== "Xvfb" && data.screenLost !== "x11vnc") return null; + } + return { + ports, + cdpPort: data.cdpPort === null ? null : (data.cdpPort as number), + screen, + ...("screenLost" in data ? { screenLost: data.screenLost as NodePreviewScreenLost } : {}), + }; +} + +/** + * `launch`'s answer (protocol 23): the virtual display it allocated, when it + * allocated one. The plane audits the display from the ack because a + * post-ack `getPorts` would race the 5 s cache on a pane that just started + * (spec §4). Validated on BOTH ends per this file's header contract. + */ +export interface NodeLaunchResult { + /** The display the node started the pane on; absent means the launch asked for none (or was refused, which answers `ok:false`). */ + screen?: NodePreviewScreenWire; +} + +/** + * Validates and narrows a `launch` answer. Absent, null, and `{}` all read + * the common case: no screen was asked for, so nothing echoes. A present + * `screen` must be a complete pair, because the plane writes what it trusts. + * @param data - the `data` member of a successful result frame + * @returns the narrowed answer, or null when malformed + */ +export function parseNodeLaunchResult(data: unknown): NodeLaunchResult | null { + if (data === undefined || data === null) return {}; + if (!isRecord(data)) return null; + if (data.screen === undefined) return {}; + const screen = parsePreviewScreenWire(data.screen); + if (screen === null) return null; + return { screen }; +} diff --git a/packages/subshell-protocol/src/ssh-limits.ts b/packages/subshell-protocol/src/ssh-limits.ts index 7200134ae..6ee466ffd 100644 --- a/packages/subshell-protocol/src/ssh-limits.ts +++ b/packages/subshell-protocol/src/ssh-limits.ts @@ -263,3 +263,34 @@ export const SSH_EXEC_RETAIN_BYTES = 8192; * the plane never sees a machine that simply did not cap itself. */ export const SSH_EXEC_RESULT_MAX_CHARS = 32_768; + +/** Byte cap on a preview lane frame's RAW payload, base64 aside (spec §4). Shared by both ends. */ +export const PREVIEW_FRAME_MAX_BYTES = 262_144; +/** Bytes the link will not take before a lane calls it slow-link (spec §5: 1 MiB, both sides). */ +export const PREVIEW_LANE_BUFFER_MAX_BYTES = 1_048_576; +/** Screencast rates. The bridge CLAMPS the wire fps into [MIN, MAX]; the grammar is looser. */ +export const PREVIEW_WATCH_FPS_MIN = 1; +export const PREVIEW_WATCH_FPS_DEFAULT = 5; +export const PREVIEW_WATCH_FPS_MAX = 5; +export const PREVIEW_FPS_GRAMMAR_MAX = 60; +export const PREVIEW_WATCH_JPEG_QUALITY = 60; +/** Frames the bridge holds queued at the emit boundary (produced but not yet accepted); + * the queue drains as the pump accepts, so this never becomes a counter with no decrement. + * Full means NEW frames drop and emission resumes on drain - never backpressure the + * browser (spec §5). The link never acks events individually; this counts bridge-held frames. */ +export const PREVIEW_WATCH_FRAMES_BUFFERED = 5; +/** Lane quotas: per pane, per node, and per watch bridge. */ +export const PREVIEW_FORWARD_SESSIONS_PER_PANE = 4; +export const PREVIEW_SESSIONS_PER_NODE = 16; +export const PREVIEW_WATCH_SUBSCRIBERS_MAX = 8; +/** Lane waiters queued beyond the pane cap before a refusal (decision 2's burst posture). */ +export const PREVIEW_LANE_QUEUE_MAX = 8; +/** Per-viewer-socket queued-bytes ceiling on the live fan-out; Task 8 detaches past it (`slow-link`). ~10 s of q60 at the 5 fps cap, generous against Bun's own WS backpressure. */ +export const PREVIEW_STREAM_DEBT_MAX = 4_194_304; +/** A forward lane with no bytes either way for this long self-closes (spec §6: 15 min). */ +export const PREVIEW_IDLE_MS = 900_000; +/** Lanes ride a lifetime inside this window; the command refuses outside it. */ +export const PREVIEW_LIFETIME_MIN_MS = 60_000; +export const PREVIEW_LIFETIME_MAX_MS = 4 * 3_600_000; +/** Lane refs double as proxy path secrets: 128 bits of lowercase hex. */ +export const PREVIEW_REF_RE = /^[0-9a-f]{32}$/; diff --git a/packages/subshell-protocol/src/versions.ts b/packages/subshell-protocol/src/versions.ts index 1aff5716d..fa387856a 100644 --- a/packages/subshell-protocol/src/versions.ts +++ b/packages/subshell-protocol/src/versions.ts @@ -119,8 +119,14 @@ export function semverLt(a: string, b: string): boolean { * * Raised to 2.2.0 with protocol 22, which requires nodes that can materialize * harness integration files beside a launch's MCP config. + * + * Raised to 2.4.0 with protocol 23 (the preview surface, spec 2026-10-21). + * The floor equals the shipping agent: `apps/node/agent/package.json` moves + * to 2.4.0 in this same commit. An agent below 23 understands no preview + * frame and the exact-match gate refuses it; the floor says the same thing + * wherever a version, not a protocol number, is what gets compared. */ -export const MIN_NODE_VERSION = "2.2.0"; +export const MIN_NODE_VERSION = "2.4.0"; /** * Whether an agent reporting `version` may connect. From f55474882948ad64d5f356d7570f42c5186e5c72 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 19:12:31 -0700 Subject: [PATCH 028/139] feat(protocol): live watch subscriptions and preview-stream frames The live feed's grammar grows the browser-facing screencast half (spec 6): server PreviewStreamFrame (no ref, no wall clock; the node-to-plane preview_stream event is a different shape), client watch/unwatch, and the parseLiveClientFrame arms. PreviewStreamFrame joins the barrel so Tasks 8 and 11 can name the frame. live-ws.ts narrows to previews before the ids read, since the widened client union is what its old shape-remainder assumption broke; watch/unwatch stay no-ops until the fan-out lands. --- apps/server/api/src/ws/live-ws.ts | 9 ++- .../src/__tests__/live-frames.test.ts | 31 ++++++++- packages/subshell-protocol/src/index.ts | 1 + packages/subshell-protocol/src/live-frames.ts | 65 +++++++++++++++++-- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/server/api/src/ws/live-ws.ts b/apps/server/api/src/ws/live-ws.ts index 946b74204..06356c7c6 100644 --- a/apps/server/api/src/ws/live-ws.ts +++ b/apps/server/api/src/ws/live-ws.ts @@ -261,7 +261,9 @@ export function liveWsDeps(): LiveWsDeps { export const MAX_PREVIEW_REQUEST = 60; /** - * Handles a client frame. The only thing a client may ask for is screens. + * Handles a client frame. Today the only thing this handler acts on is a + * screens ask; `watch`/`unwatch` are grammar ahead of wiring, parsed by the + * shared parser and ignored here until the screencast fan-out lands. * * Previews are PULLED rather than pushed (spec 2026-09-19 §4.4): the cards are * the one surface that renders them, so a page showing none costs nothing, and @@ -297,6 +299,11 @@ export async function handleLiveMessage(ws: LiveWsSocket, raw: unknown, deps: Li return; } + // `watch`/`unwatch` parse (the feed's grammar is shared, and Task 8 will + // fan screencasts out from here), but nothing is wired to them yet: a + // subscription asks for a stream this handler cannot send, so it is a no-op. + if (frame.type !== "previews") return; + const wanted = frame.ids.slice(0, MAX_PREVIEW_REQUEST); if (wanted.length === 0) return; // ONE capture run per socket at a time. Each id is a `capture-pane` spawn, diff --git a/packages/subshell-protocol/src/__tests__/live-frames.test.ts b/packages/subshell-protocol/src/__tests__/live-frames.test.ts index 07675f5d5..c8ab78b3f 100644 --- a/packages/subshell-protocol/src/__tests__/live-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/live-frames.test.ts @@ -12,6 +12,24 @@ describe("parseLiveClientFrame", () => { expect(parseLiveClientFrame('{"type":"resync"}')).toEqual({ type: "resync" }); }); + it("takes a watch and an unwatch, as a string and as an object", () => { + const expected: LiveClientFrame = { type: "watch", id: "s1" }; + expect(parseLiveClientFrame(JSON.stringify(expected))).toEqual(expected); + expect(parseLiveClientFrame(expected)).toEqual(expected); + expect(parseLiveClientFrame('{"type":"unwatch","id":"s1"}')).toEqual({ type: "unwatch", id: "s1" }); + expect(parseLiveClientFrame({ type: "unwatch", id: "s1" })).toEqual({ type: "unwatch", id: "s1" }); + }); + + it("refuses a watch or unwatch that does not name a pane with a string id", () => { + // A subscription addresses exactly one row; the server re-checks + // visibility against that id, so a frame without one names nothing to + // check and is refused rather than guessed at. + expect(parseLiveClientFrame({ type: "watch" })).toBeNull(); + expect(parseLiveClientFrame({ type: "watch", id: 7 })).toBeNull(); + expect(parseLiveClientFrame('{"type":"unwatch"}')).toBeNull(); + expect(parseLiveClientFrame({ type: "unwatch", id: null })).toBeNull(); + }); + it("drops non-string ids rather than refusing the frame", () => { // A client whose list is partly junk still gets the screens it named // properly; the alternative is a page that silently renders none. @@ -24,7 +42,18 @@ describe("parseLiveClientFrame", () => { it("answers null for anything it does not recognize, and never throws", () => { // Client frames are untrusted input: the socket stays up and the frame is // ignored, exactly as the terminal socket's own parser does. - for (const raw of ["not json", "", "null", "[]", '{"type":"input","data":"x"}', '{"type":"previews"}', 42, null]) { + for (const raw of [ + "not json", + "", + "null", + "[]", + '{"type":"input","data":"x"}', + '{"type":"previews"}', + '{"type":"watcher","id":"s1"}', + '{"type":"unwatched","id":"s1"}', + 42, + null, + ]) { expect(parseLiveClientFrame(raw)).toBeNull(); } }); diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index d054c6cb2..665ecbf81 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -72,6 +72,7 @@ export { LIST_SEARCH_MAX } from "./list-search.js"; export { type LiveClientFrame, type LiveServerFrame, + type PreviewStreamFrame, parseLiveClientFrame, } from "./live-frames.js"; export { diff --git a/packages/subshell-protocol/src/live-frames.ts b/packages/subshell-protocol/src/live-frames.ts index f131e6a5d..d68f10d63 100644 --- a/packages/subshell-protocol/src/live-frames.ts +++ b/packages/subshell-protocol/src/live-frames.ts @@ -2,7 +2,7 @@ * The dashboard's live feed (`/ws/live`, spec 2026-09-19) — one socket per * tab carrying every subshell the viewer may see. * - * Shared rather than restated on each side because there are five frame + * Shared rather than restated on each side because there are ten frame * kinds across two directions now, and the pair that matters is easy to get * wrong from one end alone: a SNAPSHOT row is per-viewer and carries its * `access`, while a broadcast row cannot — one payload reaches every @@ -15,6 +15,37 @@ * browser's is the SPA's `SubshellView`. What is shared is the envelope. */ +/** + * One screencast frame of a pane the viewer watches (spec §6, screencast + * fan-out). + * + * Published ONLY to sockets the broker has verified hold `view` on the row; + * a subscription is re-checked at `watch` time through the feed's own + * authorization path. It carries no `ref` on purpose: subscribers address + * panes by id and refs stay plane-side. This is also why there is no + * wall-clock field here. The node-to-plane `preview_stream` EVENT is a + * different shape, lane-addressed by `ref` and stamped `ts`; nothing about + * this frame should be read as that one crossing over. + */ +export interface PreviewStreamFrame { + /** Screencast frame for a pane this socket watches. */ + type: "preview-stream"; + /** The subshell whose pane is being cast. */ + id: string; + /** + * The bridge's own frame counter, for gap and age reporting on the viewer + * side. Not an anti-replay slot: the socket already authenticates the + * subscriber. + */ + seq: number; + /** Frame width in pixels; the viewer sizes its canvas from `w` and `h`. */ + w: number; + /** Frame height in pixels. */ + h: number; + /** The JPEG, base64-encoded exactly as the node's event carries it. */ + blob: string; +} + /** * A frame the server sends. * @@ -71,9 +102,10 @@ export type LiveServerFrame = id: string; /** The captured lines, top row first. */ lines: string[]; - }; + } + | PreviewStreamFrame; -/** A frame the browser sends. The only thing a client may ask for is screens, or the list again. */ +/** A frame the browser sends. A client may ask for screens, a screencast subscription, or the list again. */ export type LiveClientFrame = | { /** Capture and return these subshells' screens. */ @@ -90,6 +122,24 @@ export type LiveClientFrame = * `view` grantee. */ type: "resync"; + } + | { + /** + * Start streaming this pane's screencast frames (`preview-stream`). + * + * The server re-checks visibility on subscribe through the feed's own + * authorization path; an id this viewer cannot see simply gets no + * frames. + */ + type: "watch"; + /** The subshell to watch, by id: refs stay plane-side (spec §6). */ + id: string; + } + | { + /** Stop streaming it. The viewer stops receiving `preview-stream` for this pane. */ + type: "unwatch"; + /** The subshell to stop watching. */ + id: string; }; /** @@ -100,13 +150,18 @@ export type LiveClientFrame = * which is what `parseClientFrame` does for the terminal socket too. * * `ids` is filtered to strings; the COUNT is bounded by the caller, because - * that ceiling is a server-side defence rather than part of the contract. + * that ceiling is a server-side defence rather than part of the contract. A + * `watch`/`unwatch` instead names exactly one pane, so a frame without a + * string `id` names nothing to subscribe and is refused, not guessed at. */ export function parseLiveClientFrame(raw: unknown): LiveClientFrame | null { const frame = typeof raw === "string" ? safeParse(raw) : raw; if (!frame || typeof frame !== "object") return null; - const { type, ids } = frame as { type?: unknown; ids?: unknown }; + const { type, ids, id } = frame as { type?: unknown; ids?: unknown; id?: unknown }; if (type === "resync") return { type: "resync" }; + if (type === "watch" || type === "unwatch") { + return typeof id === "string" ? { type, id } : null; + } if (type !== "previews" || !Array.isArray(ids)) return null; return { type: "previews", ids: ids.filter((id): id is string => typeof id === "string") }; } From bcfc4d4ed6c4de7fc4c5057fb8a1edcd947e43ba Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 19:19:44 -0700 Subject: [PATCH 029/139] Fold review round-14 correctness into the plan --- docs/superpowers/plans/2026-10-21-pane-preview.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-21-pane-preview.md b/docs/superpowers/plans/2026-10-21-pane-preview.md index 624d8b101..62e2b3168 100644 --- a/docs/superpowers/plans/2026-10-21-pane-preview.md +++ b/docs/superpowers/plans/2026-10-21-pane-preview.md @@ -451,7 +451,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Test helper: `@/__tests__/helpers/test-database.js` for audit rows. **Interfaces:** -- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what the Authz Global Constraint actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; getBufferedAmount?: () => number }` (declared and exported in `preview-broker.ts`; what gets STORED is NOT the `LiveWsSocket` wrapper - measured under this repo's Elysia 1.4.24 on Bun 1.4.2, the per-callback wrapper exposes neither a `bufferedAmount` property nor a `getBufferedAmount` method, and the raw Bun `ServerWebSocket` carries the METHOD, never a property; the repo already lives with this asymmetry (`remote-subshell-ws.ts:79`: "Bun's server-side ws exposes `getBufferedAmount`", the type at `:82`, read through `ws.raw` at `:243`), so Task 11 stores a two-member adapter (`send` delegating to `ws.send`, `getBufferedAmount` to that precedent's `?? 0` read over `ws.raw`), safe because detach is keyed by `streamViewerId`, never by socket identity - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `const debt = socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the METHOD (one case builds the fake WITHOUT it, pinning the zero-debt read), Task 11's adapter supplies it from the raw Bun socket); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. +- Produces (Tasks 9-11 import these exact names): `openForward({ viewerUserId, paneId, nodeId, port }): Promise<{ ref }>` (route-gated edit; sends the PROBE `preview_open` kind forward (every `preview_open` this broker sends - forward here, screencast from `startWatch` - rides `lifetimeMs: PREVIEW_LIFETIME_MAX_MS`: the absolute cap is belt-and-braces while refcount/idle/revoke are the real lifecycle, and the value is named HERE so two executors cannot pick two), awaits ack, closes the probe lane (reason `viewer-left`, wire-only - the audit pair names SESSIONS/bridges/displays, not their lanes, and the session is still live at probe-close), returns the session ref); `openLaneForConnection(sessionRef): Promise<{ laneRef }>` (fresh 32-hex lane ref + `preview_open`; queues up to 8 waiters beyond cap, then refusal `preview-lane-over-cap`); `subscribeLane(laneRef, cb: (e: { bytes: Uint8Array } | { closed: PreviewCloseReason }) => void): () => void` - keyed by LANE, not session: under decision 2 an iframe, an HMR socket, and asset requests all hold lanes under one session, and a session-keyed callback would pump one connection's bytes into every duplex (the same shape `sendLaneBytes`/`closeLaneForConnection` already use); `sendLaneBytes(laneRef, bytes)` (rides the new `preview-frames.ts` byte sender - NOT `sendCommand`: commands and data frames are different link paths, exactly as the relay split them. It is ALSO the spec §6 sender-cap check, plane-side: bytes longer than `PREVIEW_FRAME_MAX_BYTES` write nothing, close that lane `over-cap`, and audit `node.preview_lane.over_cap` (the ONE lane-level action: the spec's "in both the refusal copy and the audit row" sentence names this death, and the session survives it, so no `.close` pair fits - a documented extension of the audit vocabulary, like `node.shares_set`) - the reason the spec's "gets the session closed with a named reason, not fragmentation" sentence names. `over-cap` names CLOSES and every close it names in this feature is this sender rule: here plane-side and the forward pump node-side (Task 5 case (i)). Refusals never borrow the bare word - they carry the distinct codes `preview-lane-over-cap`, `preview-watch-over-cap`, `preview-stream-over-cap` - and viewer-level refusals/detaches put the word in `node.preview_watch.viewer_drop` METADATA instead of closing anything (byte debt below); both proxy consumers funnel through this one choke point, so HTTP body chunks and oversized browser WS messages get the same named death Task 1 pins from the receiver side); `closeLaneForConnection(laneRef, reason)` (sends that lane's node `preview_close`, drops the broker lane entry - Task 10's one-shot responses call it on completion, or every finished response parks a node↔target TCP lane until the 15-minute idle); `closeForPane(paneId, reason)` (every lane, session, and bridge of one pane: the pane-exit route calls it with `pane-gone`, and it is the e2e cleanup verb); `startWatch({ viewerUserId, paneId, nodeId, port }): Promise<{ ok: true }>` (REST side only: ONE bridge per PANE, because the spec keys watch on the pane - its DELETE carries no port, the stream-socket map and the ref-less `preview-stream` frame are pane-keyed, and the UI offers exactly one watch target - so `startWatch`'s `port` is REQUIRED: spec §6's default lives one level up, where the ROUTE (Task 9) resolves `body.port ??` the pane's cached `cdpPort` and 400s naming `cdpPort` when neither exists, before the call (one resolution site; no broker-side null-cache branch; Task 9's route tests fake the broker, so that default is a route test) - a POST naming a port DIFFERENT from the live bridge's port is refused `watch-port-conflict` (409; two bridges on one pane would interleave on a channel with no discriminator, and the pane-keyed `stopWatch` could not name the right one), and a matching port just joins; per-viewer SUBSCRIPTION count - not a set: two tabs of one account are two watchers, so `viewers` is `Map` and a tab's POST increments; zero->one sends `preview_open`; the spec's cap is PER BRIDGE (spec §6 "max 8 watch subscribers per bridge"), so the POST checks the SUM of the viewers counts plus this one against `PREVIEW_WATCH_SUBSCRIBERS_MAX` BEFORE incrementing and refuses `preview-watch-over-cap` (decision 7: a per-viewer cap would let one account's 40 tabs sail past the limit the spec named); `stopWatch(viewerUserId, paneId)` (the REST DELETE carries only the pane id, and it is enough; it DECREMENTs, never deletes the key - one DELETE with two tabs open leaves count 1 and the bridge alive, or one tab's unmount would kill its sibling's canvas; the count reaching zero sends `preview_close viewer-left`; an absent or zero entry is a NO-OP answering `{ ok: true }` - REST DELETE is idempotent, so a second DELETE answers 200 with no wire frame, no second `preview_close`, nothing audited, and the same holds after a node-reported bridge teardown already zeroed the registry; a crashed tab's leaked count is bounded by the node-side lifetime sweep, accepted); `closeUnauthorizedForPane(paneId)` (the shares cut RIDES THE RELAY'S RECOMPUTE POSTURE, which is exactly what spec §6 names: "riding the revocation cuts the relay broker already has". The relay RECOMPUTES authorization per holder instead of sweeping by event (`ssh-relay.service.ts:160-168`: "whether the user has lost AUTHORIZATION (access gone, SSH switched off, row deleted) - never liveness"), and `set-node-shares.route.ts:51` runs `closeUnauthorizedForNode` AFTER the write. A preview-level per-grantee diff cannot express what the Authz Global Constraint actually requires: a DOWNGRADE is reachable through the same PUT (`SubshellSharePermission` is `"view" | "edit"`, `subshell-shares.db-types.ts:2`), and an `edit`->`view` grantee must lose ONLY the edit tier - their watch is exactly what `view` still buys. So the broker walks ITS OWN state for the pane and re-asks `loadSubshellAccess` per holder: a forward session whose `createdBy` now resolves below `edit` closes whole (lanes included) with `revoked`; a watcher count whose holder now resolves below `view` decrements out, and a bridge emptied by revocation closes with `revoked`, not `viewer-left`; a stream registration whose holder now resolves below `view` drops - `detachStream`, never `socket.close()`: the /ws/live socket carries the whole feed and outlives a single lost grant, exactly what Task 11's matrix row pins ("stops frames to that socket while the socket lives"); shipping the socket-kill reading here forces Task 11 to invert it. ONE recompute verb covers removal, downgrade, AND the `Everyone` grant's level change - `granteeUserId` NULL is the Everyone row (`subshell-shares.db-types.ts:18-19`), so a diff keyed on named grantees would miss the edit->view of the row that reaches everybody; `closeAllForUser` stays the disable/role sweep and would wrongly reach other panes here); `attachStream(paneId, socketKey, holderUserId, socket)` / `detachStream(paneId, socketKey)` + `detachSocketsFor(socketKey)` (the /ws/live side: the client `watch`/`unwatch` frames register THIS socket as a stream recipient after the view check; a viewer is a socket, not a user, so one device's canvas cannot light up another device's. The lookup key is a PER-CONNECTION ID the caller resolves from `ws.data`, NEVER the socket object: Elysia builds a fresh `ElysiaWS` wrapper per callback, so the object carrying `watch` is NOT the one carrying `unwatch` or `close` (measured at `live-ws.ts:36-42`: `data` and `raw` ARE the same instances across wrappers, the wrapper is not - which is why `apps/server/api/AGENTS.md` states the rule as "the viewer registry is keyed by `viewerId`, NEVER by socket identity"). Socket identity is not a style preference but dead code: both detach paths would find NOTHING, every dead entry keeps holding an attach-cap slot, and after ~8 reload churns the cap refuses viewers it never earned - while identity-stable fakes bless the bug, so Task 11's wrapper-swap close case is the pin. Task 11's open handler mints the id (`crypto.randomUUID()`, the socket-lifetime habit at `subshell-ws.ts:173`); NOT `liveViewerId` itself - that is the USER's id (`live-ws.ts:147`) and a user-keyed map would let one tab's `unwatch` detach its sibling's canvas, decision 7's own rule against it. The stored socket stays sendable (its `send` reaches the shared `raw`); it just is never the key; decision 7 also caps the socket half: the pane's attached set holds at most `PREVIEW_WATCH_SUBSCRIBERS_MAX` sockets, the 9th `watch` frame is refused AT ATTACH with `preview-stream-over-cap` and a `node.preview_watch.viewer_drop` audit (reason `over-cap` in metadata - nothing closes) while the first eight keep streaming - the spec only named the subscriber cap, but this is the same broadcast path the byte-debt rule guards. `attachStream` ANSWERS a value and never throws: `{ ok: false, code: "preview-stream-over-cap" }` when the set is full, `{ ok: true }` otherwise - the /ws/live switch SWALLOWS the refusal (a cap is not a frame-handler exception; the socket stays open, the ninth tab's experience is the dark canvas and the stale-frame wording, the audit row is the operator's)); `sessionFor(ref)`; `getPorts(nodeId, paneId): Promise<{ ports: { proto: "tcp" | "tcp6"; port: number; pid: number }[]; cdpPort: number | null; screen: { display: string; rfbPort: number } | null; screenLost?: "Xvfb" | "x11vnc" }>` (Task 4's scan through Task 1's result validator: `{ ports, cdpPort, screen }` PLUS Task 7's optional `screenLost`, spec §9 - the broker is the ONE hop between the node's answer and Task 9's route (Task 9's tests fake the broker, so a type without the member would let the route go green while production answers `screenLost: undefined` forever), and the 5 s cache carries the answer whole; cached 5 s per pane, spec §6; a node refusal - `preview-ports-unsupported`, `preview-node-off`, offline - REJECTS with the named error instead of fabricating an empty answer, and Task 9's info route is what turns a rejection into `{ nodeEnabled, reason }` - `openForward` CATCHES it and falls back to the `onDisplayOpened` memory, because on mac a refused ports walk must not sink a hand-pinned forward open - spec §10's mac leg); `onDisplayOpened(nodeId, paneId, screen)` + `onDisplayClosed(paneId, reason)` (audit + record only); `closeAllForNode(nodeId, reason)`; `closeAllForUser(userId, reason)`; `onStreamEvent(nodeId, ev)` (resolves `ev.ref` to its session's pane and fans out to THAT pane's attached sockets only - sockets are typed by ONE exported structural interface this task names, `PreviewStreamSocket { send(data: string): unknown; getBufferedAmount?: () => number }` (declared and exported in `preview-broker.ts`; what gets STORED is NOT the `LiveWsSocket` wrapper - measured under this repo's Elysia 1.4.29 on Bun 1.4.2, the per-callback wrapper exposes neither a `bufferedAmount` property nor a `getBufferedAmount` method, and the raw Bun `ServerWebSocket` carries the METHOD, never a property; the repo already lives with this asymmetry (`remote-subshell-ws.ts:79`: "Bun's server-side ws exposes `getBufferedAmount`", the type at `:82`, read through `ws.raw` at `:243`), so Task 11 stores a two-member adapter (`send` delegating to `ws.send`, `getBufferedAmount` to that precedent's `?? 0` read over `ws.raw`), safe because detach is keyed by `streamViewerId`, never by socket identity - THIS task builds the broker with NO production default at all: the broker file imports nothing from the ws layer HERE, and the default wiring belongs to Task 11 - the broker exports a one-shot `configurePreviewStreamSender(send)` beside its accessor (the singleton builds lazily, so there is no construction site to inject at; the seam is a setter for exactly that reason), and `ws/live-ws.ts` calls it with live-registry's send loop in Task 11's wiring edit: live-ws.ts holds the crossing, the broker still imports nothing from `ws/`, its own tests inject the fan-out via `resetForTests`, and an unconfigured broker never fans out) (`readyState?` at `live-ws.ts:60-61` reads "still open" when absent; absent debt reads as zero), and the fan-out IS where viewer byte-debt lives: over each `paneId` map entry, `const debt = socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { detachStream(paneId, socketKey); auditViewerDrop(paneId, "slow-link"); continue; }` - the drop audit is the `node.preview_watch.viewer_drop` row with the reason in metadata (shared with the attach-cap refusal, which drops for `over-cap`); nothing closes and no ref rides - spec §9 drops slow VIEWERS, never the bridge, so sibling sockets keep streaming; Task 8's test fakes the METHOD (one case builds the fake WITHOUT it, pinning the zero-debt read), Task 11's adapter supplies it from the raw Bun socket); `PreviewSession { ref, kind: "forward" | "screencast", mode: "http" | "raw", paneId, nodeId, port, createdBy, createdAt, lanes: Map, viewers: Map, lastActivity, cookieJar: Map }` - `cookieJar` is the anchor for spec §6's per-session cookie jar: it lives HERE so it dies everywhere sessions die (`closeForPane`, the revoke sweeps, the idle sweep - there is no other teardown path, because the broker never calls into the proxy), while its CONTENTS are read and written only by Task 10's `pv-http`/`pv-ws`; the broker never parses them and no wire type carries them. `viewers` maps REST watcher to SUBSCRIPTION count (see `startWatch`/`stopWatch`; a Set would let one tab's DELETE close the bridge under a sibling), and attached stream sockets live in a separate `paneId -> Map` map (the attach signature above's keying: id-keyed, socket-valued), because "which device sees the canvas" is socket-level state and `closeUnauthorizedForPane` re-asks by HOLDER, so the entry records the id its access check validated; `openForward` sets `mode` to `raw` when the port is the pane's screen `rfbPort` - consulting the `getPorts` screen record FIRST and falling back to the `onDisplayOpened` launch-time memory, so a post-plane-restart open still lands `raw` (decision 6's discriminator) - else `http`; `sessionFor(ref)` exposes it to the proxy. - Consumes: `sendCommand` (`@/services/nodes/node-rpc.js`) for the open/close/ports COMMANDS only; lane bytes go through the new `preview-frames.ts` sender; Task 1 grammar; audit metadata SHAPE from `ssh-relay.service.ts:437-455` (actorUserId null, ids/port/kind/reason/counts, never byte content) but WITHOUT that block's `ref`: the relay deliberately includes it because "a routing ref is not a credential" (`ssh-relay.service.ts:583` says so), while a preview session/lane ref IS the credential (spec §6: it is the `/pv` path secret and appears nowhere else), so `node.preview_*` metadata carries paneId/nodeId/port/kind/reason/counts and NEVER the ref - a comment at the audit call records the divergence. - [ ] **Step 1: Failing tests** (fake `sendCommand`, test DB for audit): open sends the probe `preview_open`, mints a 32-hex ref from `crypto.randomBytes(16).toString("hex")` - the ref doubles as the `/pv` path credential, so spec §10's "secret unguessability" is a property of THIS mint, not of the format regex (a `Math.random()` hex still matches `PREVIEW_REF_RE`), and a dedicated case asserts two opens for one pane mint DIFFERENT refs - audits `node.preview_forward.open` and closes the probe lane with NO `.close` audit row yet (lane closes audit nothing; the pair belongs to the session), and a following `closeLaneForConnection` on the session's only remaining lane likewise audits no close while the session itself is what the sweep later closes with its pair; node refusal propagates named and records nothing; lane cap: per-pane beyond 4 queues, beyond 8 waiters refuses `preview-lane-over-cap` (decision 2); watch: second viewer joins without re-sending `preview_open` (`subscribers` in the fake's ack is a lie the broker ignores; it counts itself), last leave sends `preview_close` `viewer-left` and audits `node.preview_watch.close` once per bridge (not per watcher); two POSTs from ONE user are count 2, and the first DELETE leaves the bridge OPEN (a Set-based refcount would let one tab kill its sibling's canvas); a second `stopWatch` after the zero-close, and a `stopWatch` arriving after a node-reported bridge teardown, both answer ok, send NOTHING, and audit nothing (idempotent DELETE, and the post-teardown case is live: the teardown zeroes the counts, and unpinning it invites a second `preview_close` into a dead bridge); a POST naming a port other than the live bridge's is refused `watch-port-conflict` and touches no count - the pane-keyed `stopWatch` only always names the right bridge because the pane-keyed map guarantees there is exactly one bridge per pane; caps (decision 7): subscriber counts split across THREE users fill the bridge at eight and the ninth POST refuses `preview-watch-over-cap` touching no count (a per-viewer reading of the cap would let this through), and a 9th attached socket is refused `preview-stream-over-cap` with a `node.preview_watch.viewer_drop` audit (reason `over-cap`) while the first eight keep receiving frames; `closeUnauthorizedForPane` on a faked `loadSubshellAccess`, one case per transition: an `edit`->`view` DOWNGRADE closes that grantee's forward session ONLY (lanes included, audited `revoked`) while their watch count, bridge, and attached stream socket keep streaming - the Global Constraint's tier split IS the test; a full removal sweeps both tiers, takes the bridge to zero with `revoked` (not `viewer-left`), and NEVER calls the fake socket's `close` (detach only - the fake asserts it; Task 11's matrix pins the socket alive); an `Everyone`-row edit->view write (the NULL grantee) closes every non-owner forward session on the pane while the owner's and a still-`edit` grantee's sit untouched; a `view`->`edit` UPGRADE closes nothing (the recompute fires on every shares write, including ones that only widen); and no transition reaches a different pane's sessions; stream fan-out: after two `attachStream` calls on one pane under DIFFERENT keys (two sockets, one holder - decision 7's two tabs), `onStreamEvent` delivers `{ id: paneId, seq, w, h, blob }` once per attached socket via the injected `sendToSockets` - and nothing to an un-attached socket of the same viewer; per-socket byte debt detaches THAT socket with a `node.preview_watch.viewer_drop` audit (reason `slow-link`) while the lane, the bridge, and sibling sockets keep running (spec §9); the fake carries debt as a METHOD (`getBufferedAmount`), and a sibling fake built WITHOUT it must keep frames flowing at the `?? 0` read (the property spelling is the trap the real platform does not have); and detach is keyed, never identity: attach a socket under a key, then `detachStream`/`detachSocketsFor` through a FRESH handle carrying the same key (the wrapper swap Elysia actually performs, `live-ws.ts:36-42`) and frames stop - the identity-keyed shape passes every reuse-one-object fake and is dead in production; `preview_closed` from the node ends lane subscribers with its reason, and its BRIDGE twin: a node close of a screencast ref drops that pane's session, zeroes the viewer counts, detaches the pane's stream sockets WITHOUT closing them, and audits `node.preview_watch.close` once with the reported reason - and the NEXT `startWatch` on the same pane/port re-sends `preview_open` (if the counts survived the node's close, "a matching port just joins" would join a bridge the node already killed and every canvas would stay dark until a plane restart); `sendLaneBytes` past `PREVIEW_FRAME_MAX_BYTES` sends nothing over `linkSend`, audits `node.preview_lane.over_cap`, and ends that lane's subscribers with `over-cap` (Task 5 case (i) pins the node's twin of this sender rule); DISPATCH test in `node-ws-handler.test.ts`'s style (its `handleNodeMessage(h.deps, ws, JSON.stringify({ type: "ssh_enabled", ... }))` rows at :944 are the pattern): feed each preview event type through the handler and assert the preview intake was reached - an arm missing from the switch is otherwise invisible to every other test (the switch has no default) - and ONE MORE ROW in the same file for the link-drop hook: drive `handleNodeClose` for a node with a live forward session and assert `closeAllForNode` fired (reason `node-dropped` on the wire, session gone); idle sweep (injected clock) at `PREVIEW_IDLE_MS` with `idle` - and FORWARD sessions only: the constant's own JSDoc names a forward lane, while a screencast bridge's life is refcount, revocation, node-reported close, and the node's own lifetime event, never plane-side traffic silence (a watched static page produces zero frames by definition - Task 6 Step 3 pins that promise, and a sweep that included screencast would kill a live user's canvas at 15 min, the plane-side twin of the node's "zero frames, zero closes" anti-test). So the sweep case runs BOTH kinds at once: the clock past `PREVIEW_IDLE_MS` closes the idle forward session and leaves the watched screencast bridge (viewers > 0, zero frames) and its session untouched; `closeLaneForConnection` sends that lane's `preview_close` and frees the node slot immediately (a completed one-shot response must not park the lane until idle); `openForward` at the pane's registered screen port produces a `raw` session, any other port `http` (decision 6) - including after `resetForTests` + a fresh `openForward` when only the fake's `preview_ports_get` answer still reports the screen (plane-restart regression: memory-only discrimination would ship `http` and re-handshake raw RFB); `closeAllForUser` audits `revoked` closes; `closeAllForNode` same with `node-dropped`; `getPorts` caches 5 s then re-RPCs (two calls, one RPC), carries Task 7's `screenLost` marker on the answer THROUGH the cache (a second call within the window answers from cache, marker and all, no second RPC), and answers with no `screenLost` key at all when the RPC answer carried none (absent, never `null` - Task 9's info shape reads the marker's presence); display: `onDisplayOpened` audits `node.preview_display.open` with `{display, rfbPort}`, `onDisplayClosed` audits `.close` with reason, a double-close is idempotent; `closeForPane(paneId, "pane-gone")` closes that pane's forward sessions (with their lanes), its watch bridge, and its display, auditing every close; and TWO WIRE tests pin the revocation cuts where they are PERFORMED, not at the broker: extend `users-management.test.ts` so the real disable write (:403's describe) and the real role write run against a live broker session (open it through `openForward` with the fake command sender before the write; assert it closes `revoked`), and add the twin case around a real `setShares` call proving it fires `closeUnauthorizedForPane` - spec §7's "disable closes live sessions revoked" is a promise about the routes, and every case above calls the broker directly, so without these the Modify lines are prose. @@ -504,7 +504,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - lane-cap refusal, browser-facing: a request whose `openLaneForConnection` is refused by the queue overflow answers `503` with `preview-lane-over-cap` in the body, and an upgrade is refused with the same named code - never a hang, a queue-jump, or an anonymous 500 (the refusal itself is Task 8's broker test; this pins what the PROXY does with it, which no other task observes). - [ ] **Step 2: Fail.** - [ ] **Step 3: Implement `pv-http.ts`** (first `bun add ws@8.22.0` in `apps/server/api`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it; after the `bun add`, `bun run lint:lockfile:fix` so the lock agrees by Step 5's checkpoint, and stage `bun.lock` BY PATH like everything else): resolve session (unknown -> 404); `openLaneForConnection` (await broker's cap/queue); `LaneDuplex`; `node:http.request({ createConnection: () => duplex, method, path: stripped + query, headers })`; transform INBOUND: drop the browser's `cookie`, then attach `session.cookieJar` as the request's `Cookie` header when non-empty (spec §6's jar reaches lane requests only - which is every request the proxy makes), set `host` to the literal `127.0.0.1:${port}`, drop `connection`/`keep-alive` (one-shot lane per request - the browser reconnects, the jar carries state); transform OUTBOUND: drop hop-by-hops, rewrite `set-cookie` Path AND store ONLY the rewritten `name=value` pair - never the attributes - in `session.cookieJar` keyed by cookie name (the jar's values ARE the pairs attached to later requests, so a value still wearing `Path=` or `Expires=` would ship as a bogus cookie; same name replaces, and name-only keying with last-write-wins across paths is the deliberate v1 simplification - the Path rewrite matters on the browser-facing response, which is where it must be right), rewrite relative `location`, strip only `frame-ancestors` from both CSP headers; stream the body through; lane `closed` before completion -> 502 naming the reason, never a mid-stream retry (a POST may have landed). -- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the seven verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. +- [ ] **Step 4: Implement `pv-ws.ts`:** upgrade on `/pv/:session/*` - any suffix is the tunnel root (noVNC and Vite HMR each dial their own path; the session secret authorized the upgrade, spec §6): gate with `browserOriginAllowed` from `@/lib/browser-origin.js` - PR #424's browser-origin doctrine, already applied to `/ws` and `/ws/live` upgrades - refuse 403 on a foreign origin, pass a missing `Origin` (non-browser clients) and registry origins (an exact-equality check on the plane origin would break a dashboard reached over a published Tailscale/NetBird address); call the helper, do not re-implement the check. Open a dedicated lane, then BRANCH by `sessionFor(ref).mode` (decision 6, and one mode cannot serve both consumers - spec §5's blind pump is exactly right for RFB and exactly wrong for a target that is itself a WS server). `mode === "raw"` (the screen `rfbPort`): websockify semantics - the browser WS's binary frame payloads pump straight onto the lane and lane bytes out pump back as binary frames, no re-handshake; the target is a raw TCP RFB server. `mode === "http"`: redial with the `ws` client `new WebSocket(`ws://127.0.0.1:${port}/${rest}`, { createConnection: () => laneDuplex })` - TOP-LEVEL `createConnection`, which is the real option in `node_modules/ws/lib/websocket.js`; read that file first, because a wrong key (e.g. a `net:` wrapper) is silently IGNORED and would dial plane-local loopback around the lane; pass `headers: { cookie }` from `session.cookieJar` when non-empty (a cookie-authed dev server guards its HMR socket the way it guards its HTTP, and the jar - not the browser's header - is the only state that may ride), capture any `Set-Cookie` on the target's UPGRADE response through the ws client's `upgrade` event into the SAME Step-3 jar store (spec §6's jar sentence is unqualified, so an upgrade-set cookie rides the session too - Step 3's OUTBOUND transform is the HTTP-lane half and this is the WS-lane half), while `raw` mode attaches no headers at all because RFB does not speak HTTP. Both modes: pump both directions, beyond `PREVIEW_LANE_BUFFER_MAX_BYTES` buffered close both `slow-link`; either side closing closes both and the lane, reason `viewer-left`. The close reason must reach the USER, not only the audit: when the lane closes for anything except the viewer's own `viewer-left` (their close/unmount - they know why), close the browser WS with code 1011 (unexpected condition; there is no "server policy" close code) and the `PreviewCloseReason` string verbatim in the close frame's reason field (the set's longest word fits the 123-byte limit), so the browser's `close` event carries `revoked`/`pane-gone`/... in `event.reason` - the story Task 13 renders. Finish by REGISTERING both halves in `src/server.ts` (its Files line, landed here): the seven verbs on `/pv/:session/*` mounted BEFORE the static SPA plugin - the file's own :59-61 `installScriptRoute` comment is the trap - plus the WS upgrade handler beside the ws plugin's registrations; unmounted, both modules are dead code and every Step-1 test would pass against them directly while production 404s. - [ ] **Step 5: Green; `bun run lint:lockfile`; commit** `feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel`. --- @@ -539,7 +539,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - [ ] **Step 1: Tests** (render-level): `view` render shows the Watch toggles, and the Open and Screen controls on their rows PRESENT but tooltip-disabled with the REAL reason (spec §7 pins Open "tooltip-disabled with the real reason when `access === \"view\"` or the node switch is off", and its gating trio - disabled control + `disabledReason` tooltip - means a gated control must EXPLAIN itself, never vanish; a `screen: null` row's missing Screen control is state-driven ABSENCE, not gating, and the screenLost case owns that); the disabled-reason clause covers both spec conditions (`access === \"view\"` -> the view-tier wording; `nodeEnabled: false` -> the switch's remedy wording; per AGENTS disabled-control rule); `edit` UNLOCKS Open, which opens the collapsible side panel hosting the `/pv/` iframe plus an "open in new tab" affordance (assert both, spy the anchor's href = `proxyBase`); Watch canvas swaps to the newest frame blob and ignores a lower `seq`; a fake clock past 5 s with no frame renders the stale "still there?" state (never a frozen page reading as live - spec §7), and the same canvas carries spec §7's fps/age badge (age and rate read the ARRIVAL clock - age is now minus the newest frame RECEIVED - because the live frame carries no wall-clock field: `ts` exists on the node->plane `preview_stream` EVENT only, and spec §6's browser shape is `{ id, seq, w, h, blob }` (round 11 caught this clause citing a field the shape does not have); one clock also makes the badge agree with the 5 s stale rule, which has always been arrival-based. Frozen while stale - this clause is the badge's ONLY owner; both round-10 reviewers found it otherwise homeless); an info row with `screen: null` and `screenLost: "x11vnc"` renders "Display stopped: x11vnc exited" as the detail line with no Screen control, terminal streaming on (spec §9); `cdpPort` vanishing mid-watch renders "browser gone" wording beside the stale canvas (spec §5's mapping; the info refresh is what carries it); `startWatch` fires BOTH halves (spy: REST POST plus a `{"type":"watch","id"}` frame on the feed context's bound `send`) and `stopWatch` mirrors it - a test that lets either half pass silently is how Watch ships dead - and with the POST faked to a 409 `watch-port-conflict`, the live frame is NEVER sent and the error renders named (a socket attached without the bridge would paint from a port this tab never joined); the reconnect re-fire matters too: fake a connect drop and re-establish, and the hook sends `watch` again for a pane it still watches - a socket that died mid-watch otherwise leaves every canvas dark forever, and `send` returning false while disconnected is what makes the re-fire the registration path; the shared-pane banner variant is raised once per device on first Preview open (read `use-trust-notices` + `TrustIndicators` first and reuse, do not re-invent); info refresh fires on pane focus and after an output burst, throttled to one request per 5 s window (visible throttling per spec §7 - whose SAME sentence also requires the `/pv` REQUEST_LOG_IGNORE entry, and that half is Task 9's, already pinned in its Step 1; the spec asked for both, so neither is an erratum and this task audits nothing away). Two more assertions belong HERE even though their surfaces arrive in Step 4 (the Files list owns them and no other task touches them): the node settings page renders the Preview toggle beside the ssh one with the same gate/tooltip posture, and the create dialog's POST body carries `screen: { width, height }` when "Virtual display" is checked and omits it when not (an always-sent default would silently Xvfb every terminal pane). - [ ] **Step 2: Fail.** -- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the Screen BUTTON itself arrives with Task 13, render no dead one here; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7); Watch paints the fps/age badge beside the canvas, and a named `screenLost` (with `screen` null) reads as the loss line - there is no hint to swap, no `screen` means no Screen control. +- [ ] **Step 3: Implement.** Port rows: `label` over `detail`, kind badges server / browser-debug / screen; actions are the three named verbs (spec §12.1); copy has no em dashes; `screen` presence shows the `screen-launch-hint` line (DISPLAY values from `info.screen`) for `edit` - the ENABLED Screen action and viewer arrive with Task 13, render no dead one here - the present-but-tooltip-disabled control your view-tier test pins IS rendered here, disabled-with-reason is not a dead one; on phone layouts the Open/Screen panel does not render, Watch still does (spec §7); Watch paints the fps/age badge beside the canvas, and a named `screenLost` (with `screen` null) reads as the loss line - there is no hint to swap, no `screen` means no Screen control. - [ ] **Step 4: Turn green the two Files-listed surfaces their Step-1 tests pinned** - the node settings page gains the Preview toggle beside the ssh one (read the ssh toggle's component first and clone its gate/tooltip posture; it calls the Task 3 route), and the create dialog gains an optional "Virtual display" control that puts `screen: { width, height }` on the POST body (default 1280x800; the schema owns the 320..4096 check). - [ ] **Step 5: `bunx turbo build`, green tests, commit** `feat(web): Preview section with Open panel, Watch canvas, and the node switch`. @@ -555,9 +555,9 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc **Interfaces:** Consumes: the forward lane (Screen REUSES it - zero new protocol); produces `screen-viewer.tsx`. -- [ ] **Step 1: Install first, then read the library's real shape**: `bun add @novnc/novnc@1.7.0` in `apps/server/web`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it (the Task 10 `ws@8.22.0` precedent, `bun run lint:lockfile:fix` and by-path `bun.lock` staging included), and without the install this step's own `sed` finds nothing; then read (`sed -n '1,80p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. +- [ ] **Step 1: Install first, then read the library's real shape**: `bun add @novnc/novnc@1.7.0` in `apps/server/web`, then `syncpack fix && bun install` - the Files line owns that dependency and no other step lands it (the Task 10 `ws@8.22.0` precedent, `bun run lint:lockfile:fix` and by-path `bun.lock` staging included), and without the install this step's own `sed` finds nothing; then read (`sed -n '85,140p' node_modules/@novnc/novnc/core/rfb.js` for the constructor/options - the default class starts at :90 of the shipped file, so lines 1-80 are imports and constants; `docs/API.md` ships in the package's `files`). Static top-level `import RFB from "@novnc/novnc"` - decision 4, no dynamic import anywhere. - [ ] **Step 2: Failing test** (mocked module; the component constructs on mount, disconnects on unmount - assert the lane's `viewer-left` path). -- [ ] **Step 3: Implement:** `new RFB(el, wsUrl)` with `scaleViewport = true`, `focusOnClick = true`; no credentials (spec §5: the lane and the grant are the authentication); `on disconnect` render the reason, `clipViewport` false. Then wire the Screen BUTTON into `preview-section.tsx` (Task 12's Files deferred it to this task; nothing else lands it): when `info.screen` is present and the caller holds `edit`, a `Screen` action POSTs open against `info.screen.rfbPort` (decision 6 makes the session `raw`) and mounts this viewer in the panel. Its render test goes in Step 2: the button appears iff `info.screen && edit`, and pressing it drives exactly one open call against `info.screen.rfbPort` (spy) - the manual Task 14 container leg is otherwise the ONLY coverage this button ever gets, and manual legs do not gate. +- [ ] **Step 3: Implement:** the component creates its OWN WebSocket and hands the SOCKET to RFB: `const ws = new WebSocket(wsUrl)` where `wsUrl` is the session's tunnel root (the same URL Step 2's assertion reads), then `new RFB(el, ws)` with `scaleViewport = true`, `focusOnClick = true`, `clipViewport` false; no credentials (spec §5: the lane and the grant are the authentication); render each named cause from the socket's own `close` listener, reading `event.reason`. The component-owns-the-socket form is NOT a style choice: measured against the shipped 1.7.0, the constructor accepts URL, WebSocket or RTCDataChannel (rfb.js:95-113), but the URL-string form gives RFB the socket and its host-facing `disconnect` event detail carries only `{ clean }` (rfb.js:943-945; the steady-state close handler reads `e.code`/`e.reason` into a message string it then discards, rfb.js:665-669) - so pv-ws's 1011 + reason word (Task 10's wire shape) reaches the UI ONLY through the component's own listener, and a URL-string construction would render `revoked`, `slow-link`, `target-gone` and `pane-gone` all as generic "connection lost", shipping spec §9's "tells it why" inverted. Then wire the Screen control into `preview-section.tsx` (Task 12's Files deferred it to this task; nothing else lands it): the control appears whenever `info.screen` is present - the ENABLED action iff the caller holds `edit` (POST open against `info.screen.rfbPort`, decision 6 makes the session `raw`, and this viewer mounts in the panel), and for `view` it is the same present-but-tooltip-disabled control Task 12 Step 1 pins (round-14 MINOR: a literal "iff edit" gate on the RENDER would delete the affordance Task 12's greened test asserts - the two tasks must not ship two render contracts). Its render test goes in Step 2: presence follows `info.screen`, enabled-ness follows `edit`, and pressing the enabled control drives exactly one open call against `info.screen.rfbPort` (spy) - the manual Task 14 container leg is otherwise the ONLY coverage this button ever gets, and manual legs do not gate. - [ ] **Step 4: License record:** the package ships its MPL-2.0 LICENSE and `bun run lint:licenses` governs package fields; if the repo has a third-party notices list (`grep -riE "third[-_ ]?party" apps/website apps/docs docs | head`) add an entry, else say in the commit body that the package's own license file is the record (decision 1). - [ ] **Step 5: Green + commit** `feat(web): noVNC screen viewer over the forward lane`. @@ -570,7 +570,7 @@ Executor level too (fake context, the `commands-ssh-relay.test.ts` habit): switc - Modify: `apps/docs/content/docs/meta.json` (register `preview` in the `pages` list - Fumadocs renders only listed pages, so an unregistered page is an invisible page; pick the section the guides live in), `apps/server/api/AGENTS.md` (audit inventory: `node.preview_forward.open|close`, `node.preview_watch.open|close`, `node.preview_display.open|close`, `node.preview_enabled.update`, plus `node.preview_lane.over_cap` (the plane's sender-cap lane death, the only lane-level action - the session survives it) and `node.preview_watch.viewer_drop` (viewer-level drops that close nothing - the attach-cap refusal and the byte-debt detach - with the close-reason word in metadata and never a ref); preview-lane rules paragraph: loopback-only, the plane sees proxied bytes deliberately per spec §8, the §11.14 control now covers watch frames), `docs/security.md` (pointer paragraph in the relay accounting, matching how the relay lane is accounted), `apps/node/agent/AGENTS.md` (preview executors join the executor inventory; loopback invariant; `Xvfb`/`x11vnc`/`xauth` as optional runtime deps with named refusals). - [ ] **Step 1: e2e at API level through the stack helper** (async helpers only - the sync-spawn starvation trap): launch a terminal pane running `bun -e 'Bun.serve({port:4123, fetch: () => new Response(new TextEncoder().encode("ok"), { headers: { "set-cookie": "t=1" } })})'`; as owner cookie poll `GET /preview` (throttle-respecting) until `ports` lists 4123; `POST /preview/open` and fetch `/pv//` through the plane: body `ok`, cookie jar rewrite observed on the response; watch/unwatch leg: the e2e server is a CHILD PROCESS (`e2e/stack.ts:153` spawns it, so there is no in-proc seam to inject a fake stream through and no task creates one), so this leg asserts only the subscription round trip that REST + live can prove: watch POST 200, the broker's watch audit row appears, `DELETE` idempotent. Frame delivery through `/ws/live` is owned by Task 11's unit matrix (the §11.14 control) and the real frames by Step 2's container leg; say so in the spec file's comment so the next reader does not hunt for a missing assertion. Clean up sessions, pane, then close. -- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow. Then the mac node: every mac-REFUSING preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`; the switch itself and `set_preview_enabled` SUCCEED there, and forward lanes must keep working, with a hand-pinned port - spec §10's mac leg). +- [ ] **Step 2: Real-leg container run (manual, log results on the branch, not in CI - and that IS spec-faithful, not drift: §10 states "Neither live path gates the unit suite; they are the boundary check before hand-to-human testing," so this leg runs by hand rather than growing a containerized CI job):** Linux container with `chromium`, `xvfb`, `x11vnc`, `xauth` enrolled as a real node against `e2e/stack.ts`'s backend (spec §10): launch a pane with `screen`, run `chromium --remote-debugging-port=9222 http://localhost:4123` inside it, confirm Watch frames arrive (screenshot diff), confirm `ports` shows `{4123, 9222, screen}`, open a Screen RFB session through `/pv/` and confirm frame bytes flow - and run that Screen leg from the LAN-http origin too, not only localhost: the shipped rfb.js logs "noVNC requires a secure context (TLS). Expect crashes!" outside `window.isSecureContext` (rfb.js:101-103; with security type None no crypto is used so the warning should be cosmetic, but localhost IS a secure context and would hide exactly this). Then the mac node: every mac-REFUSING preview route answers its named refusal (`preview-ports-unsupported`, `preview-display-unsupported`; the switch itself and `set_preview_enabled` SUCCEED there, and forward lanes must keep working, with a hand-pinned port - spec §10's mac leg). - [ ] **Step 3: Docs paragraphs** (voice rules; `bun run lint:prose` clean). - [ ] **Step 4: Changeset + full boundary:** `bunx changeset` - `@internal/server` minor + `@internal/node` minor (the Files line lands HERE because no other step touches it; never hand-edit CHANGELOGs, and stage the generated file by path). Then `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test`, `bunx turbo build`; fix any protocol-23 stragglers. - [ ] **Step 5: Commit + push the branch. NO PR** - hand the user the test checklist (node switch UI, watch a live browser pane, open a dev server, Screen on Linux, refusals on mac, phone layout - Open/Screen hidden and Watch still there, the spec §7 rule Task 12 Step 3 ships but no test drives). From 955e1962945500ac2efb3292f760a49583bfde85 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 19:50:49 -0700 Subject: [PATCH 030/139] feat: per-node preview switch mirroring the ssh enable posture --- .../agent/src/__tests__/preview-flags.test.ts | 314 +++++++++++++++++ apps/node/agent/src/commands/context.ts | 12 + apps/node/agent/src/commands/index.ts | 3 + apps/node/agent/src/commands/report.ts | 64 +++- .../src/commands/set-preview-enabled-exec.ts | 32 ++ apps/node/agent/src/daemon.ts | 26 +- apps/node/agent/src/preview/flags.ts | 172 ++++++++++ .../set-node-preview-enabled.route.test.ts | 229 +++++++++++++ apps/server/api/src/api/nodes/index.ts | 2 + apps/server/api/src/api/nodes/node-view.ts | 5 + .../nodes/set-node-preview-enabled.route.ts | 78 +++++ .../__tests__/backup-cli-subprocess.test.ts | 24 +- apps/server/api/src/db/migrate.ts | 2 + .../migrations/0062-node-preview-enabled.ts | 18 + .../0062-node-preview-enabled.test.ts | 101 ++++++ .../src/db/repositories/nodes.repository.ts | 33 ++ .../server/api/src/db/types/nodes.db-types.ts | 14 + apps/server/api/src/index.ts | 4 + .../api/src/services/backups/database.ts | 2 +- .../nodes/__tests__/node-events.test.ts | 2 + .../nodes/__tests__/node-ws-handler.test.ts | 93 ++++++ .../nodes/__tests__/preview-enabled.test.ts | 315 ++++++++++++++++++ .../nodes/__tests__/remote-launcher.test.ts | 2 + .../api/src/services/nodes/node-events.ts | 23 +- .../api/src/services/nodes/node-ws-handler.ts | 21 ++ .../api/src/services/nodes/preview-enabled.ts | 152 +++++++++ .../src/api-key-permissions.ts | 5 + 27 files changed, 1736 insertions(+), 12 deletions(-) create mode 100644 apps/node/agent/src/__tests__/preview-flags.test.ts create mode 100644 apps/node/agent/src/commands/set-preview-enabled-exec.ts create mode 100644 apps/node/agent/src/preview/flags.ts create mode 100644 apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts create mode 100644 apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts create mode 100644 apps/server/api/src/db/migrations/0062-node-preview-enabled.ts create mode 100644 apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts create mode 100644 apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts create mode 100644 apps/server/api/src/services/nodes/preview-enabled.ts diff --git a/apps/node/agent/src/__tests__/preview-flags.test.ts b/apps/node/agent/src/__tests__/preview-flags.test.ts new file mode 100644 index 000000000..2091bdcd9 --- /dev/null +++ b/apps/node/agent/src/__tests__/preview-flags.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { maybeReportPreviewEnabled, seedPreviewEnabledMemo } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { + getPreviewEnabled, + previewEnabledPath, + readPreviewEnabled, + reportablePreviewEnabled, + setPreviewEnabled, +} from "../preview/flags.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { captureLogs } from "./helpers/capture-logs.js"; + +/** + * The preview mirror (pane-preview spec Task 3): the SSH capability mirror's + * twin in mechanics and in default. Persistence and the file-mode doctrine + * clone `ssh-enabled.ts` exactly (0600, temp+rename, its own file beside it); + * so does the FAIL-CLOSED read with the allowing default inverted the same way + * — the plane is the only writer, and "never told" is not "allowed" for a gate + * that stands over watching panes. + * + * The wiring half goes through `dispatchCommand`, so the frame parser and the + * switch are exercised too (the `commands-ssh-relay.test.ts` posture). + */ + +const STAMP = "2026-10-21T10:00:00.000Z"; +const made: string[] = []; + +function freshDataDir(): string { + // realpath'd like the commands harnesses: macOS hides /private behind the + // temp dir, and an assertion about the file path should test the path the + // daemon itself would write. + const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-preview-on-"))); + made.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The mirror's own mtime, as the unreadable branch stamps it. */ +function mtimeOf(dataDir: string): string { + return statSync(previewEnabledPath(dataDir)).mtime.toISOString(); +} + +/** A context whose tmux THROWS on anything unstubbed (the ssh harness). */ +function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext { + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const tmux = new Proxy( + {}, + { + get: () => () => { + throw new Error("tmux must not be reached"); + }, + }, + ); + return { + config, + tmux: tmux as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("persistence", () => { + it("an empty dir reads `absent`: nothing has said yes, so preview is refused", () => { + expect(readPreviewEnabled(freshDataDir())).toEqual({ kind: "absent" }); + }); + + it("round-trips the exact stamp it was given", () => { + const dataDir = freshDataDir(); + expect(setPreviewEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + }); + + it("stores the whole wire verbatim — `ready.previewEnabled` is buildable from what was written", () => { + const dataDir = freshDataDir(); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(JSON.parse(readFileSync(previewEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); + }); + + it("stores `on: false` as a value too, but the mirror's ANSWER for it is refusal", () => { + // The plane stamps the file when it turns preview off, and the bytes stay + // on disk; what the reader answers is the gate's answer, and an off file + // and no file give the same one. The plane's own row is the record, and + // it agrees. + const dataDir = freshDataDir(); + const state = { on: false, changedAt: "2026-10-21T11:00:00.000Z" }; + setPreviewEnabled(dataDir, state); + expect(JSON.parse(readFileSync(previewEnabledPath(dataDir), "utf8"))).toEqual(state); + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("writes the file 0600, and creates a missing data dir rather than throwing", () => { + const dataDir = freshDataDir(); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(statSync(previewEnabledPath(dataDir)).mode & 0o777).toBe(0o600); + const nested = join(freshDataDir(), "nested", "data"); + setPreviewEnabled(nested, { on: true, changedAt: STAMP }); + expect(exists(previewEnabledPath(nested))).toBe(true); + }); + + it("leaves no temp file behind, and the file it leaves is whole", () => { + // Atomic because a half-written file parses as corrupt, and corrupt means + // REFUSED here — a torn write would cut preview for the duration. + const dataDir = freshDataDir(); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + setPreviewEnabled(dataDir, { on: false, changedAt: "2026-10-21T12:00:00.000Z" }); + expect(readdirSync(dataDir)).toEqual(["preview-enabled.json"]); + }); + + function exists(file: string): boolean { + try { + statSync(file); + return true; + } catch { + return false; + } + } +}); + +describe("reading is fail-CLOSED", () => { + it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => { + const dataDir = freshDataDir(); + writeFileSync(previewEnabledPath(dataDir), "{not json"); + const cap = captureLogs(); + try { + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + } finally { + cap.restore(); + } + expect(cap.lines.filter((l) => l.includes("preview"))).toHaveLength(1); + }); + + it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => { + // Same classification the SSH mirror gives garbage, and the same reason: + // both halves or nothing. A content fixture rather than chmod-000: an + // EACCES read fails into the same branch, so the spellings of "cannot + // read" must not answer differently. + const dataDir = freshDataDir(); + writeFileSync(previewEnabledPath(dataDir), JSON.stringify({ on: true })); + const cap = captureLogs(); + try { + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + writeFileSync(previewEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP })); + expect(readPreviewEnabled(dataDir).kind).toBe("unreadable"); + } finally { + cap.restore(); + } + }); +}); + +describe("the gate read", () => { + it("allows ONLY a parsed on:true mirror; absent, unreadable, and malformed all read as OFF", () => { + // The whole point of the flag: a node that cannot read its own setting + // refuses. The SSH twin's classifier pins absent and unreadable; the + // malformed-shape case joins here as the third because the gate reads a + // FILE through `getPreviewEnabled`, and a file whose bytes are not a + // state must answer the same refusal as a missing one. + const allowed = freshDataDir(); + setPreviewEnabled(allowed, { on: true, changedAt: STAMP }); + expect(getPreviewEnabled(allowed)?.on).toBe(true); + + expect(getPreviewEnabled(freshDataDir())?.on ?? false).toBe(false); // absent + + const corrupt = freshDataDir(); + writeFileSync(previewEnabledPath(corrupt), "{not json"); + expect(getPreviewEnabled(corrupt)?.on ?? false).toBe(false); // unreadable + + const malformed = freshDataDir(); + writeFileSync(previewEnabledPath(malformed), JSON.stringify({ on: true })); // no stamp - not a state + const cap = captureLogs(); + try { + expect(getPreviewEnabled(malformed)?.on ?? false).toBe(false); // malformed shape + } finally { + cap.restore(); + } + }); +}); + +describe("what a read is worth announcing", () => { + it("announces `on` verbatim; an unreadable mirror as the REFUSAL it produces", () => { + // The machine owes the plane the answer it is actually acting on, and a + // row that disagrees earns the push that rewrites the broken file. + expect(reportablePreviewEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(reportablePreviewEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ + on: false, + changedAt: STAMP, + }); + }); + + it("sends nothing for absent, and nothing an unstamped unreadable", () => { + expect(reportablePreviewEnabled({ kind: "absent" })).toBeUndefined(); + expect(reportablePreviewEnabled({ kind: "unreadable" })).toBeUndefined(); + }); +}); + +describe("set_preview_enabled (the plane's half)", () => { + it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + const cmd = { type: "set_preview_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody; + expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true }); + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + expect(ctx.lastReportedPreviewEnabled).toEqual({ on: true, changedAt: STAMP }); + expect(events).toEqual([]); // the command answers with a result; it announces nothing + }); + + it("an off flip also lands verbatim, and the machine says nothing about it", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + await dispatchCommand(ctx, { type: "set_preview_enabled", on: false, changedAt: "2026-10-21T13:00:00.000Z" }); + maybeReportPreviewEnabled(ctx); + expect(events).toEqual([]); + }); +}); + +describe("the heartbeat belt", () => { + it("reports a mirror that moved outside the plane's own push, once", () => { + // The external-edit case the task exists to cover: the file is rewritten + // by hand between commands, and the belt re-reads and re-reports it. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved + + maybeReportPreviewEnabled(ctx); + maybeReportPreviewEnabled(ctx); + + expect(events).toEqual([{ type: "preview_enabled", on: true, changedAt: STAMP }]); + expect(ctx.lastReportedPreviewEnabled).toEqual({ on: true, changedAt: STAMP }); + }); + + it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => { + // The repair is the plane's push (its row wins); a node overwriting the + // corrupt file would destroy the bytes an operator may want to look at, + // and inventing an `off` here would mask the disagreement the report + // exists to raise. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + writeFileSync(previewEnabledPath(dataDir), "{not json"); + const stamp = mtimeOf(dataDir); + const ctx = makeCtx(dataDir, events); + const cap = captureLogs(); + try { + maybeReportPreviewEnabled(ctx); + maybeReportPreviewEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([{ type: "preview_enabled", on: false, changedAt: stamp }]); + expect(readPreviewEnabled(dataDir).kind).toBe("unreadable"); + }); + + it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => { + // Maintenance re-writes a vanished mirror from its memo because a CLI at + // the keyboard can delete it legitimately. There is no such writer here: + // hand-deleting this file is not an interface, and the next `ready` makes + // the plane push the row value back. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + seedPreviewEnabledMemo(ctx); + unlinkSync(previewEnabledPath(dataDir)); + + maybeReportPreviewEnabled(ctx); + + expect(events).toEqual([]); + expect(readPreviewEnabled(dataDir)).toEqual({ kind: "absent" }); + }); + + it("seed returns the value ready carries and silences the first heartbeat for it", () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(seedPreviewEnabledMemo(ctx)).toEqual({ on: true, changedAt: STAMP }); + maybeReportPreviewEnabled(ctx); + expect(events).toEqual([]); + expect(seedPreviewEnabledMemo(makeCtx(freshDataDir()))).toBeUndefined(); + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 11056083f..79f5797fc 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -4,6 +4,7 @@ import type { NodeCommandBody, NodeEvent, NodeMaintenanceWire, + NodePreviewEnabledWire, NodeRuntimeReport, NodeSshEnabledWire, RelayFrame, @@ -192,6 +193,17 @@ export interface CommandContext { * repair), so there is no failing restore to say anything about. */ lastReportedSshEnabled?: NodeSshEnabledWire; + /** + * The last preview-switch value this connection spoke about — the same echo + * memo as {@link lastReportedSshEnabled}, cloned with the same reasoning: + * the plane is the mirror's ONLY writer, so the belt reports a mirror that + * moved by accident (corruption, mostly) and never the plane's own push + * come back one tick later. Seeded at every connect from the same read + * that built `ready`. No restore-from-memo partner here either: a + * hand-deleted plane-only file is not an interface, and the next `ready` + * makes the plane push the row value back. + */ + lastReportedPreviewEnabled?: NodePreviewEnabledWire; /** * Whether this connection has already reported a mirror it could not * rewrite (see `restoreMirrorFromMemo`). diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index f050247a2..2de90d41f 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -26,6 +26,7 @@ import { execPromptDeliver } from "./prompt.js"; import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; +import { execSetPreviewEnabled } from "./set-preview-enabled-exec.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; import { execSshDiscoverAliases, execSshResolveConfig } from "./ssh-aliases.js"; @@ -158,6 +159,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetLogLevel(ctx, cmd); case "set_maintenance": return execSetMaintenance(ctx, cmd); + case "set_preview_enabled": + return execSetPreviewEnabled(ctx, cmd); case "set_ssh_enabled": return execSetSshEnabled(ctx, cmd); case "ssh_discover_aliases": diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index af53109d5..f2f710fa0 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -1,9 +1,15 @@ import { rm } from "node:fs/promises"; import { dirname } from "node:path"; import { buildSshConfigPath } from "@internal/pane-runtime"; -import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; +import type { + NodeEvent, + NodeMaintenanceWire, + NodePreviewEnabledWire, + NodeSshEnabledWire, +} from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; +import { readPreviewEnabled, reportablePreviewEnabled } from "../preview/flags.js"; import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; import type { CommandContext, WatcherRegistration } from "./context.js"; @@ -208,6 +214,62 @@ export function seedSshEnabledMemo(ctx: CommandContext): NodeSshEnabledWire | un return ctx.lastReportedSshEnabled; } +/** + * Announce one preview-switch mirror state to the plane and remember having + * done so (pane-preview spec Task 3). The SSH twin's rule, cloned: the memo + * update is what keeps the next heartbeat tick silent about a value the plane + * already holds — and what keeps a `set_preview_enabled` the plane just + * pushed from coming back as an event. + * + * @param ctx - the daemon's command context (owns the socket seam and the memo) + * @param state - the mirror's answer, sent verbatim (never re-stamped here) + */ +export function reportPreviewEnabled(ctx: CommandContext, state: NodePreviewEnabledWire): void { + ctx.ws.send({ type: "preview_enabled", on: state.on, changedAt: state.changedAt }); + ctx.lastReportedPreviewEnabled = state; +} + +/** + * Report the preview mirror IF it has moved since this connection last spoke + * about it — the heartbeat belt, the SSH twin's posture in full. + * + * With no node-side writer, the common steady state says nothing: an + * off-or-absent mirror has nothing to announce (the plane's own row is + * authoritative, and silence reads as refusal on both ends), and an on mirror + * only speaks up once. What this loop exists to catch is the file changing + * UNDER the connection — corruption flipping a machine from allowed to + * refused mid-session, or a hand-edit — so the plane learns it now and pushes + * its row (the repair) rather than discovering the disagreement at the next + * reconnect. There is no absent-restore half: the plane is the only writer, + * so a deleted mirror is silence, and the next `ready` reconciles it. + * + * @param ctx - the daemon's command context + */ +export function maybeReportPreviewEnabled(ctx: CommandContext): void { + const state = reportablePreviewEnabled(readPreviewEnabled(ctx.config.dataDir)); + if (!state) return; + const last = ctx.lastReportedPreviewEnabled; + if (last?.on === state.on && last.changedAt === state.changedAt) return; + reportPreviewEnabled(ctx, state); +} + +/** + * Seed this connection's preview memo from the mirror on disk. + * + * Called once per connect, immediately before `ready` — which carries the same + * value, so the first heartbeat must not repeat it — and again on every + * reconnect, because a new socket has told the plane nothing. The one read of + * the file builds both, so the frame and the memo cannot disagree. + * + * @param ctx - the daemon's command context + * @returns the value `ready` should carry, or undefined when there is nothing + * truthful to report + */ +export function seedPreviewEnabledMemo(ctx: CommandContext): NodePreviewEnabledWire | undefined { + ctx.lastReportedPreviewEnabled = reportablePreviewEnabled(readPreviewEnabled(ctx.config.dataDir)); + return ctx.lastReportedPreviewEnabled; +} + /** Stop the shared tick if one is running (idempotent). */ function stopLoop(ctx: CommandContext): void { if (ctx.watchTick !== undefined) { diff --git a/apps/node/agent/src/commands/set-preview-enabled-exec.ts b/apps/node/agent/src/commands/set-preview-enabled-exec.ts new file mode 100644 index 000000000..d4ee45fa3 --- /dev/null +++ b/apps/node/agent/src/commands/set-preview-enabled-exec.ts @@ -0,0 +1,32 @@ +import { setPreviewEnabled } from "../preview/flags.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * `set_preview_enabled` (pane-preview spec Task 3): the plane's half of the + * preview switch, reached only through `dispatchCommand`'s already-verified + * frame — an unsigned or misauthenticated command never gets this far. + * + * It writes the file and NOTHING else. In particular it opens no lane and + * revokes nothing: the gate is checked when a preview act is asked for, so + * turning it off takes effect on the next one, and turning it on grants no + * viewer anything the plane's sharing had not already admitted. + * + * The plane's own `changedAt` is stored verbatim, so both copies end + * byte-identical; re-stamping would leave the next `ready` reporting a + * disagreement the link latency invented. + * + * A failing write propagates: `dispatchCommand` turns the throw into + * `{ ok: false }`, and the plane learns its push did not land rather than + * recording a mirror that does not exist. + * + * @param ctx - the per-daemon execution context (data dir + the report memo) + * @param cmd - the verified `set_preview_enabled` command + * @returns `{ ok: true }` once the file is on disk + */ +export function execSetPreviewEnabled(ctx: CommandContext, cmd: Cmd<"set_preview_enabled">): CommandResult { + // Memoized as REPORTED, not merely written: the plane is the source of this + // value — the ONLY source it will ever have — so sending it back on the next + // heartbeat tick would be an echo the plane then reconciles against itself. + ctx.lastReportedPreviewEnabled = setPreviewEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); + return { ok: true }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 9de000d34..950541b85 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -9,6 +9,7 @@ import { NODE_PROTOCOL_VERSION, type NodeEvent, type NodeMaintenanceWire, + type NodePreviewEnabledWire, type NodeRuntimeReport, type NodeSshEnabledWire, parseRelayFrame, @@ -22,8 +23,10 @@ import { dispatchCommand } from "./commands/index.js"; import { buildSubshellsReport, maybeReportMaintenance, + maybeReportPreviewEnabled, maybeReportSshEnabled, seedMaintenanceMemo, + seedPreviewEnabledMemo, seedSshEnabledMemo, } from "./commands/report.js"; import { RelaySessions } from "./commands/ssh-relay.js"; @@ -360,6 +363,7 @@ function readyEvent( runtime: NodeRuntimeReport | null, maintenance: NodeMaintenanceWire | undefined, sshEnabled: NodeSshEnabledWire | undefined, + previewEnabled: NodePreviewEnabledWire | undefined, ): Extract { return { type: "ready", @@ -414,6 +418,12 @@ function readyEvent( // its own mtime, so the plane can repair the file instead of holding a // silent disagreement until the next reconnect. ...(sshEnabled ? { sshEnabled } : {}), + // This machine's preview-switch mirror (pane-preview spec Task 3), + // carried exactly as the SSH one is: OMITTED when absent, and an + // UNREADABLE file carried as the refusal it acts on under its own mtime, + // so the plane can repair the file instead of holding a silent + // disagreement until the next reconnect. + ...(previewEnabled ? { previewEnabled } : {}), }; } @@ -1235,7 +1245,16 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // The SSH mirror rides the same synchronous read + seed as the // maintenance one (spec 2026-10-07 §4.3): the value `ready` carries and // the memo that stops the first heartbeat repeating it are one read. - send(ws, readyEvent(config, sshRuntime, seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx))); + send( + ws, + readyEvent( + config, + sshRuntime, + seedMaintenanceMemo(ctx), + seedSshEnabledMemo(ctx), + seedPreviewEnabledMemo(ctx), + ), + ); // Connect-time `subshells_report` (spec §3.3): re-projects the panes // that survived an agent restart so the control plane heals its rows. // Fire-and-forget with catch-log — a scan failure (junk meta, tmux @@ -1315,6 +1334,11 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // and this tick is its ONLY mid-session path — there are no deaths // for it to ride ahead of, because an SSH flip kills no panes. maybeReportSshEnabled(ctx); + // The preview switch's belt (pane-preview spec Task 3): the same + // quiet shape, the same single mid-session path — a preview flip + // closes no panes here, so this tick is the only way the plane + // learns the mirror moved before the next reconnect. + maybeReportPreviewEnabled(ctx); }, heartbeatMs); // Periodic `inventory` push — the "every 5 min" leg of spec §7 // (P3-T8c). The timer's lifecycle mirrors the heartbeat's — armed diff --git a/apps/node/agent/src/preview/flags.ts b/apps/node/agent/src/preview/flags.ts new file mode 100644 index 000000000..ee00c0985 --- /dev/null +++ b/apps/node/agent/src/preview/flags.ts @@ -0,0 +1,172 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type NodePreviewEnabledWire, parseNodePreviewEnabled } from "@internal/subshell-protocol"; +import { logger } from "../log.js"; + +/** + * This node's MIRROR of the preview switch (pane-preview spec Task 3): one + * word meaning "this machine's pane content may be watched at all". + * + * It is the SSH capability mirror's twin in mechanics and in default. Same + * file-permission doctrine (0600, temp+rename, its own file beside + * `ssh-enabled.json` and for the same two reasons — `config.json` is + * snapshotted at boot and it is the node key's only home). Same fail-CLOSED + * read: what ENABLING lets Subshell do is stream pane content to anyone the + * plane's sharing admits, so a setting this process cannot read is not a + * permission. Absent refuses too — watching panes has no "before" to keep + * behaving like. + * + * **The plane is the sole writer.** There is no `subshell preview-enabled` + * verb, because turning a machine's pane-watching on from a keyboard at that + * machine is exactly the decision this gate exists to make someone make in a + * browser, as the owner, with an audit row. So the mirror has only one author + * — a `set_preview_enabled` push — and its readers are the preview gates + * (this process, fail-closed) and the `ready` report plus heartbeat belt that + * let the plane notice when the two copies disagree. + * + * Because no one stamps this file locally, nothing compares stamps across the + * two ends: reconciliation never runs newer-wins here, the plane pushes its + * row, and the node's copy converges by obeying rather than by bargaining. + */ + +/** File name inside the agent data dir. */ +const FILE = "preview-enabled.json"; + +/** Absolute path of the preview-switch mirror for a data dir. */ +export function previewEnabledPath(dataDir: string): string { + return join(dataDir, FILE); +} + +/** + * What the mirror says, as the three answers a caller must tell apart — the + * gate's answers, not the file's biography. Same shape as the SSH mirror's + * `SshEnabledRead`, cloned for the same reasons. + * + * `on` is the only kind that permits, and it carries the stamp because + * `ready.previewEnabled` must be buildable from what was written. A file that + * parses to `{ on: false, changedAt }` and a file that does not exist give the + * SAME answer to every reader (refuse; and the plane's row already says off, + * so there is nothing to report), and no node-side writer exists whose stamp + * anyone would later reconcile — so the off file reads as `absent` while its + * bytes stay on disk. `unreadable` refuses too, but unlike the other two it + * says so: the answer it produces may match a quiet row, and when the row says + * on it is a disagreement the plane can only repair if it hears about it. + */ +export type PreviewEnabledRead = + /** Nothing permits here: no file, or a file that says off. */ + | { kind: "absent" } + /** The mirror parsed and says yes: panes here may be watched. */ + | { + kind: "on"; + /** The stored stamp, verbatim — `ready.previewEnabled` is built from it. */ + changedAt: string; + } + /** The file exists but is not a state: REFUSED (fail-closed), reported under the file's mtime. */ + | { + kind: "unreadable"; + /** The file's mtime, ISO — absent only when the stat failed too, leaving nothing truthful to send. */ + changedAt?: string; + }; + +/** + * Reads the mirror. + * + * Total — every failure answers `unreadable` (one warn line) rather than + * throwing, because every caller is on a path where a throw would cost more + * than the wrong answer: the preview gates, a heartbeat tick, and the `ready` + * builder. + * + * The parse is the WIRE parser, not a private one: the file holds exactly what + * travels, and a second validator here would be a second opinion about what + * `{ on, changedAt }` means. + */ +export function readPreviewEnabled(dataDir: string): PreviewEnabledRead { + const file = previewEnabledPath(dataDir); + if (!existsSync(file)) return { kind: "absent" }; + try { + const state = parseNodePreviewEnabled(JSON.parse(readFileSync(file, "utf8"))); + if (!state) throw new SyntaxError("not a preview-enabled state"); + return state.on ? { kind: "on", changedAt: state.changedAt } : { kind: "absent" }; + } catch (err) { + logger.withError(err).warn(`preview-enabled: ${file} unreadable; treating this node as PREVIEW-DISABLED`); + // The mtime, never `now`: this answer refuses, so the plane has to hear + // about it (a row that says on would push the clean file that repairs it), + // and the one timestamp available that nobody invented is when the file + // last changed. A fresh `now` per read would defeat the report memo and + // put one frame on the wire per heartbeat, forever. A stat that fails in + // turn leaves the stamp off: the refusal stands, and there is nothing to + // send. + const changedAt = mtimeOf(file); + return changedAt ? { kind: "unreadable", changedAt } : { kind: "unreadable" }; + } +} + +/** + * A file's mtime as an ISO stamp. TOTAL — every caller is on a path where the + * answer is optional and a throw would cost the whole read. + */ +function mtimeOf(file: string): string | undefined { + try { + return statSync(file).mtime.toISOString(); + } catch { + return undefined; + } +} + +/** + * The wire value a read is worth announcing, or `undefined` when there is + * nothing truthful to say. + * + * Every reporting path goes through this so the answers cannot be classified + * differently in three places: `ready`, the heartbeat memo check and the + * preview gates all owe the plane the same value for the same file. An + * unreadable mirror announces the REFUSAL it actually produces — the SSH + * twin's rule, held for the same reason. + * + * @param read - what {@link readPreviewEnabled} answered + * @returns the `{ on, changedAt }` to send, or `undefined` for absent (the + * plane's row needs no echo from a node agreeing off) and for an unstamped + * unreadable + */ +export function reportablePreviewEnabled(read: PreviewEnabledRead): NodePreviewEnabledWire | undefined { + if (read.kind === "on") return { on: true, changedAt: read.changedAt }; + if (read.kind === "unreadable" && read.changedAt) return { on: false, changedAt: read.changedAt }; + return undefined; +} + +/** + * The value this machine would hand `ready`, and the shape every preview gate + * reads: a permit is `?.on === true` and NOTHING else — absent, unreadable, + * and a malformed file all answer a refusal (or nothing), never a permit. The + * fail-closed rule, spelled once at the read. + */ +export function getPreviewEnabled(dataDir: string): NodePreviewEnabledWire | undefined { + return reportablePreviewEnabled(readPreviewEnabled(dataDir)); +} + +/** + * Persists the mirror, 0600, via temp + rename. + * + * Atomic for the SSH reason and sharper: a gate may read this at any instant, + * and a half-written file parses as corrupt — which under the fail-closed rule + * REFUSES every preview act for as long as the write takes. + * + * The `state` is stored VERBATIM. The plane sent it; re-stamping here would + * make the two copies differ by exactly the link delay, and the next `ready` + * would report a disagreement this process invented — which the plane would + * "repair" by pushing the same value again, forever. + * + * @returns the state as stored + */ +export function setPreviewEnabled(dataDir: string, state: NodePreviewEnabledWire): NodePreviewEnabledWire { + const file = previewEnabledPath(dataDir); + const body: NodePreviewEnabledWire = { on: state.on, changedAt: state.changedAt }; + // 0700, matching the data dir `identity.ts` creates. mkdir's mode is + // umask-clamped, so it is a floor; the file's own 0600 below is set at + // creation and is the one that matters. + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, file); + return body; +} diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts new file mode 100644 index 000000000..d051658d9 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { systemKeysRoutes } from "@/api/system-keys.route.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/preview-enabled` (pane-preview spec Task 3) — the + * per-node preview switch, opt-in and off by default, cloned from the SSH + * capability route it sits beside. + * + * OWNER-only (`gate.canManage`, with the seeded-`local` admin exception), the + * same gate as SSH and maintenance: a grantee may restart the agent or read + * its log, but deciding whether this machine's pane content may be watched is + * not theirs. + * + * The route performs no write and records no audit itself — `setPreviewEnabled` + * is the one change-site (row + audit + push), so this file asserts the ROUTE + * wired the acting human into that row. + */ +describe("node preview-enabled route", () => { + const pw = "preview-enabled-1"; + const ownerEmail = `pv-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `pv-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `pv-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `pv-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-pv-${crypto.randomUUID()}`; + const createdKeyIds: string[] = []; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + /** Every `node.preview_enabled.update` row this file's node has collected, id-keyed. */ + async function previewAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.preview_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string, on: boolean) { + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/preview-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + body: JSON.stringify({ on }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + // Offline on purpose: the route answers anyway (the flag is the + // plane's record), and no push can be seen from here - the push + // assertions live in the service test's scripted-wire pattern. + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found: every file in this + // package gets its own database, but a flipped host switch still reads as + // the fleet default if a later test in THIS file's process re-reads it. + await db + .updateTable("nodes") + .set({ previewEnabled: 0, previewEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const id of createdKeyIds) { + await systemKeysRoutes.fetch(authDelete(id)); + } + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + function authDelete(id: string): Request { + return new Request(`http://localhost:3080/api/system-keys/${id}`, { + method: "DELETE", + headers: { cookie: `better-auth.session_token=${adminCookie}` }, + }); + } + + it("reads OFF before anyone has flipped it (no row = no history = off)", async () => { + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}`, { + headers: { cookie: `better-auth.session_token=${ownerCookie}` }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { previewEnabled: boolean }).previewEnabled).toBe(false); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + const view = (await res.json()) as { previewEnabled: boolean }; + expect(view.previewEnabled).toBe(true); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + // The ROUTE is the only place `actorUserId` is wired in — the service + // takes it as an argument, so a regression to a null actor passes every + // test of the service alone. + await put(NODE, ownerCookie, false); + const before = await previewAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered, so a newest-first read + // cannot name this act's row. + const added = [...(await previewAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + // The metadata is the STRING `"{\"on\":true}"`: the flag and nothing else + // (audit never carries secrets). + expect(added[0][1].metadataJson).toBe('{"on":true}'); + }); + + it("turning it off lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { previewEnabled: boolean }).previewEnabled).toBe(false); + }); + + it("an edit grantee is refused — it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; deciding whether someone may watch this machine's panes is a + // different act. + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a system key scoped to SSH cannot flip preview — the grants are separate", async () => { + // The catalog split this task had to make: reusing `nodes:ssh` would let + // every ssh-scoped key flip preview. A key minted with the SSH grant and + // without `nodes:preview` is refused at the permission layer, before any + // gate reads the row. + const auditsBefore = (await previewAudit()).size; + const created = await systemKeysRoutes.fetch( + new Request("http://localhost:3080/api/system-keys", { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${adminCookie}` }, + body: JSON.stringify({ name: `pv-pin-${crypto.randomUUID().slice(0, 8)}`, permissions: ["nodes:ssh"] }), + }), + ); + expect(created.status).toBe(200); + const { key, id } = (await created.json()) as { key: string; id: string }; + createdKeyIds.push(id); + + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}/preview-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, + body: JSON.stringify({ on: true }), + }), + ); + expect(res.status).toBe(403); + // The scoped-key refusal comes from the authGuard's onBeforeHandle as a + // plain-text HttpError body, not the route's JSON error shape - the route + // never ran. The grant it names is the point. + expect(await res.text()).toContain("nodes:preview"); + // The refusal is real: the audit trail did not grow. + expect((await previewAudit()).size).toBe(auditsBefore); + }); + + it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, true)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there — refused, not invisible (a node on the page must + // not answer "not found"). + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + // Like SSH and unlike maintenance, this flip stops nothing, so ON is safe + // to test on the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { previewEnabled: boolean }).previewEnabled).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index 5703f1c03..295172be0 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -19,6 +19,7 @@ import { serviceNodeRoute } from "@/api/nodes/service-node.route.js"; import { setNodeAllowedDirsRoute } from "@/api/nodes/set-node-allowed-dirs.route.js"; import { setNodeCrossAgentRoute } from "@/api/nodes/set-node-cross-agent.route.js"; import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route.js"; +import { setNodePreviewEnabledRoute } from "@/api/nodes/set-node-preview-enabled.route.js"; import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; @@ -57,6 +58,7 @@ const nodeRegistryRoutes = new Elysia() const nodeOperationsRoutes = new Elysia() .use(setNodeMaintenanceRoute) .use(setNodeSshEnabledRoute) + .use(setNodePreviewEnabledRoute) .use(repairNodeMachinePinRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 3ca55960c..47e5e61dd 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -181,6 +181,10 @@ export const NodeViewSchema = t.Object({ description: "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", }), + previewEnabled: t.Boolean({ + description: + "Whether this machine's pane content may be watched (live preview and screencasts). Opt-in per node, off by default on every row including the control-plane host; every preview door refuses here while false, whoever asks", + }), sshWideForwards: t.Boolean({ description: "Whether SSH port forwards on this machine may bind non-loopback addresses. Off by default: forwards bind loopback only, and widening turns the machine into a router for whatever network reaches the bound port. The owner's decision, refused at launch while false", @@ -483,6 +487,7 @@ function nodeViewBase( maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, sshEnabled: row.sshEnabled === 1, + previewEnabled: row.previewEnabled === 1, sshWideForwards: row.sshWideForwards === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, diff --git a/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts new file mode 100644 index 000000000..a8c1ebbf5 --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts @@ -0,0 +1,78 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { ForbiddenError } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { nodeManagementGuard } from "@/api/nodes/node-management-guard.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { setPreviewEnabled } from "@/services/nodes/preview-enabled.js"; + +const PreviewEnabledBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow this machine's pane content to be watched (live preview and screencasts). Off by default. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/preview-enabled` `{on}` — open or close pane preview on + * one machine (pane-preview spec Task 3). Browser session or an API key + * holding the `nodes:preview` grant. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as SSH and maintenance: a grantee may restart the agent or + * read its log, but deciding whether this machine's pane content may be + * watched is not theirs. The permission is its OWN catalog id, not + * `nodes:ssh` — a key scoped to SSH must not be able to flip preview; the + * grants answer different questions. + * + * The route writes nothing and audits nothing itself: `setPreviewEnabled` is + * the one change-site (row + audit + push), so no caller can perform half the + * act — and the route is the only place the acting human is wired in. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` reconcile. + * Refusing would make the flag settable only while the machine is reachable, + * which is backwards for a gate that exists to be closed. + */ +export const setNodePreviewEnabledRoute = new Elysia() + .use(nodeManagementGuard) + .use(apiModels) + .put( + "/:id/preview-enabled", + async ({ params, body, user, actor, status }) => { + const gate = await loadNodeGate(user.id, params.id, actor); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setPreviewEnabled(gate.row.id, user.id, body.on); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored, not what this + // handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: PreviewEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "nodes:preview", + detail: { + operationId: "setNodePreviewEnabled", + tags: ["nodes"], + description: + "Allow or refuse pane preview on a node (owner only; admin on the control-plane host). Off by default; enabling lets authorized viewers watch this machine's pane content.", + }, + }, + ); diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 68f26dfbc..2c6c862b4 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047..0058 together: this era's twelve migrations are the tail of the + // WHY 0047..0062 together: this era's sixteen migrations are the tail of the // registered ledger, so the prefix runs contiguously through 0046 and boot - // re-applies all twelve. Advancing LATEST_BACKUP_MIGRATION (0058) means the - // strip set moves with it in the same commit. + // re-applies all sixteen. Advancing LATEST_BACKUP_MIGRATION means the strip + // set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN preview_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN preview_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN preview_enabled_at text; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled') ORDER BY name", ) .all(), ).toEqual([ @@ -449,6 +449,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0059-cross-agent-guidance" }, { name: "0060-subshell-parent" }, { name: "0061-node-system-specs" }, + { name: "0062-node-preview-enabled" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -474,6 +475,13 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_wide_forwards'").get()).toEqual({ name: "ssh_wide_forwards", }); + expect( + migrated + .query( + "SELECT name FROM pragma_table_info('nodes') WHERE name IN ('preview_enabled','preview_enabled_at') ORDER BY name", + ) + .all(), + ).toEqual([{ name: "preview_enabled" }, { name: "preview_enabled_at" }]); expect(migrated.query("SELECT name FROM pragma_table_info('ssh_presets') WHERE name='forwards'").get()).toEqual({ name: "forwards", }); diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 8f916b997..135557c10 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -59,6 +59,7 @@ import * as workspaceFirstMigration from "@/db/migrations/0058-workspace-first.j import * as crossAgentGuidanceMigration from "@/db/migrations/0059-cross-agent-guidance.js"; import * as subshellParentMigration from "@/db/migrations/0060-subshell-parent.js"; import * as nodeSystemSpecsMigration from "@/db/migrations/0061-node-system-specs.js"; +import * as nodePreviewEnabledMigration from "@/db/migrations/0062-node-preview-enabled.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; import * as presetSharingMigration from "./migrations/0057-preset-sharing.js"; @@ -177,6 +178,7 @@ export async function runMigrations(): Promise { "0059-cross-agent-guidance": crossAgentGuidanceMigration, "0060-subshell-parent": subshellParentMigration, "0061-node-system-specs": nodeSystemSpecsMigration, + "0062-node-preview-enabled": nodePreviewEnabledMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts b/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts new file mode 100644 index 000000000..1f4d73c68 --- /dev/null +++ b/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node preview switch. Default 0 keeps the whole fleet (and the +// control-plane host) off after an upgrade: watching panes is opt-in per +// machine, and preview exposes terminal content, so an upgrade must never +// turn it on anywhere (0047's SSH-capability shape and doctrine). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("preview_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + await db.schema.alterTable("nodes").addColumn("preview_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("preview_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("preview_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts new file mode 100644 index 000000000..d55ffd366 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as previewEnabledMigration from "@/db/migrations/0062-node-preview-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The per-node preview switch columns (pane-preview spec Task 3). Default 0 is + * the whole upgrade story, cloned from 0047's doctrine: a node enrolled before + * this migration (and the seeded local row) reads OFF, because watching panes + * is opt-in per machine — preview exposes terminal content, so an upgrade must + * never turn it on anywhere. + */ +describe("migration 0062-node-preview-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0062 repository did - no preview fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-21T00:00:00.000Z", + updated_at: "2026-10-21T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0062-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await previewEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).toContain("preview_enabled"); + expect(names).toContain("preview_enabled_at"); + }); + + it("leaves every pre-existing node with preview off (default-off doctrine)", async () => { + const r = await sql<{ + preview_enabled: number; + preview_enabled_at: string | null; + }>`SELECT preview_enabled, preview_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ preview_enabled: 0, preview_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ preview_enabled: number }>`SELECT preview_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].preview_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ preview_enabled: 1, preview_enabled_at: "2026-10-21T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + preview_enabled: number; + preview_enabled_at: string; + }>`SELECT preview_enabled, preview_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ preview_enabled: 1, preview_enabled_at: "2026-10-21T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await previewEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("preview_enabled"); + expect(names).not.toContain("preview_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await previewEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index b87987caa..a8c658b43 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -63,6 +63,10 @@ export class NodesRepository extends BaseRepository { // and the gate is opt-in per machine (spec 4.3); mirror it the same way. sshEnabled: input.sshEnabled ?? 0, sshEnabledAt: input.sshEnabledAt ?? null, + // A new node watches no panes: `0` is the column default (migration + // 0062) and preview is opt-in per machine; mirror it the same way. + previewEnabled: input.previewEnabled ?? 0, + previewEnabledAt: input.previewEnabledAt ?? null, sshWideForwards: 0, createdAt: input.createdAt ?? now, updatedAt: now, @@ -297,6 +301,35 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the preview switch and its stamp (pane-preview spec Task 3). + * + * Same shape as {@link setSshEnabled}: both fields move together, and the + * CALLER supplies `changedAt` — the stamp is the reconciliation protocol + * between the plane's row and the node's mirror file, so a relay must not + * re-stamp the fact it is carrying. + */ + async setPreviewEnabled( + id: string, + state: { + /** True = this machine's panes may be watched. */ + on: boolean; + /** ISO 8601 of the write that produced `on` — never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + previewEnabled: state.on ? 1 : 0, + previewEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * Write the SSH wide-forwards allowance (spec 2026-10-15 §6). No stamp * column: unlike {@link setSshEnabled} there is no node-side mirror to diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 2c8b0d4fe..42fd4ad5a 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -107,6 +107,20 @@ export interface NodeTable { * maintenance. */ sshEnabledAt: string | null; + /** + * 1 = pane preview may be watched on this machine (pane-preview spec + * Task 3). Opt-in per node, never a fleet default: 0 for every row that + * predates the column, so an upgrade leaves preview off everywhere until + * the owner flips it — the flag exposes terminal content. + */ + previewEnabled: number; + /** + * ISO 8601 of the write that last changed {@link previewEnabled} (the + * wire's `changedAt`), or null when the flag was never written. The stamp + * rides the flag on every push, the shape {@link sshEnabledAt} has for + * SSH. + */ + previewEnabledAt: string | null; /** * 1 = this machine's owner allows SSH port forwards to bind NON-loopback * addresses (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index ff35112b9..3d73c9657 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -57,6 +57,7 @@ import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; +import { reconcilePreviewEnabled } from "@/services/nodes/preview-enabled.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; import { reconcileSshEnabled } from "@/services/nodes/ssh-enabled.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; @@ -328,6 +329,9 @@ async function bootServer(): Promise { // The SSH mirror's sibling (spec 2026-10-07 §4.3): same door, opposite // direction — the row is the record, so this reconcile only pushes. onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), + // The preview switch's sibling (pane-preview spec Task 3): same door, same + // direction rule — the row is the record, so this reconcile only pushes. + onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 13264c310..7fb4953ee 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0061-node-system-specs"; +export const LATEST_BACKUP_MIGRATION = "0062-node-preview-enabled"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts index 2e901edef..361b46e9b 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts @@ -256,6 +256,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onSubshellsReport: () => {}, onMaintenance: () => {}, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -282,6 +283,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onExit: () => {}, onMaintenance: () => {}, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index fa685e227..a0613fa71 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -763,6 +763,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => seen.push([nodeId, reported]); }, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -820,6 +821,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => seen.push([nodeId, reported]); }, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -902,6 +904,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onSshEnabled: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onPreviewEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -964,6 +967,96 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The preview mirror (pane-preview spec Task 3) walks the same two frames + * into the same shape of hook as SSH — one reconciler for connect-time state + * and a mid-session report — and the rows below pin the DOOR: both carriers, + * the socket's nodeId, the lenient field. What the hook does with what it + * hears ("never adopt") lives in `preview-enabled.test.ts`. The switch in + * `node-ws-handler.ts` has no default branch, so an event arm that is simply + * missing passes every other suite — these rows are the only thing the + * `preview_enabled` arm and the ready-path read answer to. + */ + describe("preview_enabled → the lifecycle hook (pane-preview spec Task 3)", () => { + /** Install a recording preview hook and return both the log and the uninstaller. */ + function recordPreviewEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: () => {}, + onPreviewEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING previewEnabled reconciles it", async () => { + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ previewEnabled: { on: true, changedAt: "2026-10-21T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-10-21T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles — silence is the fail-closed answer, not a skipped beat", async () => { + // A node with no mirror file reads REFUSED, which is precisely when the + // plane's row (say, ON from a flip during the outage) must push. This + // `undefined` report IS the fail-closed beat connect-time mirror repair + // lives or dies on — the same load-bearing cell the ssh quartet pins. + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the preview_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_enabled", on: false, changedAt: "2026-10-21T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-10-21T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose previewEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient exactly like `maintenance` and `sshEnabled`: the plane learns + // the drop as silence, and silence reconciles toward the row. + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ previewEnabled: { on: "yes" } })), + ); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` diff --git a/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts new file mode 100644 index 000000000..9f981516e --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts @@ -0,0 +1,315 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + decidePreviewEnabled, + getPreviewEnabled, + pushSetPreviewEnabled, + reconcilePreviewEnabled, + setPreviewEnabled, +} from "@/services/nodes/preview-enabled.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; + +/** + * The preview switch's plane half (pane-preview spec Task 3): the decide table + * — pure, no DB, no socket (the ssh-enabled suite's pattern) — and the hook + * body with one real row and one scripted node (its seam: no RPC mocks, the + * push travels the real `sendCommand` chain and lands in the scripted wire, + * which is what proves the `set_preview_enabled` frame parses against the + * shipped parser). + * + * The property this suite exists to pin is the DIRECTION, cloned verbatim from + * the SSH twin: the plane is the sole writer, so a reconcile NEVER adopts. + * Every disagreement ends with the row's value pushed down and the row itself + * unmoved — that push IS the corruption repair. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `preview-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("preview-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket — every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `preview-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.preview_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +/** + * Narrow a stamp read back from the row to the `string` the wire shape wants. + * A null here IS a failure of the test's own subject, so it throws loudly + * rather than asserting `!` over a value that could be null (or absent). + */ +function stamped(value: string | null | undefined): string { + if (typeof value !== "string") throw new Error(`the row carries no preview stamp: ${String(value)}`); + return value; +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decidePreviewEnabled — the plane is authoritative, so stamps are never read", () => { + const row = (previewEnabled: 0 | 1, previewEnabledAt: string | null) => ({ previewEnabled, previewEnabledAt }); + + it("agreement is a noop — reported matches the row, either value", () => { + expect( + decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), { on: false, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight — there is no node-side writer to bargain with. + expect( + decidePreviewEnabled(row(1, "2026-10-21T12:00:00.000Z"), { on: true, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { + // An absent mirror reads REFUSED on the machine, so a never-written or + // off row and a silent node already answer alike; the row saying ON faces + // a machine that refuses, and the row wins — that push is the repair. + expect(decidePreviewEnabled(row(0, null), undefined)).toBe("noop"); + expect(decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), undefined)).toBe("push-plane"); + }); + + it("disagreement pushes the row, in both directions — and never adopts, not even a NEWER report", () => { + // With no node-side writer, even a future-dated report is just bytes the + // row overwrites. This is the direction rule the task exists to pin. + expect( + decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-plane"); + expect( + decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), { on: false, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("push-plane"); + }); + + it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { + // Row never written + a node mirror saying ON (a data dir carried across + // from another plane, say): the row IS off, every plane-side gate reads + // the row, so the disagreement is inert — and a push would have to mint a + // `changedAt` for a write that never happened. + expect(decidePreviewEnabled(row(0, null), { on: true, changedAt: "2026-10-21T09:00:00.000Z" })).toBe("noop"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setPreviewEnabled — write, audit, push, one function", () => { + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + try { + await setPreviewEnabled(nodeId, ownerId, true); + const row = await nodes.findById(nodeId); + expect(row?.previewEnabled).toBe(1); + expect(row?.previewEnabledAt).not.toBeNull(); + await settle(); + // Exactly one audit row — the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: true }); + // The push is the row's OWN bytes — the wire frame the shipped parser + // accepted, the stored stamp included, not a re-stamped copy. + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { type: "set_preview_enabled", on: true, changedAt: stamped(row?.previewEnabledAt) }, + ]); + } finally { + scripted.detach(); + } + }); + + it("an OFF flip lands too — the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + try { + await setPreviewEnabled(nodeId, ownerId, false); + const row = await nodes.findById(nodeId); + expect(row?.previewEnabled).toBe(0); + expect(row?.previewEnabledAt).not.toBeNull(); + await settle(); + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { type: "set_preview_enabled", on: false, changedAt: stamped(row?.previewEnabledAt) }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node — the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect(setPreviewEnabled(nodeId, ownerId, true)).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.previewEnabled).toBe(1); + }); +}); + +/* ------------------ the fail-closed read -------------------------- */ + +describe("getPreviewEnabled — the row is the record, read fail-CLOSED", () => { + it("a row that does not exist reads OFF", async () => { + await expect(getPreviewEnabled("no-such-node")).resolves.toBe(false); + }); + + it("a fresh row reads OFF until someone flips it", async () => { + const nodeId = await mkNode(); + await expect(getPreviewEnabled(nodeId)).resolves.toBe(false); + await setPreviewEnabled(nodeId, ownerId, true); + await expect(getPreviewEnabled(nodeId)).resolves.toBe(true); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcilePreviewEnabled — decide, push the row, write nothing", () => { + it("agreement pushes NOTHING — the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcilePreviewEnabled(nodeId, { on: true, changedAt }); + await settle(); + expect(scripted.countOf("set_preview_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + try { + // The machine reports REFUSED (its mirror corrupted, say) under an ON + // row: the answer is the row pushed down — never the report pulled up. + await reconcilePreviewEnabled(nodeId, { on: false, changedAt: "2026-10-21T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_preview_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ type: "set_preview_enabled", on: true, changedAt }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.previewEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row ON + node silent → push; and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's value is + // re-armed by the push (the decide table's same cell). + await reconcilePreviewEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { type: "set_preview_enabled", on: true, changedAt }, + { type: "set_preview_enabled", on: true, changedAt }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a future-dated report does not outrank anything — there is nothing to outrank", async () => { + // No adopt means nothing to clamp: an adopted stamp is the failure mode + // clamps guard against, and preview never adopts one to begin with. + const nodeId = await mkNode(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: "2026-10-21T10:00:00.000Z" }); + await reconcilePreviewEnabled(nodeId, { on: true, changedAt: "2099-01-01T00:00:00.000Z" }); + await settle(); + const row = await nodes.findById(nodeId); + expect(row?.previewEnabled).toBe(0); + expect(row?.previewEnabledAt).toBe("2026-10-21T10:00:00.000Z"); + expect(await auditFor(nodeId)).toHaveLength(0); // reconcile records nothing, ever + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect( + reconcilePreviewEnabled("not-a-node", { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetPreviewEnabled is a NO-OP for local — resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE — its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetPreviewEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setPreviewEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await setPreviewEnabled(LOCAL_NODE_ID, ownerId, true); + expect((await nodes.findById(LOCAL_NODE_ID))?.previewEnabled).toBe(1); + // Restore whatever the shared test DB had, so no later suite inherits a + // server account with preview open (the switch ships OFF). The restore + // goes through the service, so this test leaves two + // node.preview_enabled.update audit rows on LOCAL_NODE_ID in THIS file's + // DB - harmless by design: each test file gets its own database, and + // audit-counting tests filter by targetId. + await setPreviewEnabled(LOCAL_NODE_ID, ownerId, false); + expect((await nodes.findById(LOCAL_NODE_ID))?.previewEnabled).toBe(0); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index ad7b86b21..fe965819a 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -176,6 +176,8 @@ function nodeRow(over: Partial = {}): NodeTable { sshEnabled: 0, sshWideForwards: 0, sshEnabledAt: null, + previewEnabled: 0, + previewEnabledAt: null, // The 0049 mirror slot: NULL until the runtime-report task fills it. sshFingerprint: null, createdAt: "2026-09-01T00:00:00.000Z", diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index 45aae4d33..01f4b7d81 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -1,4 +1,9 @@ -import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; +import type { + NodeEvent, + NodeMaintenanceWire, + NodePreviewEnabledWire, + NodeSshEnabledWire, +} from "@internal/subshell-protocol"; /** * The backend event plane for `/ws/node` (spec 2026-08-31 §3.3, phase 2). @@ -112,6 +117,22 @@ export interface NodeLifecycleHooks { * field the lenient parser dropped — all of which mean REFUSED there) */ onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; + /** + * The machine's own copy of its preview switch (pane-preview spec Task 3) — + * from `ready.previewEnabled` at connect, or from the `preview_enabled` + * event when the mirror moved underneath the connection (in practice: + * corruption). + * + * The same hook-not-repository shape as {@link onSshEnabled}, the same one + * reconciler for both frames, and the same DIRECTION rule: the plane is + * this flag's only writer, so reconciliation never adopts, it pushes. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none (no file, a file that says off, or a malformed `ready` + * field the lenient parser dropped — all of which mean REFUSED there) + */ + onPreviewEnabled(nodeId: string, reported: NodePreviewEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 9dcab8fea..7b8923c5d 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -1098,6 +1098,12 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // cheap; everything slow lives in the push's own best-effort swallow. if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); + // The preview mirror travels the same door (pane-preview spec Task 3) + // with the same decision behind it: the row is the record and never + // adopts, so the hook's whole cost is one read and at most one + // fire-and-forget push of the row's own value. + if (hooks) await hooks.onPreviewEnabled(nodeId, event.previewEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile preview_enabled`); // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity // record has no signing key is ASKED ONCE, and the answer files itself. // Fire-and-forget for the same reason the detect kick above is: the @@ -1180,6 +1186,21 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: ssh_enabled from ${nodeId} with no lifecycle hook`); return; } + case "preview_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's preview mirror moved mid-session. With no node-side + // writer there is no keyboard flip to carry — what arrives here is a + // file that changed by other means (corruption, chiefly: it flips the + // node to REFUSED silently, and this frame is how the plane learns to + // push the repair now rather than at the next reconnect). Same hook as + // `ready`: a mid-session report and a connect-time state are the same + // disagreement, and one reconciler keeps them answering alike. The + // switch has no default arm, so an event type added here without one + // lands dead — this arm is the whole dispatch for this frame. + if (hooks) await hooks.onPreviewEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: preview_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to diff --git a/apps/server/api/src/services/nodes/preview-enabled.ts b/apps/server/api/src/services/nodes/preview-enabled.ts new file mode 100644 index 000000000..819a0d2c0 --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-enabled.ts @@ -0,0 +1,152 @@ +import type { NodePreviewEnabledWire } from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The per-node preview switch (pane-preview spec Task 3), written in exactly + * one place — the posture `ssh-enabled.ts` established, cloned because the + * two flags share more than a name: both gate something that exposes content, + * both are opt-in per machine, and both mirror a file the plane alone writes. + * + * **The plane is the SOLE writer** — same fact, same consequences: there is + * no `subshell preview-enabled` verb, no second stamp to race, and + * reconciliation never compares dates, never adopts, and answers every + * disagreement the same way: push the row. The differences from the SSH twin + * are what does NOT happen here: no relay broker to close (preview's lanes + * land in later tasks) and no wide-forwards cousin. + */ + +/** + * Read the switch the way every gate reads it: the row is the record, read + * fail-CLOSED. A row that does not exist, a column that was never flipped, + * and a stamp-less default all answer OFF — watching panes is opt-in, so an + * unknown node is a refused node, never an error the caller must handle. + */ +export async function getPreviewEnabled(nodeId: string): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + return row?.previewEnabled === 1; +} + +/** Flip one node's preview switch: row, audit, and the best-effort push — + * all in this one function, so no caller can perform half the act. The + * stamp is minted HERE (one change-site mints once); the route supplies the + * acting human, never a time. */ +export async function setPreviewEnabled(nodeId: string, actorUserId: string, on: boolean): Promise { + const changedAt = new Date().toISOString(); + const { repos } = getRequestlessContext(); + await repos.nodes.setPreviewEnabled(nodeId, { on, changedAt }); + await audit({ + actorUserId, + action: "node.preview_enabled.update", + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify({ on }), + }); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to — + // and a push that fails (the node is offline) costs nothing: the value + // re-lands at the node's next `ready` through {@link reconcilePreviewEnabled}. + pushSetPreviewEnabledBestEffort(nodeId, { on, changedAt }); +} + +/** The node-row fields {@link decidePreviewEnabled} reads — nothing else is relevant. */ +type PreviewRow = Pick; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type PreviewDecision = "push-plane" | "noop"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE — it reads two values and answers; the caller performs. Cloned from + * `decideSshEnabled` verbatim in the one rule that matters: the plane is + * authoritative, so stamps are never read and NOTHING adopts. + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | off / never written | `noop` — silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane` — the node has no file (or a lost one); give it ours | + * | the row's value | — | `noop` — agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane` — the row is the record; the machine is told, never asked | + * | the other value | never written | `noop` — nothing to send (no stamp exists), and the disagreement is INERT: every plane-side gate reads the row, so a stale mirror-on permits nothing the row permits | + * + * @param row - the plane's node row (the two preview fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported nothing (no file, a file that says off, or a malformed + * `ready` field the lenient parser dropped) + */ +export function decidePreviewEnabled(row: PreviewRow, reported: NodePreviewEnabledWire | undefined): PreviewDecision { + const rowOn = row.previewEnabled === 1; + // Silence reads as OFF — the node's own fail-closed default — which is the + // only reading under which "node lost its file while the row says off" is + // the quiet agreement it is. + if ((reported?.on ?? false) === rowOn) return "noop"; + // The one disagreement with nothing to send (row never written, node mirror + // on). The row IS off and the plane gates on the row, so an unstamped push + // would only mint a write time for a write that never happened. + if (!row.previewEnabledAt) return "noop"; + return "push-plane"; +} + +/** + * Send one node the preview state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no + * mirror file — its answer to the gate is the DB row itself. + * + * @param nodeId - the node to tell; `local` is a no-op + * @param state - the STORED value, relayed verbatim; never re-stamped here, + * or the relay would outrank the decision it is carrying + */ +export async function pushSetPreviewEnabled(nodeId: string, state: NodePreviewEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_preview_enabled", on: state.on, changedAt: state.changedAt }); +} + +/** + * {@link pushSetPreviewEnabled}, fire-and-forget with the failure logged. + * + * An offline node simply misses it: the flag is already on the plane's row, + * which is what refuses every preview act plane-side, and the node re-learns + * the value on its next `ready` through {@link reconcilePreviewEnabled}. + */ +export function pushSetPreviewEnabledBestEffort(nodeId: string, state: NodePreviewEnabledWire): void { + void pushSetPreviewEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`preview-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row — the body of the + * `onPreviewEnabled` lifecycle hook, fed by `ready.previewEnabled` and by the + * `preview_enabled` event a machine sends when its mirror moved underneath it. + * + * The whole hook is one read and at most one fire-and-forget push: nothing to + * stop (a preview flip closes no panes here — the lanes that could arrive in + * later tasks own their own cuts), nothing to write, nothing to await. A row + * that disagrees with the machine pushes; never the other direction — that + * rule is the point. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none + */ +export async function reconcilePreviewEnabled( + nodeId: string, + reported: NodePreviewEnabledWire | undefined, +): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const stamp = row.previewEnabledAt; + if (decidePreviewEnabled(row, reported) !== "push-plane" || stamp === null) return; + // (`decidePreviewEnabled` only answers "push-plane" when a stamp exists; the + // null check is what shows the type what the decide already proved.) + pushSetPreviewEnabledBestEffort(nodeId, { on: row.previewEnabled === 1, changedAt: stamp }); +} diff --git a/packages/subshell-protocol/src/api-key-permissions.ts b/packages/subshell-protocol/src/api-key-permissions.ts index 165d675e8..0638d1690 100644 --- a/packages/subshell-protocol/src/api-key-permissions.ts +++ b/packages/subshell-protocol/src/api-key-permissions.ts @@ -37,6 +37,11 @@ export const API_KEY_PERMISSION_GROUPS = [ label: "Change SSH access", description: "Enable or disable Subshell SSH and use of the machine's keys.", }, + { + id: "nodes:preview", + label: "Preview panes", + description: "Turn pane preview on or off on a node.", + }, { id: "nodes:ssh-wide-forwards", label: "Allow network-accessible forwards", From b4143691e7c6b85943824bcfc85a9c0ba502c55c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 20:14:31 -0700 Subject: [PATCH 031/139] test: pin the machine-bearer 403 on the preview switch; reword added comments The route suite's bearer story stopped at the scoped-key cell, which is refused by authGuard's grant check before nodeManagementGuard's actor rule can answer; dropping the guard's mount kept every test green. Clone the maintenance precedent's live-token cell and pin the guard's own refusal. Reword the em-dashes the task-3 commits added (91 lines across 17 files, the voice ruling; inherited lines untouched). --- .../agent/src/__tests__/preview-flags.test.ts | 10 +-- apps/node/agent/src/commands/context.ts | 2 +- apps/node/agent/src/commands/report.ts | 12 ++-- .../src/commands/set-preview-enabled-exec.ts | 4 +- apps/node/agent/src/daemon.ts | 2 +- apps/node/agent/src/preview/flags.ts | 28 ++++---- .../set-node-preview-enabled.route.test.ts | 64 ++++++++++++++++--- .../nodes/set-node-preview-enabled.route.ts | 6 +- .../0062-node-preview-enabled.test.ts | 2 +- .../src/db/repositories/nodes.repository.ts | 4 +- .../server/api/src/db/types/nodes.db-types.ts | 2 +- apps/server/api/src/index.ts | 2 +- .../nodes/__tests__/node-ws-handler.test.ts | 10 +-- .../nodes/__tests__/preview-enabled.test.ts | 42 ++++++------ .../api/src/services/nodes/node-events.ts | 4 +- .../api/src/services/nodes/node-ws-handler.ts | 4 +- .../api/src/services/nodes/preview-enabled.ts | 34 +++++----- 17 files changed, 139 insertions(+), 93 deletions(-) diff --git a/apps/node/agent/src/__tests__/preview-flags.test.ts b/apps/node/agent/src/__tests__/preview-flags.test.ts index 2091bdcd9..05c4685c1 100644 --- a/apps/node/agent/src/__tests__/preview-flags.test.ts +++ b/apps/node/agent/src/__tests__/preview-flags.test.ts @@ -31,7 +31,7 @@ import { captureLogs } from "./helpers/capture-logs.js"; * twin in mechanics and in default. Persistence and the file-mode doctrine * clone `ssh-enabled.ts` exactly (0600, temp+rename, its own file beside it); * so does the FAIL-CLOSED read with the allowing default inverted the same way - * — the plane is the only writer, and "never told" is not "allowed" for a gate + * because the plane is the only writer, and "never told" is not "allowed" for a gate * that stands over watching panes. * * The wiring half goes through `dispatchCommand`, so the frame parser and the @@ -102,7 +102,7 @@ describe("persistence", () => { expect(readPreviewEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); }); - it("stores the whole wire verbatim — `ready.previewEnabled` is buildable from what was written", () => { + it("stores the whole wire verbatim: `ready.previewEnabled` is buildable from what was written", () => { const dataDir = freshDataDir(); setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); expect(JSON.parse(readFileSync(previewEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); @@ -131,7 +131,7 @@ describe("persistence", () => { it("leaves no temp file behind, and the file it leaves is whole", () => { // Atomic because a half-written file parses as corrupt, and corrupt means - // REFUSED here — a torn write would cut preview for the duration. + // REFUSED here, and a torn write would cut preview for the duration. const dataDir = freshDataDir(); setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); setPreviewEnabled(dataDir, { on: false, changedAt: "2026-10-21T12:00:00.000Z" }); @@ -225,7 +225,7 @@ describe("what a read is worth announcing", () => { }); describe("set_preview_enabled (the plane's half)", () => { - it("persists the plane's exact bytes and memoizes them — no echo, no re-stamp", async () => { + it("persists the plane's exact bytes and memoizes them, with no echo and no re-stamp", async () => { const dataDir = freshDataDir(); const events: NodeEvent[] = []; const ctx = makeCtx(dataDir, events); @@ -283,7 +283,7 @@ describe("the heartbeat belt", () => { expect(readPreviewEnabled(dataDir).kind).toBe("unreadable"); }); - it("a mirror deleted under a live connection is NOT restored — the plane is the only writer", () => { + it("a mirror deleted under a live connection is NOT restored: the plane is the only writer", () => { // Maintenance re-writes a vanished mirror from its memo because a CLI at // the keyboard can delete it legitimately. There is no such writer here: // hand-deleting this file is not an interface, and the next `ready` makes diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 79f5797fc..1f2cff692 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -194,7 +194,7 @@ export interface CommandContext { */ lastReportedSshEnabled?: NodeSshEnabledWire; /** - * The last preview-switch value this connection spoke about — the same echo + * The last preview-switch value this connection spoke about, the same echo * memo as {@link lastReportedSshEnabled}, cloned with the same reasoning: * the plane is the mirror's ONLY writer, so the belt reports a mirror that * moved by accident (corruption, mostly) and never the plane's own push diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index f2f710fa0..ef7f28efc 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -218,7 +218,7 @@ export function seedSshEnabledMemo(ctx: CommandContext): NodeSshEnabledWire | un * Announce one preview-switch mirror state to the plane and remember having * done so (pane-preview spec Task 3). The SSH twin's rule, cloned: the memo * update is what keeps the next heartbeat tick silent about a value the plane - * already holds — and what keeps a `set_preview_enabled` the plane just + * already holds, and what keeps a `set_preview_enabled` the plane just * pushed from coming back as an event. * * @param ctx - the daemon's command context (owns the socket seam and the memo) @@ -231,14 +231,14 @@ export function reportPreviewEnabled(ctx: CommandContext, state: NodePreviewEnab /** * Report the preview mirror IF it has moved since this connection last spoke - * about it — the heartbeat belt, the SSH twin's posture in full. + * about it: the heartbeat belt, the SSH twin's posture in full. * * With no node-side writer, the common steady state says nothing: an * off-or-absent mirror has nothing to announce (the plane's own row is * authoritative, and silence reads as refusal on both ends), and an on mirror * only speaks up once. What this loop exists to catch is the file changing - * UNDER the connection — corruption flipping a machine from allowed to - * refused mid-session, or a hand-edit — so the plane learns it now and pushes + * UNDER the connection (corruption flipping a machine from allowed to + * refused mid-session, or a hand-edit), so the plane learns it now and pushes * its row (the repair) rather than discovering the disagreement at the next * reconnect. There is no absent-restore half: the plane is the only writer, * so a deleted mirror is silence, and the next `ready` reconciles it. @@ -256,8 +256,8 @@ export function maybeReportPreviewEnabled(ctx: CommandContext): void { /** * Seed this connection's preview memo from the mirror on disk. * - * Called once per connect, immediately before `ready` — which carries the same - * value, so the first heartbeat must not repeat it — and again on every + * Called once per connect, immediately before `ready` (which carries the same + * value, so the first heartbeat must not repeat it), and again on every * reconnect, because a new socket has told the plane nothing. The one read of * the file builds both, so the frame and the memo cannot disagree. * diff --git a/apps/node/agent/src/commands/set-preview-enabled-exec.ts b/apps/node/agent/src/commands/set-preview-enabled-exec.ts index d4ee45fa3..bcb567b83 100644 --- a/apps/node/agent/src/commands/set-preview-enabled-exec.ts +++ b/apps/node/agent/src/commands/set-preview-enabled-exec.ts @@ -4,7 +4,7 @@ import type { Cmd, CommandContext, CommandResult } from "./context.js"; /** * `set_preview_enabled` (pane-preview spec Task 3): the plane's half of the * preview switch, reached only through `dispatchCommand`'s already-verified - * frame — an unsigned or misauthenticated command never gets this far. + * frame: an unsigned or misauthenticated command never gets this far. * * It writes the file and NOTHING else. In particular it opens no lane and * revokes nothing: the gate is checked when a preview act is asked for, so @@ -25,7 +25,7 @@ import type { Cmd, CommandContext, CommandResult } from "./context.js"; */ export function execSetPreviewEnabled(ctx: CommandContext, cmd: Cmd<"set_preview_enabled">): CommandResult { // Memoized as REPORTED, not merely written: the plane is the source of this - // value — the ONLY source it will ever have — so sending it back on the next + // value, the ONLY source it will ever have, so sending it back on the next // heartbeat tick would be an echo the plane then reconciles against itself. ctx.lastReportedPreviewEnabled = setPreviewEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); return { ok: true }; diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 950541b85..a7783e21c 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -1335,7 +1335,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // for it to ride ahead of, because an SSH flip kills no panes. maybeReportSshEnabled(ctx); // The preview switch's belt (pane-preview spec Task 3): the same - // quiet shape, the same single mid-session path — a preview flip + // quiet shape, the same single mid-session path: a preview flip // closes no panes here, so this tick is the only way the plane // learns the mirror moved before the next reconnect. maybeReportPreviewEnabled(ctx); diff --git a/apps/node/agent/src/preview/flags.ts b/apps/node/agent/src/preview/flags.ts index ee00c0985..25bf5b411 100644 --- a/apps/node/agent/src/preview/flags.ts +++ b/apps/node/agent/src/preview/flags.ts @@ -9,18 +9,18 @@ import { logger } from "../log.js"; * * It is the SSH capability mirror's twin in mechanics and in default. Same * file-permission doctrine (0600, temp+rename, its own file beside - * `ssh-enabled.json` and for the same two reasons — `config.json` is + * `ssh-enabled.json` and for the same two reasons: `config.json` is * snapshotted at boot and it is the node key's only home). Same fail-CLOSED * read: what ENABLING lets Subshell do is stream pane content to anyone the * plane's sharing admits, so a setting this process cannot read is not a - * permission. Absent refuses too — watching panes has no "before" to keep + * permission. Absent refuses too: watching panes has no "before" to keep * behaving like. * * **The plane is the sole writer.** There is no `subshell preview-enabled` * verb, because turning a machine's pane-watching on from a keyboard at that * machine is exactly the decision this gate exists to make someone make in a - * browser, as the owner, with an audit row. So the mirror has only one author - * — a `set_preview_enabled` push — and its readers are the preview gates + * browser, as the owner, with an audit row. The mirror therefore has exactly + * one author, a `set_preview_enabled` push. Its readers are the preview gates * (this process, fail-closed) and the `ready` report plus heartbeat belt that * let the plane notice when the two copies disagree. * @@ -38,7 +38,7 @@ export function previewEnabledPath(dataDir: string): string { } /** - * What the mirror says, as the three answers a caller must tell apart — the + * What the mirror says, as the three answers a caller must tell apart: the * gate's answers, not the file's biography. Same shape as the SSH mirror's * `SshEnabledRead`, cloned for the same reasons. * @@ -47,7 +47,7 @@ export function previewEnabledPath(dataDir: string): string { * parses to `{ on: false, changedAt }` and a file that does not exist give the * SAME answer to every reader (refuse; and the plane's row already says off, * so there is nothing to report), and no node-side writer exists whose stamp - * anyone would later reconcile — so the off file reads as `absent` while its + * anyone would later reconcile, so the off file reads as `absent` while its * bytes stay on disk. `unreadable` refuses too, but unlike the other two it * says so: the answer it produces may match a quiet row, and when the row says * on it is a disagreement the plane can only repair if it hears about it. @@ -58,20 +58,20 @@ export type PreviewEnabledRead = /** The mirror parsed and says yes: panes here may be watched. */ | { kind: "on"; - /** The stored stamp, verbatim — `ready.previewEnabled` is built from it. */ + /** The stored stamp, verbatim: `ready.previewEnabled` is built from it. */ changedAt: string; } /** The file exists but is not a state: REFUSED (fail-closed), reported under the file's mtime. */ | { kind: "unreadable"; - /** The file's mtime, ISO — absent only when the stat failed too, leaving nothing truthful to send. */ + /** The file's mtime, ISO. Absent only when the stat failed too, leaving nothing truthful to send. */ changedAt?: string; }; /** * Reads the mirror. * - * Total — every failure answers `unreadable` (one warn line) rather than + * Total: every failure answers `unreadable` (one warn line) rather than * throwing, because every caller is on a path where a throw would cost more * than the wrong answer: the preview gates, a heartbeat tick, and the `ready` * builder. @@ -102,7 +102,7 @@ export function readPreviewEnabled(dataDir: string): PreviewEnabledRead { } /** - * A file's mtime as an ISO stamp. TOTAL — every caller is on a path where the + * A file's mtime as an ISO stamp. TOTAL: every caller is on a path where the * answer is optional and a throw would cost the whole read. */ function mtimeOf(file: string): string | undefined { @@ -120,7 +120,7 @@ function mtimeOf(file: string): string | undefined { * Every reporting path goes through this so the answers cannot be classified * differently in three places: `ready`, the heartbeat memo check and the * preview gates all owe the plane the same value for the same file. An - * unreadable mirror announces the REFUSAL it actually produces — the SSH + * unreadable mirror announces the REFUSAL it actually produces, the SSH * twin's rule, held for the same reason. * * @param read - what {@link readPreviewEnabled} answered @@ -136,7 +136,7 @@ export function reportablePreviewEnabled(read: PreviewEnabledRead): NodePreviewE /** * The value this machine would hand `ready`, and the shape every preview gate - * reads: a permit is `?.on === true` and NOTHING else — absent, unreadable, + * reads: a permit is `?.on === true` and NOTHING else. Absent, unreadable, * and a malformed file all answer a refusal (or nothing), never a permit. The * fail-closed rule, spelled once at the read. */ @@ -148,12 +148,12 @@ export function getPreviewEnabled(dataDir: string): NodePreviewEnabledWire | und * Persists the mirror, 0600, via temp + rename. * * Atomic for the SSH reason and sharper: a gate may read this at any instant, - * and a half-written file parses as corrupt — which under the fail-closed rule + * and a half-written file parses as corrupt, which under the fail-closed rule * REFUSES every preview act for as long as the write takes. * * The `state` is stored VERBATIM. The plane sent it; re-stamping here would * make the two copies differ by exactly the link delay, and the next `ready` - * would report a disagreement this process invented — which the plane would + * would report a disagreement this process invented, which the plane would * "repair" by pushing the same value again, forever. * * @returns the state as stored diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts index d051658d9..8bf63363e 100644 --- a/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts @@ -4,13 +4,15 @@ import { nodesRoutes } from "@/api/nodes/index.js"; import { systemKeysRoutes } from "@/api/system-keys.route.js"; import { db } from "@/db/index.js"; import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; /** - * `PUT /api/nodes/:id/preview-enabled` (pane-preview spec Task 3) — the + * `PUT /api/nodes/:id/preview-enabled` (pane-preview spec Task 3): the * per-node preview switch, opt-in and off by default, cloned from the SSH * capability route it sits beside. * @@ -19,7 +21,7 @@ import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/ * its log, but deciding whether this machine's pane content may be watched is * not theirs. * - * The route performs no write and records no audit itself — `setPreviewEnabled` + * The route performs no write and records no audit itself. `setPreviewEnabled` * is the one change-site (row + audit + push), so this file asserts the ROUTE * wired the acting human into that row. */ @@ -37,6 +39,8 @@ describe("node preview-enabled route", () => { let adminCookie: string; const NODE = `n-pv-${crypto.randomUUID()}`; const createdKeyIds: string[] = []; + const subshells = new SubshellsRepository(db); + const KEYED = `s-pv-key-${crypto.randomUUID()}`; async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { return await new UsersRepository(db).createUser({ @@ -58,11 +62,14 @@ describe("node preview-enabled route", () => { return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); } - function put(nodeId: string, cookie: string, on: boolean) { + function put(nodeId: string, cookie: string | undefined, on: boolean, bearer?: string) { + const headers: Record = { "content-type": "application/json" }; + if (cookie) headers.cookie = `better-auth.session_token=${cookie}`; + if (bearer) headers.authorization = `Bearer ${bearer}`; return nodesRoutes.fetch( new Request(`http://localhost:3080/api/nodes/${nodeId}/preview-enabled`, { method: "PUT", - headers: { "content-type": "application/json", cookie: `better-auth.session_token=${cookie}` }, + headers, body: JSON.stringify({ on }), }), ); @@ -100,6 +107,7 @@ describe("node preview-enabled route", () => { }); afterAll(async () => { + await db.deleteFrom("subshells").where("id", "=", KEYED).execute(); await db.deleteFrom("nodes").where("id", "=", NODE).execute(); // Leave the shared `local` row exactly as found: every file in this // package gets its own database, but a flipped host switch still reads as @@ -140,7 +148,7 @@ describe("node preview-enabled route", () => { }); it("names the acting human and carries ONLY {on} in the audit row", async () => { - // The ROUTE is the only place `actorUserId` is wired in — the service + // The ROUTE is the only place `actorUserId` is wired in: the service // takes it as an argument, so a regression to a null actor passes every // test of the service alone. await put(NODE, ownerCookie, false); @@ -164,14 +172,14 @@ describe("node preview-enabled route", () => { expect(((await res.json()) as { previewEnabled: boolean }).previewEnabled).toBe(false); }); - it("an edit grantee is refused — it is not their machine to widen", async () => { + it("an edit grantee is refused: it is not their machine to widen", async () => { // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read // logs; deciding whether someone may watch this machine's panes is a // different act. expect((await put(NODE, editorCookie, true)).status).toBe(403); }); - it("a system key scoped to SSH cannot flip preview — the grants are separate", async () => { + it("a system key scoped to SSH cannot flip preview: the grants are separate", async () => { // The catalog split this task had to make: reusing `nodes:ssh` would let // every ssh-scoped key flip preview. A key minted with the SSH grant and // without `nodes:preview` is refused at the permission layer, before any @@ -204,7 +212,45 @@ describe("node preview-enabled route", () => { expect((await previewAudit()).size).toBe(auditsBefore); }); - it("a stranger gets 404, never 403 — an invisible node stays invisible", async () => { + it("refuses a valid bearer credential outright", async () => { + // Machine credentials never manage a machine. The key is a LIVE one + // belonging to the node's own owner, so this 403 is `nodeManagementGuard` + // classifying the actor, not authentication failing, which a bogus string + // would not have distinguished. It is also the only cell here that pins + // the guard's MOUNT: a scoped key is refused one layer earlier, by + // authGuard's grant check, so dropping `.use(nodeManagementGuard)` from + // the route would let authGuard resolve a pane's token as its OWNER, flip + // `canManage` on the owner's own node, and leave every other test in this + // file green. + // + // Minted inside the test rather than in `beforeAll`, the maintenance + // precedent's discipline: terminating a subshell revokes its token, so + // this test owns its key end to end. + await subshells.create({ + id: KEYED, + userId: ownerId, + presetId: "p", + harnessId: "claude-code", + name: KEYED, + workingDir: "/tmp", + tmuxSocket: null, + }); + const key = await issueSubshellToken(KEYED, ownerId); + const auditsBefore = (await previewAudit()).size; + + const res = await put(NODE, undefined, true, key); + expect(res.status).toBe(403); + // The refusal names the guard, not the preview grant: a pane token + // carries no node permission map at all, so it never reaches the + // `nodes:preview` check the scoped-key cell above pins. (The guard + // answers with a plain-text HttpError, the same shape the scoped-key + // refusal has; the route's JSON error never ran.) + expect(await res.text()).toContain("cannot manage nodes"); + // The refusal is real: the row did not move. + expect((await previewAudit()).size).toBe(auditsBefore); + }); + + it("a stranger gets 404, never 403: an invisible node stays invisible", async () => { expect((await put(NODE, otherCookie, true)).status).toBe(404); }); @@ -217,7 +263,7 @@ describe("node preview-enabled route", () => { // the real owner, plus admins on the seeded host alone. expect((await put(NODE, adminCookie, true)).status).toBe(403); // A member sees `local` through the seeded Everyone/edit grant but holds - // no management there — refused, not invisible (a node on the page must + // no management there: refused, not invisible (a node on the page must // not answer "not found"). expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); // Like SSH and unlike maintenance, this flip stops nothing, so ON is safe diff --git a/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts index a8c1ebbf5..acb79d098 100644 --- a/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts +++ b/apps/server/api/src/api/nodes/set-node-preview-enabled.route.ts @@ -18,7 +18,7 @@ const PreviewEnabledBodySchema = t.Object({ }); /** - * `PUT /api/nodes/:id/preview-enabled` `{on}` — open or close pane preview on + * `PUT /api/nodes/:id/preview-enabled` `{on}`: open or close pane preview on * one machine (pane-preview spec Task 3). Browser session or an API key * holding the `nodes:preview` grant. * @@ -26,12 +26,12 @@ const PreviewEnabledBodySchema = t.Object({ * the same gate as SSH and maintenance: a grantee may restart the agent or * read its log, but deciding whether this machine's pane content may be * watched is not theirs. The permission is its OWN catalog id, not - * `nodes:ssh` — a key scoped to SSH must not be able to flip preview; the + * `nodes:ssh`. A key scoped to SSH must not be able to flip preview; the * grants answer different questions. * * The route writes nothing and audits nothing itself: `setPreviewEnabled` is * the one change-site (row + audit + push), so no caller can perform half the - * act — and the route is the only place the acting human is wired in. + * act. The route is the only place the acting human is wired in. * * It answers on an OFFLINE node rather than refusing: the flag is the plane's * record, and the machine learns the value at its next `ready` reconcile. diff --git a/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts index d55ffd366..172816c4e 100644 --- a/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts +++ b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts @@ -17,7 +17,7 @@ interface MigrationDatabase { * The per-node preview switch columns (pane-preview spec Task 3). Default 0 is * the whole upgrade story, cloned from 0047's doctrine: a node enrolled before * this migration (and the seeded local row) reads OFF, because watching panes - * is opt-in per machine — preview exposes terminal content, so an upgrade must + * is opt-in per machine: preview exposes terminal content, so an upgrade must * never turn it on anywhere. */ describe("migration 0062-node-preview-enabled", () => { diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index a8c658b43..5819c747d 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -305,7 +305,7 @@ export class NodesRepository extends BaseRepository { * Write the preview switch and its stamp (pane-preview spec Task 3). * * Same shape as {@link setSshEnabled}: both fields move together, and the - * CALLER supplies `changedAt` — the stamp is the reconciliation protocol + * CALLER supplies `changedAt`: the stamp is the reconciliation protocol * between the plane's row and the node's mirror file, so a relay must not * re-stamp the fact it is carrying. */ @@ -314,7 +314,7 @@ export class NodesRepository extends BaseRepository { state: { /** True = this machine's panes may be watched. */ on: boolean; - /** ISO 8601 of the write that produced `on` — never re-stamped on relay. */ + /** ISO 8601 of the write that produced `on`. Never re-stamped on relay. */ changedAt: string; }, ): Promise { diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 42fd4ad5a..9eeaffb3e 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -111,7 +111,7 @@ export interface NodeTable { * 1 = pane preview may be watched on this machine (pane-preview spec * Task 3). Opt-in per node, never a fleet default: 0 for every row that * predates the column, so an upgrade leaves preview off everywhere until - * the owner flips it — the flag exposes terminal content. + * the owner flips it: the flag exposes terminal content. */ previewEnabled: number; /** diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 3d73c9657..5165e65eb 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -330,7 +330,7 @@ async function bootServer(): Promise { // direction — the row is the record, so this reconcile only pushes. onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), // The preview switch's sibling (pane-preview spec Task 3): same door, same - // direction rule — the row is the record, so this reconcile only pushes. + // direction rule: the row is the record, so this reconcile only pushes. onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index a0613fa71..4c0bff0d0 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -969,12 +969,12 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => /** * The preview mirror (pane-preview spec Task 3) walks the same two frames - * into the same shape of hook as SSH — one reconciler for connect-time state - * and a mid-session report — and the rows below pin the DOOR: both carriers, + * into the same shape of hook as SSH: one reconciler for connect-time state + * and a mid-session report, and the rows below pin the DOOR: both carriers, * the socket's nodeId, the lenient field. What the hook does with what it * hears ("never adopt") lives in `preview-enabled.test.ts`. The switch in * `node-ws-handler.ts` has no default branch, so an event arm that is simply - * missing passes every other suite — these rows are the only thing the + * missing passes every other suite, and these rows are the only thing the * `preview_enabled` arm and the ready-path read answer to. */ describe("preview_enabled → the lifecycle hook (pane-preview spec Task 3)", () => { @@ -1008,11 +1008,11 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => } }); - it("a ready WITHOUT it still reconciles — silence is the fail-closed answer, not a skipped beat", async () => { + it("a ready WITHOUT it still reconciles: silence is the fail-closed answer, not a skipped beat", async () => { // A node with no mirror file reads REFUSED, which is precisely when the // plane's row (say, ON from a flip during the outage) must push. This // `undefined` report IS the fail-closed beat connect-time mirror repair - // lives or dies on — the same load-bearing cell the ssh quartet pins. + // lives or dies on, the same load-bearing cell the ssh quartet pins. const { seen, off } = recordPreviewEnabled(); try { const h = makeHarness(); diff --git a/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts index 9f981516e..76e721f5c 100644 --- a/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts @@ -17,7 +17,7 @@ import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; /** * The preview switch's plane half (pane-preview spec Task 3): the decide table - * — pure, no DB, no socket (the ssh-enabled suite's pattern) — and the hook + * (pure, no DB, no socket, the ssh-enabled suite's pattern) and the hook * body with one real row and one scripted node (its seam: no RPC mocks, the * push travels the real `sendCommand` chain and lands in the scripted wire, * which is what proves the `set_preview_enabled` frame parses against the @@ -26,7 +26,7 @@ import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; * The property this suite exists to pin is the DIRECTION, cloned verbatim from * the SSH twin: the plane is the sole writer, so a reconcile NEVER adopts. * Every disagreement ends with the row's value pushed down and the row itself - * unmoved — that push IS the corruption repair. + * unmoved: that push IS the corruption repair. */ const nodes = new NodesRepository(db); @@ -45,7 +45,7 @@ async function mkUser(): Promise { }); } -/** An enrolled agent node with no live socket — every command on it fails offline. */ +/** An enrolled agent node with no live socket. Every command on it fails offline. */ async function mkNode(): Promise { const id = crypto.randomUUID(); nodeIds.push(id); @@ -91,10 +91,10 @@ afterAll(async () => { /* ------------------ the pure table -------------------------- */ -describe("decidePreviewEnabled — the plane is authoritative, so stamps are never read", () => { +describe("decidePreviewEnabled: the plane is authoritative, so stamps are never read", () => { const row = (previewEnabled: 0 | 1, previewEnabledAt: string | null) => ({ previewEnabled, previewEnabledAt }); - it("agreement is a noop — reported matches the row, either value", () => { + it("agreement is a noop: reported matches the row, either value", () => { expect( decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), ).toBe("noop"); @@ -102,7 +102,7 @@ describe("decidePreviewEnabled — the plane is authoritative, so stamps are nev decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), { on: false, changedAt: "2026-10-21T09:00:00.000Z" }), ).toBe("noop"); // Differing stamps under an agreeing value are a relayed fact, not a - // fight — there is no node-side writer to bargain with. + // fight: there is no node-side writer to bargain with. expect( decidePreviewEnabled(row(1, "2026-10-21T12:00:00.000Z"), { on: true, changedAt: "2026-10-21T09:00:00.000Z" }), ).toBe("noop"); @@ -111,13 +111,13 @@ describe("decidePreviewEnabled — the plane is authoritative, so stamps are nev it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { // An absent mirror reads REFUSED on the machine, so a never-written or // off row and a silent node already answer alike; the row saying ON faces - // a machine that refuses, and the row wins — that push is the repair. + // a machine that refuses, and the row wins: that push is the repair. expect(decidePreviewEnabled(row(0, null), undefined)).toBe("noop"); expect(decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), undefined)).toBe("noop"); expect(decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), undefined)).toBe("push-plane"); }); - it("disagreement pushes the row, in both directions — and never adopts, not even a NEWER report", () => { + it("disagreement pushes the row, in both directions, and never adopts: not even a NEWER report", () => { // With no node-side writer, even a future-dated report is just bytes the // row overwrites. This is the direction rule the task exists to pin. expect( @@ -131,7 +131,7 @@ describe("decidePreviewEnabled — the plane is authoritative, so stamps are nev it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { // Row never written + a node mirror saying ON (a data dir carried across // from another plane, say): the row IS off, every plane-side gate reads - // the row, so the disagreement is inert — and a push would have to mint a + // the row, so the disagreement is inert, and a push would have to mint a // `changedAt` for a write that never happened. expect(decidePreviewEnabled(row(0, null), { on: true, changedAt: "2026-10-21T09:00:00.000Z" })).toBe("noop"); }); @@ -139,7 +139,7 @@ describe("decidePreviewEnabled — the plane is authoritative, so stamps are nev /* ------------------ the act, on a real row -------------------------- */ -describe("setPreviewEnabled — write, audit, push, one function", () => { +describe("setPreviewEnabled: write, audit, push, one function", () => { it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { const nodeId = await mkNode(); const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); @@ -149,13 +149,13 @@ describe("setPreviewEnabled — write, audit, push, one function", () => { expect(row?.previewEnabled).toBe(1); expect(row?.previewEnabledAt).not.toBeNull(); await settle(); - // Exactly one audit row — the push adds nothing, and a second writer of + // Exactly one audit row: the push adds nothing, and a second writer of // this act is precisely what the one-function shape exists to prevent. const rows = await auditFor(nodeId); expect(rows).toHaveLength(1); expect(rows[0].actorUserId).toBe(ownerId); expect(rows[0].meta).toEqual({ on: true }); - // The push is the row's OWN bytes — the wire frame the shipped parser + // The push is the row's OWN bytes: the wire frame the shipped parser // accepted, the stored stamp included, not a re-stamped copy. expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ { type: "set_preview_enabled", on: true, changedAt: stamped(row?.previewEnabledAt) }, @@ -165,7 +165,7 @@ describe("setPreviewEnabled — write, audit, push, one function", () => { } }); - it("an OFF flip lands too — the mirror is written as a value, not deleted", async () => { + it("an OFF flip lands too: the mirror is written as a value, not deleted", async () => { const nodeId = await mkNode(); const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); try { @@ -182,7 +182,7 @@ describe("setPreviewEnabled — write, audit, push, one function", () => { } }); - it("survives an offline node — the push is best-effort; the row already refuses", async () => { + it("survives an offline node: the push is best-effort; the row already refuses", async () => { const nodeId = await mkNode(); await expect(setPreviewEnabled(nodeId, ownerId, true)).resolves.toBeUndefined(); expect((await nodes.findById(nodeId))?.previewEnabled).toBe(1); @@ -191,7 +191,7 @@ describe("setPreviewEnabled — write, audit, push, one function", () => { /* ------------------ the fail-closed read -------------------------- */ -describe("getPreviewEnabled — the row is the record, read fail-CLOSED", () => { +describe("getPreviewEnabled: the row is the record, read fail-CLOSED", () => { it("a row that does not exist reads OFF", async () => { await expect(getPreviewEnabled("no-such-node")).resolves.toBe(false); }); @@ -206,8 +206,8 @@ describe("getPreviewEnabled — the row is the record, read fail-CLOSED", () => /* ------------------ the hook body -------------------------- */ -describe("reconcilePreviewEnabled — decide, push the row, write nothing", () => { - it("agreement pushes NOTHING — the steady state must not echo a command per beat", async () => { +describe("reconcilePreviewEnabled: decide, push the row, write nothing", () => { + it("agreement pushes NOTHING: the steady state must not echo a command per beat", async () => { const nodeId = await mkNode(); const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); await setPreviewEnabled(nodeId, ownerId, true); @@ -231,7 +231,7 @@ describe("reconcilePreviewEnabled — decide, push the row, write nothing", () = await settle(); try { // The machine reports REFUSED (its mirror corrupted, say) under an ON - // row: the answer is the row pushed down — never the report pulled up. + // row: the answer is the row pushed down, never the report pulled up. await reconcilePreviewEnabled(nodeId, { on: false, changedAt: "2026-10-21T13:00:00.000Z" }); await settle(); const pushes = scripted.cmdsOf("set_preview_enabled"); @@ -265,7 +265,7 @@ describe("reconcilePreviewEnabled — decide, push the row, write nothing", () = } }); - it("a future-dated report does not outrank anything — there is nothing to outrank", async () => { + it("a future-dated report does not outrank anything: there is nothing to outrank", async () => { // No adopt means nothing to clamp: an adopted stamp is the failure mode // clamps guard against, and preview never adopts one to begin with. const nodeId = await mkNode(); @@ -288,10 +288,10 @@ describe("reconcilePreviewEnabled — decide, push the row, write nothing", () = /* ------------------ `local` -------------------------- */ describe("`local` has no mirror to push to", () => { - it("pushSetPreviewEnabled is a NO-OP for local — resolves without a socket, where an agent would fail offline", async () => { + it("pushSetPreviewEnabled is a NO-OP for local: resolves without a socket, where an agent would fail offline", async () => { // The proof is the contrast: `sendCommand` to a node with no live socket // rejects (the offline case above swallows exactly that). Local must - // RESOLVE — its answer to the gate is the DB row, and there is no second + // RESOLVE: its answer to the gate is the DB row, and there is no second // copy to tell. await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); await expect( diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index 01f4b7d81..7756ad438 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -118,7 +118,7 @@ export interface NodeLifecycleHooks { */ onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; /** - * The machine's own copy of its preview switch (pane-preview spec Task 3) — + * The machine's own copy of its preview switch (pane-preview spec Task 3): * from `ready.previewEnabled` at connect, or from the `preview_enabled` * event when the mirror moved underneath the connection (in practice: * corruption). @@ -130,7 +130,7 @@ export interface NodeLifecycleHooks { * @param nodeId - the SOCKET's authenticated identity, never a frame's claim * @param reported - the machine's mirror answer, or undefined when it * reported none (no file, a file that says off, or a malformed `ready` - * field the lenient parser dropped — all of which mean REFUSED there) + * field the lenient parser dropped, all of which mean REFUSED there) */ onPreviewEnabled(nodeId: string, reported: NodePreviewEnabledWire | undefined): Promise | void; } diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 7b8923c5d..197dfbfa2 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -1189,14 +1189,14 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: case "preview_enabled": { const hooks = getNodeLifecycleHooks(); // The machine's preview mirror moved mid-session. With no node-side - // writer there is no keyboard flip to carry — what arrives here is a + // writer there is no keyboard flip to carry: what arrives here is a // file that changed by other means (corruption, chiefly: it flips the // node to REFUSED silently, and this frame is how the plane learns to // push the repair now rather than at the next reconnect). Same hook as // `ready`: a mid-session report and a connect-time state are the same // disagreement, and one reconciler keeps them answering alike. The // switch has no default arm, so an event type added here without one - // lands dead — this arm is the whole dispatch for this frame. + // lands dead. This arm is the whole dispatch for this frame. if (hooks) await hooks.onPreviewEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); else logger.warn(`node ws: preview_enabled from ${nodeId} with no lifecycle hook`); return; diff --git a/apps/server/api/src/services/nodes/preview-enabled.ts b/apps/server/api/src/services/nodes/preview-enabled.ts index 819a0d2c0..8c1d126bc 100644 --- a/apps/server/api/src/services/nodes/preview-enabled.ts +++ b/apps/server/api/src/services/nodes/preview-enabled.ts @@ -7,11 +7,11 @@ import { logger } from "@/utils/logger.js"; /** * The per-node preview switch (pane-preview spec Task 3), written in exactly - * one place — the posture `ssh-enabled.ts` established, cloned because the + * one place: the posture `ssh-enabled.ts` established, cloned because the * two flags share more than a name: both gate something that exposes content, * both are opt-in per machine, and both mirror a file the plane alone writes. * - * **The plane is the SOLE writer** — same fact, same consequences: there is + * **The plane is the SOLE writer.** Same fact, same consequences: there is * no `subshell preview-enabled` verb, no second stamp to race, and * reconciliation never compares dates, never adopts, and answers every * disagreement the same way: push the row. The differences from the SSH twin @@ -22,7 +22,7 @@ import { logger } from "@/utils/logger.js"; /** * Read the switch the way every gate reads it: the row is the record, read * fail-CLOSED. A row that does not exist, a column that was never flipped, - * and a stamp-less default all answer OFF — watching panes is opt-in, so an + * and a stamp-less default all answer OFF. Watching panes is opt-in, so an * unknown node is a refused node, never an error the caller must handle. */ export async function getPreviewEnabled(nodeId: string): Promise { @@ -31,7 +31,7 @@ export async function getPreviewEnabled(nodeId: string): Promise { return row?.previewEnabled === 1; } -/** Flip one node's preview switch: row, audit, and the best-effort push — +/** Flip one node's preview switch: row, audit, and the best-effort push, * all in this one function, so no caller can perform half the act. The * stamp is minted HERE (one change-site mints once); the route supplies the * acting human, never a time. */ @@ -47,13 +47,13 @@ export async function setPreviewEnabled(nodeId: string, actorUserId: string, on: metadataJson: JSON.stringify({ on }), }); // The row is written and audited BEFORE the push, so a node that answers the - // command cannot be looking at a mirror the record has not caught up to — + // command cannot be looking at a mirror the record has not caught up to, // and a push that fails (the node is offline) costs nothing: the value // re-lands at the node's next `ready` through {@link reconcilePreviewEnabled}. pushSetPreviewEnabledBestEffort(nodeId, { on, changedAt }); } -/** The node-row fields {@link decidePreviewEnabled} reads — nothing else is relevant. */ +/** The node-row fields {@link decidePreviewEnabled} reads. Nothing else is relevant. */ type PreviewRow = Pick; /** What reconciliation decided about one node's two copies of the flag. */ @@ -61,17 +61,17 @@ export type PreviewDecision = "push-plane" | "noop"; /** * Decide what to do when the plane's row and the node's report disagree. - * PURE — it reads two values and answers; the caller performs. Cloned from + * PURE: it reads two values and answers; the caller performs. Cloned from * `decideSshEnabled` verbatim in the one rule that matters: the plane is * authoritative, so stamps are never read and NOTHING adopts. * * | node reports | plane row | → | * |---|---|---| - * | nothing | off / never written | `noop` — silence means refusal HERE, and the row already refuses | - * | nothing | on | `push-plane` — the node has no file (or a lost one); give it ours | - * | the row's value | — | `noop` — agreement, the steady state; a push here would echo forever | - * | the other value | with a stamp | `push-plane` — the row is the record; the machine is told, never asked | - * | the other value | never written | `noop` — nothing to send (no stamp exists), and the disagreement is INERT: every plane-side gate reads the row, so a stale mirror-on permits nothing the row permits | + * | nothing | off / never written | `noop`: silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane`: the node has no file (or a lost one); give it ours | + * | the row's value | any value | `noop`: agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane`: the row is the record; the machine is told, never asked | + * | the other value | never written | `noop`: nothing to send (no stamp exists), and the disagreement is INERT: every plane-side gate reads the row, so a stale mirror-on permits nothing the row permits | * * @param row - the plane's node row (the two preview fields only) * @param reported - what the machine says its mirror answers, or undefined @@ -80,7 +80,7 @@ export type PreviewDecision = "push-plane" | "noop"; */ export function decidePreviewEnabled(row: PreviewRow, reported: NodePreviewEnabledWire | undefined): PreviewDecision { const rowOn = row.previewEnabled === 1; - // Silence reads as OFF — the node's own fail-closed default — which is the + // Silence reads as OFF, the node's own fail-closed default, which is the // only reading under which "node lost its file while the row says off" is // the quiet agreement it is. if ((reported?.on ?? false) === rowOn) return "noop"; @@ -96,7 +96,7 @@ export function decidePreviewEnabled(row: PreviewRow, reported: NodePreviewEnabl * byte-identical. * * `local` is skipped: the control-plane host has no agent socket and no - * mirror file — its answer to the gate is the DB row itself. + * mirror file: its answer to the gate is the DB row itself. * * @param nodeId - the node to tell; `local` is a no-op * @param state - the STORED value, relayed verbatim; never re-stamped here, @@ -123,14 +123,14 @@ export function pushSetPreviewEnabledBestEffort(nodeId: string, state: NodePrevi } /** - * Reconcile one node's report against the plane's row — the body of the + * Reconcile one node's report against the plane's row. Body of the * `onPreviewEnabled` lifecycle hook, fed by `ready.previewEnabled` and by the * `preview_enabled` event a machine sends when its mirror moved underneath it. * * The whole hook is one read and at most one fire-and-forget push: nothing to - * stop (a preview flip closes no panes here — the lanes that could arrive in + * stop (a preview flip closes no panes here; the lanes that could arrive in * later tasks own their own cuts), nothing to write, nothing to await. A row - * that disagrees with the machine pushes; never the other direction — that + * that disagrees with the machine pushes; never the other direction. That * rule is the point. * * @param nodeId - the SOCKET's authenticated identity, never a frame's claim From 41425f4a98b687944836855d41f8a515fea1803c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 20:29:41 -0700 Subject: [PATCH 032/139] feat(node): attribute pane subtrees' listening sockets, probing CDP per spec --- .../agent/src/__tests__/preview-ports.test.ts | 264 ++++++++++++++++++ apps/node/agent/src/commands/index.ts | 3 + .../agent/src/commands/preview-ports-exec.ts | 62 ++++ apps/node/agent/src/preview/ports.ts | 237 ++++++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 apps/node/agent/src/__tests__/preview-ports.test.ts create mode 100644 apps/node/agent/src/commands/preview-ports-exec.ts create mode 100644 apps/node/agent/src/preview/ports.ts diff --git a/apps/node/agent/src/__tests__/preview-ports.test.ts b/apps/node/agent/src/__tests__/preview-ports.test.ts new file mode 100644 index 000000000..6742ea372 --- /dev/null +++ b/apps/node/agent/src/__tests__/preview-ports.test.ts @@ -0,0 +1,264 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseNodePreviewPortsResult } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execPreviewPorts, PREVIEW_NODE_OFF, type PreviewPortsSeams } from "../commands/preview-ports-exec.js"; +import { setPreviewEnabled } from "../preview/flags.js"; +import type { ScanPanePortsOptions } from "../preview/ports.js"; +import { scanPanePorts } from "../preview/ports.js"; + +/** + * Pane port discovery (pane-preview spec §4/§5, Task 4): the scanner walks a + * fixture `/proc` in a temp dir, and the executor arms answer through the + * dispatch switch. Posture copied from commands-ssh-relay.test.ts: real temp + * dirs, the preview mirror written by its own writer, refusals asserted by + * `ok:false` + exact message. + * + * THREE fixture roots, and one shared root cannot serve all five scan tests: + * `tinyProcRoot` holds exactly ONE attributed LISTEN (test 1's exact-list + * assertion needs a root nothing else is in); `procRoot` is the full fixture + * (the 3000 row, an attributed 9222 for the CDP probe, an attributed tcp6 + * 4321, and outsider 8080 owned by a pid OUTSIDE the pane tree); + * `emptyProcRoot` has no listeners. An attributed 9222 or 4321 in a shared + * root would break test 1's `toEqual`, and a root without them makes tests 2 + * and 5 unanswerable. + * + * The scanner tests are Linux-gated (spec §12: "fixture /proc trees + * (inject the reader, Linux-gated test)") — the scanner itself refuses any + * other platform before touching fs, so on macOS the walk cannot be + * exercised at all. The executor tests run everywhere: every path they take + * stops before the scan (gate, dead pane) or injects the scan seam. + */ + +const STAMP = "2026-10-21T10:00:00.000Z"; +const PANE = "11111111-2222-4333-8444-555555555555"; +const PREVIEW_OFF_REFUSAL = "preview-node-off: turn on Preview for this node"; +const PANE_GONE_REFUSAL = "preview-ports-pane-gone"; + +let base: string; +let procRoot: string; +let tinyProcRoot: string; +let emptyProcRoot: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-preview-ports-"))); + // procRoot: the full fixture. Tree 100 → 101 → 102; outsider 999 hangs off + // pid 500, outside the pane tree. 102 owns the LISTENs the tests expect; + // 999 owns 8080, which must never appear. + procRoot = join(base, "proc"); + mkdirSync(join(procRoot, "net"), { recursive: true }); + mkPidTree(procRoot, [ + { pid: 100, ppid: 1 }, + { pid: 101, ppid: 100 }, + { + pid: 102, + ppid: 101, + fds: [ + { name: "3", inode: 300_001 }, + { name: "5", inode: 300_002 }, + { name: "6", inode: 300_004 }, + ], + }, + { pid: 999, ppid: 500, fds: [{ name: "3", inode: 300_003 }] }, + ]); + writeNet(join(procRoot, "net", "tcp"), [ + { addr: "0100007F", port: 3000, inode: 300_001 }, + { addr: "0100007F", port: 9222, inode: 300_002 }, + { addr: "00000000", port: 8080, inode: 300_003 }, + ]); + writeNet(join(procRoot, "net", "tcp6"), [{ addr: "00000000000000000000000001000000", port: 4321, inode: 300_004 }]); + + // tinyProcRoot: exactly ONE attributed LISTEN, and TWO tree members own the + // same socket inode (the 101 link is the inherited fd): the answer must be + // the deepest descendant, 102. + tinyProcRoot = join(base, "tiny-proc"); + mkdirSync(join(tinyProcRoot, "net"), { recursive: true }); + mkPidTree(tinyProcRoot, [ + { pid: 100, ppid: 1 }, + { pid: 101, ppid: 100, fds: [{ name: "3", inode: 5001 }] }, + { pid: 102, ppid: 101, fds: [{ name: "4", inode: 5001 }] }, + ]); + writeNet(join(tinyProcRoot, "net", "tcp"), [{ addr: "0100007F", port: 3000, inode: 5001 }]); + writeNet(join(tinyProcRoot, "net", "tcp6"), []); + + // emptyProcRoot: a live tree that simply listens on nothing. + emptyProcRoot = join(base, "empty-proc"); + mkdirSync(join(emptyProcRoot, "net"), { recursive: true }); + mkPidTree(emptyProcRoot, [{ pid: 100, ppid: 1 }]); + writeNet(join(emptyProcRoot, "net", "tcp"), []); + writeNet(join(emptyProcRoot, "net", "tcp6"), []); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +/** Write one `/` dir: `stat` (ppid at its real column), `cmdline`, and the fd symlinks. */ +function mkPidTree(root: string, pids: { pid: number; ppid: number; fds?: { name: string; inode: number }[] }[]): void { + for (const p of pids) { + const dir = join(root, String(p.pid)); + mkdirSync(dir, { recursive: true }); + // The comm field carries a space on purpose: a parser that splits the + // whole line on whitespace reads the wrong column as ppid on every real + // host whose process names contain spaces or parens. + writeFileSync( + `${dir}/stat`, + `${p.pid} (proc ${p.pid}) S ${p.ppid} ${p.pid} ${p.pid} 0 0 0 0 20 0 0 0 0 0 0 0 0 0 0 0 0 0\n`, + ); + writeFileSync(`${dir}/cmdline`, `proc-${p.pid}\0--flag\0`); + if (p.fds) { + mkdirSync(`${dir}/fd`); + for (const fd of p.fds) symlinkSync(`socket:[${fd.inode}]`, join(dir, "fd", fd.name)); + } + } +} + +/** + * Write a `/proc/net/tcp`-shaped file (the tcp6 twin differs only in the + * width of the address the caller passes): header plus LISTEN (state 0A) + * rows, the inode in its real column. + */ +function writeNet(file: string, rows: { addr: string; port: number; inode: number }[]): void { + const header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode"; + const lines = rows.map( + (r, i) => + ` ${i}: ${r.addr}:${r.port.toString(16).toUpperCase().padStart(4, "0")} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 ${r.inode} 1 0 1 0 -1`, + ); + writeFileSync(file, `${[header, ...lines].join("\n")}\n`); +} + +describe.skipIf(process.platform !== "linux")("scanPanePorts over fixture /proc trees", () => { + it("attributes a listening socket to the deepest descendant that owns it", async () => { + const r = await scanPanePorts({ procRoot: tinyProcRoot, panePid: 100, probe: async () => false }); + expect(r.ports).toEqual([{ proto: "tcp", port: 3000, pid: 102 }]); + }); + + it("cdpPort is the LISTEN port whose /json/version probe answers", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async (p) => p === 9222 }); + expect(r.cdpPort).toBe(9222); + }); + + it("a socket owned by an outsider never appears", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); + expect(r.ports.some((p) => p.port === 8080)).toBe(false); + }); + + it("no listeners answers with empty arrays, not an error", async () => { + const r = await scanPanePorts({ procRoot: emptyProcRoot, panePid: 100, probe: async () => false }); + expect(r.ports).toEqual([]); + expect(r.cdpPort).toBeNull(); + expect(r.screen).toBeNull(); + }); + + it("an IPv6 loopback listener parses", async () => { + const r = await scanPanePorts({ procRoot, panePid: 100, probe: async () => false }); + expect(r.ports).toContainEqual({ proto: "tcp6", port: 4321, pid: 102 }); + }); + + it("a process tree past 512 pids refuses preview-ports-overcap", async () => { + // A linear chain of 600 descendants: the walk must refuse rather than + // readlink its way through a fork bomb. + const capRoot = join(base, "cap-proc"); + mkdirSync(join(capRoot, "net"), { recursive: true }); + const pids: { pid: number; ppid: number }[] = [{ pid: 1000, ppid: 1 }]; + for (let pid = 1001; pid < 1600; pid++) pids.push({ pid, ppid: pid - 1 }); + mkPidTree(capRoot, pids); + writeNet(join(capRoot, "net", "tcp"), []); + writeNet(join(capRoot, "net", "tcp6"), []); + await expect(scanPanePorts({ procRoot: capRoot, panePid: 1000, probe: async () => false })).rejects.toThrow( + "preview-ports-overcap", + ); + }); + + it("the screen handle rides through the scan untouched", async () => { + const r = await scanPanePorts({ + procRoot, + panePid: 100, + probe: async () => false, + screen: { display: ":97", rfbPort: 5900 }, + }); + expect(r.screen).toEqual({ display: ":97", rfbPort: 5900 }); + }); +}); + +it.skipIf(process.platform === "linux")( + "scanPanePorts refuses preview-ports-unsupported on non-Linux, before touching fs", + async () => { + // The procRoot names nothing: the refusal must arrive without a single + // fs read, so a bad-path root cannot tell the refusal apart from an + // ENOENT. + await expect( + scanPanePorts({ procRoot: join(base, "no-such-proc"), panePid: 1, probe: async () => false }), + ).rejects.toThrow("preview-ports-unsupported"); + }, +); + +describe("the preview_ports_get executor", () => { + function makeCtx(tag: string, opts: { previewOn?: boolean; panePid?: number | null } = {}) { + const dataDir = join(base, tag, "data"); + mkdirSync(dataDir, { recursive: true }); + if (opts.previewOn ?? false) setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + const ctx = { + config: { dataDir, nodeId: "node-self" }, + meta: { get: async () => undefined }, + tmux: { panePid: async () => (opts.panePid === undefined ? 100 : opts.panePid) }, + } as unknown as CommandContext; + return ctx; + } + + it("an off switch refuses preview-node-off naming the remedy, via the dispatch switch", async () => { + const ctx = makeCtx("gate-off"); + const res = await dispatchCommand(ctx, { type: "preview_ports_get", paneId: PANE }); + expect(res).toEqual({ ok: false, error: PREVIEW_OFF_REFUSAL }); + // The word AND the remedy, and the arm exists (an unwired switch answers + // `unsupported`, never this sentence). + expect(PREVIEW_NODE_OFF).toBe(PREVIEW_OFF_REFUSAL); + expect(PREVIEW_NODE_OFF).toContain("preview-node-off"); + expect(PREVIEW_NODE_OFF).toContain("turn on Preview for this node"); + }); + + it("a dead pane refuses preview-ports-pane-gone", async () => { + const ctx = makeCtx("dead-pane", { previewOn: true, panePid: null }); + const res = await dispatchCommand(ctx, { type: "preview_ports_get", paneId: PANE }); + expect(res).toEqual({ ok: false, error: PANE_GONE_REFUSAL }); + }); + + it("a malformed pane id refuses invalid subshell id before any tmux touch", async () => { + const ctx = makeCtx("bad-id", { previewOn: true, panePid: null }); + // panePid null would answer pane-gone: the id answer proves the gate ran + // first and tmux was never reached. + const res = await dispatchCommand(ctx, { type: "preview_ports_get", paneId: "nope/" }); + expect(res).toEqual({ ok: false, error: "invalid subshell id" }); + }); + + it("a successful answer is the scan output, screen null, and passes the Task 1 validator", async () => { + const ctx = makeCtx("success", { previewOn: true, panePid: 100 }); + const seen: ScanPanePortsOptions[] = []; + const seams: PreviewPortsSeams = { + scan: async (opts) => { + seen.push(opts); + return { + ports: [{ proto: "tcp", port: 3000, pid: 102 }], + cdpPort: 9222, + screen: opts.screen ?? null, + }; + }, + }; + const res = await execPreviewPorts(ctx, { type: "preview_ports_get", paneId: PANE }, seams); + expect(res.ok).toBe(true); + if (!res.ok) return; // type narrowing; unreachable + // The executor's answer is the scan's result plus the screen it passed + // in, and this task hardwires that screen to null (Task 7's registry + // amends the arm). + expect(seen.length).toBe(1); + expect(seen[0]?.panePid).toBe(100); + expect(seen[0]?.screen).toBeNull(); + // The shape is contract, not vibes: Task 1's validator must accept it. + const parsed = parseNodePreviewPortsResult(res.data); + expect(parsed).not.toBeNull(); + expect(parsed).toEqual({ ports: [{ proto: "tcp", port: 3000, pid: 102 }], cdpPort: 9222, screen: null }); + }); +}); diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 2de90d41f..94b13d9ab 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -22,6 +22,7 @@ import type { CommandContext, CommandResult } from "./context.js"; import { execFileRead } from "./file-read.js"; import { execFsLs } from "./fs-ls.js"; import { execLaunch } from "./launch.js"; +import { execPreviewPorts } from "./preview-ports-exec.js"; import { execPromptDeliver } from "./prompt.js"; import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; @@ -129,6 +130,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execPaneSize(ctx, cmd); case "pane_cursor": return await execPaneCursor(ctx, cmd); + case "preview_ports_get": + return await execPreviewPorts(ctx, cmd); case "prompt_deliver": return await execPromptDeliver(ctx, cmd); case "stat_dir": diff --git a/apps/node/agent/src/commands/preview-ports-exec.ts b/apps/node/agent/src/commands/preview-ports-exec.ts new file mode 100644 index 000000000..aee9eb07a --- /dev/null +++ b/apps/node/agent/src/commands/preview-ports-exec.ts @@ -0,0 +1,62 @@ +import { getPreviewEnabled } from "../preview/flags.js"; +import { type PanePortsScan, type ScanPanePortsOptions, scanPanePorts } from "../preview/ports.js"; +import { resolveSocket } from "./basics.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * The `preview_ports_get` arm (pane-preview spec §4, Task 4): the plane asks + * what a pane is serving, this machine answers in one stateless pass. The + * refusal WORDS are shared across the preview arms — {@link PREVIEW_NODE_OFF} + * is the voice every gated preview act answers with when this machine's + * switch is off, so "every entry point refuses with one voice" is a constant, + * not three strings that drift. + * + * Gate-first, like every SSH arm consults its mirror before mechanics: a + * machine switched off refuses the same whether the pane is alive, dead, or + * nonexistent, and no process table is walked for a refusal the switch + * already answers. The pane's liveness is the next gate — `panePid` is the + * tmux server's own word on where the pane's process starts, so a refused + * `preview-ports-pane-gone` means the server says the pane is gone (a tmux + * error reads the same and is answered the same: the plane re-checks). + * + * The `screen` half is HARDWIRED null for this task: the display registry is + * Task 7's, and until it exists this node allocates no displays. The arm + * passes `screen: null` to the scan and returns what the scan echoes, so + * Task 7 amends exactly one call site. `screenLost` (the validator's + * companion member) is likewise never set here — there is no display to have + * lost. + */ + +/** The one-word switch-off refusal every preview arm shares, with the operator's remedy named. */ +export const PREVIEW_NODE_OFF = "preview-node-off: turn on Preview for this node"; + +/** The pane the command names is not running here (anymore, or never). */ +export const PREVIEW_PORTS_PANE_GONE = "preview-ports-pane-gone"; + +/** Injectable scan seam (test-only; production is the real /proc walk), the `RelayOpenSeams` habit. */ +export interface PreviewPortsSeams { + scan?: (opts: ScanPanePortsOptions) => Promise; +} + +/** + * Execute `preview_ports_get`: switch → pane → scan, in that order. + * A malformed pane id refuses `invalid subshell id` through + * {@link resolveSocket}'s throw, before tmux is asked anything — the same + * id gate every pane executor runs first. + */ +export async function execPreviewPorts( + ctx: CommandContext, + cmd: Cmd<"preview_ports_get">, + seams?: PreviewPortsSeams, +): Promise { + // The mirror is asked, fail-CLOSED: a permit is `on === true` and nothing + // else (absent, off, and unreadable all refuse) — flags.ts states the rule + // once, this arm just applies it before any work. + if (getPreviewEnabled(ctx.config.dataDir)?.on !== true) return { ok: false, error: PREVIEW_NODE_OFF }; + const socket = await resolveSocket(ctx, cmd.paneId); + const panePid = await ctx.tmux.panePid(socket, cmd.paneId); + if (panePid === null) return { ok: false, error: PREVIEW_PORTS_PANE_GONE }; + const scan = seams?.scan ?? scanPanePorts; + const r = await scan({ panePid, screen: null }); + return { ok: true, data: { ports: r.ports, cdpPort: r.cdpPort, screen: r.screen } }; +} diff --git a/apps/node/agent/src/preview/ports.ts b/apps/node/agent/src/preview/ports.ts new file mode 100644 index 000000000..230c02858 --- /dev/null +++ b/apps/node/agent/src/preview/ports.ts @@ -0,0 +1,237 @@ +import { readdirSync, readFileSync, readlinkSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Pane port discovery (pane-preview spec §4/§5, Task 4): what the pane's + * process tree is listening on, found by ONE STATELESS PASS over `/proc` — + * no cache, no watcher, nothing to reconcile after the fact. The pass reads + * the kernel's two listener tables, the ppid edge of every process, and the + * socket inodes held open by each fd, and joins them: a LISTEN row is the + * pane's when the inode it names is open in a process under the pane's root, + * attributed to the DEEPEST such descendant (an inherited fd in the pane's + * shell must not steal the socket from the server that owns it now). + * + * `procRoot` and `probe` exist only for tests: the fixture `/proc` is a temp + * dir, and no test should open real sockets to fake a devtools answer. The + * real scan reads `/proc` on the box the pane runs on. + */ + +/** One listening socket of the pane's tree, as the wire row names it. */ +export type PreviewPortRow = { proto: "tcp" | "tcp6"; port: number; pid: number }; + +/** The display the node allocated for this pane, when it has one (Task 7 owns the registry; today every caller passes null). */ +export type PreviewScreenHandle = { display: string; rfbPort: number }; + +/** Everything `scanPanePorts` takes; `procRoot` and `probe` are test seams. */ +export interface ScanPanePortsOptions { + /** The proc filesystem root, default `/proc`. Test-only seam: fixture trees live in temp dirs. */ + procRoot?: string; + /** The pid tmux reports for the pane (`TmuxRunner.panePid`), the BFS root. */ + panePid: number; + /** "Is this port CDP?" default: a loopback `GET /json/version` with a 400 ms budget. Test-only seam. */ + probe?: (port: number) => Promise; + /** The pane's allocated display, passed through to the answer; null means this pane has none. */ + screen?: PreviewScreenHandle | null; +} + +/** The one-pass answer: rows, the CDP port if a listed one answered, and the passed-in screen. */ +export interface PanePortsScan { + ports: PreviewPortRow[]; + cdpPort: number | null; + screen: PreviewScreenHandle | null; +} + +/** Non-Linux has no `/proc` walk to run; the refusal is named, never a guess. */ +export const PREVIEW_PORTS_UNSUPPORTED = "preview-ports-unsupported"; + +/** The pane's tree outgrew the walk budget; refuse rather than readlink an unbounded fork bomb. */ +export const PREVIEW_PORTS_OVERCAP = "preview-ports-overcap"; + +/** The refusal's number: total pids (the pane root included) the BFS may visit. */ +const MAX_TREE_PIDS = 512; + +/** The CDP probe's budget, spec §4. */ +const CDP_PROBE_TIMEOUT_MS = 400; + +/** + * One pass over the proc filesystem: listeners, ppid map, fd inodes, CDP. + * + * Refuses `preview-ports-unsupported` on non-Linux BEFORE touching fs, and + * `preview-ports-overcap` when the tree exceeds {@link MAX_TREE_PIDS}. A + * vanished pid mid-walk is normal (processes die) and costs that pid's rows, + * never the scan. + */ +export async function scanPanePorts(opts: ScanPanePortsOptions): Promise { + if (process.platform !== "linux") throw new Error(PREVIEW_PORTS_UNSUPPORTED); + const procRoot = opts.procRoot ?? "/proc"; + const screen = opts.screen ?? null; + + const listeners = [ + ...readListenRows(join(procRoot, "net", "tcp"), "tcp"), + ...readListenRows(join(procRoot, "net", "tcp6"), "tcp6"), + ]; + const owners = socketOwners(procRoot, buildPpidMap(procRoot), opts.panePid); + + const ports: PreviewPortRow[] = []; + const seenInodes = new Set(); + for (const row of listeners) { + const owner = owners.get(row.inode); + if (owner === undefined || seenInodes.has(row.inode)) continue; + // A socket bound to two addresses (127.0.0.1 and 0.0.0.0) is two rows + // beside one inode: the wire lists one row per listening SOCKET. + seenInodes.add(row.inode); + ports.push({ proto: row.proto, port: row.port, pid: owner }); + } + + const probe = opts.probe ?? defaultCdpProbe; + let cdpPort: number | null = null; + for (const row of ports) { + if (await probe(row.port)) { + cdpPort = row.port; + break; + } + } + return { ports, cdpPort, screen }; +} + +/** + * The default CDP test (spec §4): does `127.0.0.1:` answer + * `GET /json/version` within 400 ms with JSON carrying a `Browser` field? + * Loopback only — the scan runs on the node's own machine, and a listener's + * devtools answers on loopback whatever address it was bound to. Every + * failure shape (refused, slow, non-JSON, not CDP) answers false. + */ +async function defaultCdpProbe(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(CDP_PROBE_TIMEOUT_MS), + }); + if (!res.ok) return false; + const body: unknown = await res.json(); + return typeof body === "object" && body !== null && "Browser" in body; + } catch { + return false; + } +} + +/** One LISTEN row of a net table. */ +type ListenRow = { proto: "tcp" | "tcp6"; port: number; inode: number }; + +/** + * Parse the state-0A (LISTEN) rows of a `net/tcp`/`net/tcp6` table. + * Columns: `sl`, `local_address` (`HEXADDR:HEXPORT`), `rem_address`, `st`, + * the tx/rx queue pair, the timer pair, `retrnsmt`, `uid`, `timeout`, + * `inode` — the join key the fd walk matches. The header row answers + * `st` where a hex state belongs and is skipped, as is any row whose port + * or inode does not parse (a kernel writing a shape we cannot read is + * answered by ignoring that row, not by refusing the scan). + */ +function readListenRows(file: string, proto: "tcp" | "tcp6"): ListenRow[] { + const rows: ListenRow[] = []; + let text: string; + try { + text = readFileSync(file, "utf8"); + } catch { + throw new Error(`preview-ports: cannot read ${file}`); + } + for (const line of text.split("\n").slice(1)) { + const fields = line.trim().split(/\s+/); + if (fields.length < 10 || fields[3] !== "0A") continue; + const [addr, portHex] = (fields[1] ?? "").split(":"); + const port = portHex === undefined ? Number.NaN : parseInt(portHex, 16); + const inode = Number(fields[9]); + if (!addr || !Number.isInteger(port) || port < 1 || !Number.isInteger(inode) || inode <= 0) continue; + rows.push({ proto, port, inode }); + } + return rows; +} + +/** + * pid → ppid for every numeric dir in the proc root. `/stat` field 2 + * (comm) may contain spaces and parens, so the ppid (field 4) is read from + * the tail after the LAST `)`, never by counting whitespace from column 0. + * A pid that vanishes mid-walk is simply not in the map. + */ +function buildPpidMap(procRoot: string): Map { + const map = new Map(); + let names: string[]; + try { + names = readdirSync(procRoot); + } catch { + throw new Error(`preview-ports: cannot read ${procRoot}`); + } + for (const name of names) { + if (!/^\d+$/.test(name)) continue; + const pid = Number(name); + let stat: string; + try { + stat = readFileSync(join(procRoot, name, "stat"), "utf8"); + } catch { + continue; + } + const close = stat.lastIndexOf(")"); + if (close < 0) continue; + const afterComm = stat + .slice(close + 1) + .trim() + .split(/\s+/); + const ppid = Number(afterComm[1]); // [0] is state, [1] is ppid + if (Number.isInteger(ppid) && ppid >= 0) map.set(pid, ppid); + } + return map; +} + +/** + * Inode → owning pid for every socket fd open in the pane's tree: BFS the + * descendants of `panePid` (itself included) over the ppid map, readlink + * each `fd/*`, keep the last match at the GREATEST depth. The BFS refuses + * `preview-ports-overcap` past {@link MAX_TREE_PIDS} pids — a pane's tree + * legitimately numbers in the dozens, and five hundred means a fork bomb + * this walk will not follow. + */ +function socketOwners(procRoot: string, ppid: Map, panePid: number): Map { + const children = new Map(); + for (const [pid, parent] of ppid) { + const list = children.get(parent); + if (list) list.push(pid); + else children.set(parent, [pid]); + } + const depth = new Map([[panePid, 0]]); + const queue = [panePid]; + for (let i = 0; i < queue.length; i++) { + const parent = queue[i] as number; + for (const child of children.get(parent) ?? []) { + if (depth.has(child)) continue; // reparented loop guard; BFS never revisits + if (depth.size + 1 > MAX_TREE_PIDS) throw new Error(PREVIEW_PORTS_OVERCAP); + depth.set(child, (depth.get(parent) ?? 0) + 1); + queue.push(child); + } + } + + const owners = new Map(); + const ownerDepth = new Map(); + for (const [pid, d] of depth) { + let fds: string[]; + try { + fds = readdirSync(join(procRoot, String(pid), "fd")); + } catch { + continue; // no fd table readable for a pid that just died + } + for (const fd of fds) { + let target: string; + try { + target = readlinkSync(join(procRoot, String(pid), "fd", fd)); + } catch { + continue; + } + const m = /^socket:\[(\d+)\]$/.exec(target); + if (!m) continue; + const inode = Number(m[1]); + if (d > (ownerDepth.get(inode) ?? -1)) { + owners.set(inode, pid); + ownerDepth.set(inode, d); + } + } + } + return owners; +} From 9767618f7a11f0110f6fa491eefb89f38454bfce Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 21:00:38 -0700 Subject: [PATCH 033/139] feat(node): pending-ack forward lanes with off-chain dial and 1 MiB slow-link pump --- .../src/__tests__/preview-forward.test.ts | 567 ++++++++++++++++++ apps/node/agent/src/commands/context.ts | 17 + apps/node/agent/src/commands/index.ts | 6 + .../agent/src/commands/preview-close-exec.ts | 23 + .../agent/src/commands/preview-open-exec.ts | 90 +++ apps/node/agent/src/daemon.ts | 45 +- apps/node/agent/src/preview/forward.ts | 390 ++++++++++++ apps/node/agent/src/relay-send.ts | 11 +- 8 files changed, 1145 insertions(+), 4 deletions(-) create mode 100644 apps/node/agent/src/__tests__/preview-forward.test.ts create mode 100644 apps/node/agent/src/commands/preview-close-exec.ts create mode 100644 apps/node/agent/src/commands/preview-open-exec.ts create mode 100644 apps/node/agent/src/preview/forward.ts diff --git a/apps/node/agent/src/__tests__/preview-forward.test.ts b/apps/node/agent/src/__tests__/preview-forward.test.ts new file mode 100644 index 000000000..bc3bae5fe --- /dev/null +++ b/apps/node/agent/src/__tests__/preview-forward.test.ts @@ -0,0 +1,567 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { type AddressInfo, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_SESSIONS_PER_NODE, + type PreviewCloseReason, + type PreviewOpenCommand, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, +} from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { execPreviewClose } from "../commands/preview-close-exec.js"; +import { + PREVIEW_LANE_OVER_NODE_CAP, + PREVIEW_LANE_OVER_PANE_CAP, + PREVIEW_PANE_GONE, +} from "../commands/preview-open-exec.js"; +import { PREVIEW_NODE_OFF } from "../commands/preview-ports-exec.js"; +import { setPreviewEnabled } from "../preview/flags.js"; +import { type LaneDeps, type LaneSocket, PreviewForward, type PreviewLaneEvent } from "../preview/forward.js"; + +/** + * The forward-lane executor and registry (pane-preview spec 2026-10-21 §4/§6, + * Task 5): the A-branch contract (register + ack synchronously, dial off the + * chain, close-not-gated, tombstoned answer) copied from + * commands-ssh-relay.test.ts, the byte pump copied from relay-send's + * deliver-or-throw. Posture identical to that precedent: real temp dirs, the + * preview mirror written by its own writer, refusals asserted by `ok:false` + + * exact message. + * + * Every mechanics test injects the `LaneDeps` seam: a fake `connect` hands the + * lane a FakeSocket it can drive (connect/data/end/error on demand), and a + * FakeClock captures the idle/lifetime timers so the budget cases fire them by + * hand instead of waiting out real 15-minute timers. Case (b) is the honest + * one: a real echo server on an ephemeral loopback port, dialed by real net. + */ + +const STAMP = "2026-10-21T10:00:00.000Z"; +const PANES = [ + "11111111-2222-4333-8444-555555555555", + "22222222-2222-4333-8444-555555555555", + "33333333-2222-4333-8444-555555555555", + "44444444-2222-4333-8444-555555555555", + "55555555-2222-4333-8444-555555555555", +] as const; +const PANE = PANES[0]; + +/** 32 lowercase hex, valid against PREVIEW_REF_RE, unique per sequence number. */ +const refFor = (n: number): string => n.toString(16).padStart(32, "0"); + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-preview-forward-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +/* ------------------------------------------------------------------ */ +/* harness: fake sockets, fake clock, recording context */ +/* ------------------------------------------------------------------ */ + +type Timer = { cb: () => void; ms: number; cancelled: boolean; fired: boolean }; + +class FakeClock { + timers: Timer[] = []; + + setTimeout(cb: () => void, ms: number): unknown { + const t: Timer = { cb, ms, cancelled: false, fired: false }; + this.timers.push(t); + return t; + } + + clearTimeout(handle: unknown): void { + if (typeof handle === "object" && handle !== null) (handle as Timer).cancelled = true; + } + + /** Run every live timer scheduled at exactly `ms`; returns how many fired. */ + fire(ms: number): number { + let n = 0; + for (const t of this.timers) { + if (!t.cancelled && !t.fired && t.ms === ms) { + t.fired = true; + n += 1; + t.cb(); + } + } + return n; + } + + liveAt(ms: number): number { + return this.timers.filter((t) => !t.cancelled && !t.fired && t.ms === ms).length; + } + + cancelledAt(ms: number): number { + return this.timers.filter((t) => t.cancelled && t.ms === ms).length; + } +} + +class FakeSocket { + private handlers = new Map void)[]>(); + writes: Buffer[] = []; + destroyed = false; + + on(event: string, listener: (arg?: unknown) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(listener); + this.handlers.set(event, list); + return this; + } + + fire(event: string, arg?: unknown): void { + for (const l of this.handlers.get(event) ?? []) l(arg); + } + + write(chunk: Uint8Array | string): boolean { + this.writes.push(Buffer.from(chunk)); + return true; + } + + destroy(): void { + this.destroyed = true; + } +} + +function makeHarness( + tag: string, + opts: { previewOn?: boolean; panePid?: number | null; deps?: LaneDeps } = {}, +): { + ctx: CommandContext; + forward: PreviewForward; + events: PreviewLaneEvent[]; + sockets: FakeSocket[]; + attempts: { host: string; port: number }[]; + clock: FakeClock; +} { + const dataDir = join(base, tag, "data"); + mkdirSync(dataDir, { recursive: true }); + if (opts.previewOn ?? true) setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + const events: PreviewLaneEvent[] = []; + const sockets: FakeSocket[] = []; + const attempts: { host: string; port: number }[] = []; + const clock = new FakeClock(); + // Held-connect dials by default: lanes register and stay pending until the + // test fires connect/error, so quota and close-all cases hold real lanes + // without real sockets. `{ connect: undefined }` falls back to the module's + // real net dial (case (b)). + const forward = new PreviewForward({ + connect: ({ host, port }) => { + const s = new FakeSocket(); + sockets.push(s); + attempts.push({ host, port }); + return s as unknown as LaneSocket; + }, + timer: clock, + idleMs: 900_000, + ...opts.deps, + }); + const ctx = { + config: { dataDir, nodeId: "node-self" }, + meta: { get: async () => undefined }, + tmux: { panePid: async () => (opts.panePid === undefined ? 100 : opts.panePid) }, + previewForward: forward, + sendPreviewEvent: (ev: PreviewLaneEvent) => { + events.push(ev); + }, + } as unknown as CommandContext; + return { ctx, forward, events, sockets, attempts, clock }; +} + +function openCmd(over: Partial = {}): PreviewOpenCommand { + return { + type: "preview_open", + ref: refFor(1), + kind: "forward", + paneId: PANE, + port: 4321, + lifetimeMs: 60_000, + ...over, + }; +} + +/** Poll until `cond`, then return; a real wait for the real-socket case. */ +async function waitUntil(cond: () => boolean, what: string, ms = 3000): Promise { + const until = Date.now() + ms; + while (!cond()) { + if (Date.now() > until) throw new Error(`timed out: ${what}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +const closeEvents = (events: PreviewLaneEvent[]): { ref: string; reason: PreviewCloseReason }[] => + events + .filter((e): e is Extract => e.type === "preview_closed") + .map((e) => ({ ref: e.ref, reason: e.reason })); + +const dataEvents = (events: PreviewLaneEvent[]): Extract[] => + events.filter((e): e is Extract => e.type === "preview_data"); + +/* ------------------------------------------------------------------ */ +/* (a) the A-branch contract: ack before the dial settles */ +/* ------------------------------------------------------------------ */ + +describe("(a) pending ack, off-chain dial (the ssh-relay A-branch contract)", () => { + it("acks immediately, unblocks the next command, and a failed dial emits target-gone once", async () => { + const h = makeHarness("ack-offchain"); + const exec = dispatchCommand(h.ctx, openCmd()); + // The proof is timing (commands-ssh-relay.test.ts's habit): if the + // executor awaited the dial, this race would answer `timeout`. + const winner = await Promise.race([ + exec.then((r) => ({ kind: "ack" as const, r })), + new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 100)), + ]); + expect(winner.kind).toBe("ack"); + if (winner.kind !== "ack") return; + expect(winner.r).toEqual({ ok: true, data: { ref: refFor(1), pending: true } }); + // Task 1's validator accepts the forward ack shape. + expect(parseNodePreviewOpenResult(winner.r.ok === true ? winner.r.data : null)).toEqual({ + ref: refFor(1), + pending: true, + }); + // A FOLLOWING command is not parked behind the pending dial: the ping + // answers while the first attempt is still open (one attempt so far). + const ping = await Promise.race([ + dispatchCommand(h.ctx, { type: "ping" }).then((r) => ({ kind: "pong" as const, r })), + new Promise<{ kind: "blocked" }>((resolve) => setTimeout(() => resolve({ kind: "blocked" }), 100)), + ]); + expect(ping.kind).toBe("pong"); + expect(h.attempts).toEqual([{ host: "127.0.0.1", port: 4321 }]); + + // The eventual failure: v4 refused, the ONE v6 retry refused, and the + // plane is told `target-gone` exactly once. + h.sockets[0]?.fire("error", new Error("connect ECONNREFUSED 127.0.0.1:4321")); + expect(h.attempts.map((a) => a.host)).toEqual(["127.0.0.1", "::1"]); + h.sockets[1]?.fire("error", new Error("connect ECONNREFUSED ::1:4321")); + expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "target-gone" }]); + expect(h.forward.laneCount()).toBe(0); + + // A close of that ref after the death is the honest tombstone answer. + expect(await dispatchCommand(h.ctx, { type: "preview_close", ref: refFor(1), reason: "viewer-left" })).toEqual({ + ok: true, + data: { ref: refFor(1), closed: false }, + }); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (b) a real connected lane echoes both directions */ +/* ------------------------------------------------------------------ */ + +describe("(b) the connected lane pumps both directions", () => { + it("an inbound preview_data reaches the socket and echoed bytes leave as an out event", async () => { + const received: Buffer[] = []; + const server = createServer((sock) => { + sock.on("data", (d) => { + received.push(Buffer.from(d)); + sock.write(d); + }); + }); + const dialed = new Promise((resolve) => server.once("connection", () => resolve())); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + const h = makeHarness("echo", { deps: { connect: undefined } }); // real net, fake clock + try { + const res = await dispatchCommand(h.ctx, openCmd({ port })); + expect(res.ok).toBe(true); + await dialed; + h.forward.ingestInboundFrame({ + type: "preview_data", + ref: refFor(1), + dir: "in", + blob: Buffer.from("ping").toString("base64"), + }); + await waitUntil(() => dataEvents(h.events).length >= 1, "the echoed bytes leave as an out event"); + expect(Buffer.concat(received).toString()).toBe("ping"); + expect(dataEvents(h.events)[0]).toEqual({ + type: "preview_data", + ref: refFor(1), + dir: "out", + blob: Buffer.from("ping").toString("base64"), + }); + } finally { + h.forward.closeAll("viewer-left"); + server.close(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (c) close is not gated; unknown refs answer the tombstone */ +/* ------------------------------------------------------------------ */ + +describe("(c) preview_close is teardown, not a gated act", () => { + it("an off switch does not refuse a close: an unknown ref answers closed:false", async () => { + const h = makeHarness("close-ungated", { previewOn: false }); + const res = await execPreviewClose(h.ctx, { type: "preview_close", ref: refFor(7), reason: "viewer-left" }); + expect(res).toEqual({ ok: true, data: { ref: refFor(7), closed: false } }); + expect(parseNodePreviewCloseResult(res.ok === true ? res.data : null)).toEqual({ + ref: refFor(7), + closed: false, + }); + }); + + it("closing an owned lane destroys its socket, answers closed:true, and emits NO event", () => { + const h = makeHarness("close-owned"); + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + h.sockets[0]?.fire("connect"); + expect(h.forward.closeLane(refFor(1), "viewer-left")).toEqual({ closed: true }); + expect(h.sockets[0]?.destroyed).toBe(true); + expect(h.forward.laneCount()).toBe(0); + // The plane ordered this death; it does not need the node to report it. + expect(h.events).toHaveLength(0); + // The idempotent twin: a second close answers closed:false. + expect(h.forward.closeLane(refFor(1), "viewer-left")).toEqual({ closed: false }); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (d) quotas: 16 per node, 4 per pane (lanes only, this task) */ +/* ------------------------------------------------------------------ */ + +describe("(d) the node-side budgets", () => { + it("the 17th concurrent lane refuses node-cap; the 5th in one pane refuses pane-cap", async () => { + expect(PREVIEW_SESSIONS_PER_NODE).toBe(16); + expect(PREVIEW_FORWARD_SESSIONS_PER_PANE).toBe(4); + const h = makeHarness("quota"); + let n = 0; + const openIn = async (paneId: string) => { + n += 1; + return await dispatchCommand(h.ctx, openCmd({ ref: refFor(n), paneId })); + }; + for (const paneId of [PANES[0], PANES[1], PANES[2], PANES[3]]) { + for (let i = 0; i < PREVIEW_FORWARD_SESSIONS_PER_PANE; i += 1) expect((await openIn(paneId)).ok).toBe(true); + } + expect(h.forward.laneCount()).toBe(16); + expect(h.forward.laneCount(PANES[0])).toBe(4); + // The 17th lands in a fresh pane: the pane gate passes, the node gate refuses. + expect(await openIn(PANES[4])).toEqual({ ok: false, error: PREVIEW_LANE_OVER_NODE_CAP }); + expect(h.forward.laneCount()).toBe(16); + // Free one node slot, refill pane A to its cap, then ask for its 5th: + // the per-pane refusal must win (the pane rung is checked first). + expect(h.forward.closeLane(refFor(1), "viewer-left")).toEqual({ closed: true }); + expect((await openIn(PANES[0])).ok).toBe(true); + expect(h.forward.laneCount()).toBe(16); + expect(await openIn(PANES[0])).toEqual({ ok: false, error: PREVIEW_LANE_OVER_PANE_CAP }); + expect(h.forward.laneCount()).toBe(16); + expect(h.events).toHaveLength(0); // refusals start no mechanism and report no death + }); +}); + +/* ------------------------------------------------------------------ */ +/* (e) idle, EOF-once, and the close-that-lands-first */ +/* ------------------------------------------------------------------ */ + +describe("(e) idle and target death", () => { + it("the idle budget self-closes with reason idle, and traffic resets it", () => { + const h = makeHarness("idle", { deps: { idleMs: 250 } }); + const open = () => + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + open(); + h.sockets[0]?.fire("connect"); // activity: resets the idle timer + expect(h.clock.cancelledAt(250)).toBe(1); // the pre-connect timer was replaced + h.forward.ingestInboundFrame({ type: "preview_data", ref: refFor(1), dir: "in", blob: "QUJD" }); // and so is this one + expect(h.clock.cancelledAt(250)).toBe(2); + expect(h.clock.liveAt(250)).toBe(1); // exactly one idle timer still armed + expect(h.clock.fire(250)).toBe(1); + expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "idle" }]); + expect(h.forward.laneCount()).toBe(0); + }); + + it("target EOF emits target-gone exactly once, and a close landing first suppresses it", () => { + const h = makeHarness("eof"); + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + h.sockets[0]?.fire("connect"); + h.sockets[0]?.fire("end"); // a real socket fires `end` then `close` + h.sockets[0]?.fire("close"); // one emission total, not two + expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "target-gone" }]); + expect(h.forward.laneCount()).toBe(0); + + const g = makeHarness("close-first"); + g.forward.openLane({ + ref: refFor(2), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => g.events.push(ev) }, + }); + g.sockets[0]?.fire("connect"); + expect(g.forward.closeLane(refFor(2), "viewer-left")).toEqual({ closed: true }); + g.sockets[0]?.fire("end"); // the late EOF finds no lane + g.sockets[0]?.fire("close"); + g.clock.fire(250_000_000); // and no timer can resurrect it either + expect(g.events).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (f) the slow-link guard */ +/* ------------------------------------------------------------------ */ + +describe("(f) slow link", () => { + it("undeliverable bytes past the injected buffer close the lane with slow-link", () => { + const h = makeHarness("slow", { deps: { laneBufferMaxBytes: 64 } }); + // The recording pump: every frame is ATTEMPTED (recorded) and the byte + // leg is REFUSED, which is the deliver-or-throw shape a dead link gives + // the lane. The close notice rides the same recorder so the final event + // is observable. + const attempts: PreviewLaneEvent[] = []; + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { + emit: (ev) => { + attempts.push(ev); + if (ev.type === "preview_data") throw new Error("relay send refused: no live socket"); + }, + }, + }); + h.sockets[0]?.fire("connect"); + h.sockets[0]?.fire("data", Buffer.alloc(40, 7)); // 40 unaccepted: still under the 64-byte budget + expect(h.forward.laneCount()).toBe(1); + expect(closeEvents(attempts)).toHaveLength(0); + h.sockets[0]?.fire("data", Buffer.alloc(40, 7)); // 80 past 64: the link is too slow + expect(dataEvents(attempts)).toHaveLength(2); // the pump was given both frames; both refused delivery + expect(closeEvents(attempts)).toEqual([{ ref: refFor(1), reason: "slow-link" }]); + expect(h.sockets[0]?.destroyed).toBe(true); + expect(h.forward.laneCount()).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (g) the mirror switch gates the open, not the close */ +/* ------------------------------------------------------------------ */ + +describe("(g) the gate", () => { + it("an off switch refuses preview_open before any dial or registration", async () => { + const h = makeHarness("gate-off", { previewOn: false }); + const res = await dispatchCommand(h.ctx, openCmd()); + expect(res).toEqual({ ok: false, error: PREVIEW_NODE_OFF }); + expect(h.attempts).toHaveLength(0); + expect(h.forward.laneCount()).toBe(0); + expect(h.events).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (h) closeAll on link drop */ +/* ------------------------------------------------------------------ */ + +describe("(h) closeAll", () => { + it("closes every lane with exactly one event each and empties the registry", () => { + const h = makeHarness("close-all"); + for (const [i, paneId] of [PANES[0], PANES[1]].entries()) { + h.forward.openLane({ + ref: refFor(i + 1), + port: 4321, + paneId, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + } + expect(h.forward.laneCount()).toBe(2); + h.forward.closeAll("node-dropped"); + expect(closeEvents(h.events)).toEqual([ + { ref: refFor(1), reason: "node-dropped" }, + { ref: refFor(2), reason: "node-dropped" }, + ]); + expect(h.forward.laneCount()).toBe(0); + expect(h.sockets.every((s) => s.destroyed)).toBe(true); + // A dial that lands after the sweep must not resurrect the lane. + h.sockets[0]?.fire("connect"); + h.sockets[0]?.fire("data", Buffer.from("late")); + expect(h.forward.laneCount()).toBe(0); + expect(closeEvents(h.events)).toHaveLength(2); + expect(dataEvents(h.events)).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (i) the oversized-read guard */ +/* ------------------------------------------------------------------ */ + +describe("(i) over-cap", () => { + it("one oversized read sends zero preview_data and closes over-cap exactly once", () => { + const h = makeHarness("over-cap"); + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + h.sockets[0]?.fire("connect"); + // A fake socket can hand any chunk it likes; net's 64 KiB default is + // production behavior, not the harness's. The task names ONE read over cap. + h.sockets[0]?.fire("data", Buffer.alloc(PREVIEW_FRAME_MAX_BYTES + 1, 9)); + expect(dataEvents(h.events)).toHaveLength(0); // the blob never reaches the parser + expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "over-cap" }]); + expect(h.sockets[0]?.destroyed).toBe(true); + expect(h.forward.laneCount()).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (j) the absolute lifetime */ +/* ------------------------------------------------------------------ */ + +describe("(j) lifetime", () => { + it("the lifetime timer fires preview_closed idle exactly once per lane", () => { + const h = makeHarness("lifetime"); + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 40, // the seam exists to be small; the wire clamps to >= 60 s + hooks: { emit: (ev) => h.events.push(ev) }, + }); + h.sockets[0]?.fire("connect"); + expect(h.clock.fire(40)).toBe(1); + expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "idle" }]); + h.clock.fire(40); // a second fire of a dead timer changes nothing + expect(closeEvents(h.events)).toHaveLength(1); + expect(h.forward.laneCount()).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (k) pane liveness gates before registration */ +/* ------------------------------------------------------------------ */ + +describe("(k) pane liveness", () => { + it("an absent pane refuses preview-pane-gone before any registration or dial", async () => { + const h = makeHarness("pane-gone", { panePid: null }); + const res = await dispatchCommand(h.ctx, openCmd()); + expect(res).toEqual({ ok: false, error: PREVIEW_PANE_GONE }); + expect(h.attempts).toHaveLength(0); // the fake connect never ran + expect(h.forward.laneCount()).toBe(0); // and no lane appeared in the registry + expect(h.events).toHaveLength(0); + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 1f2cff692..5b7ef8735 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -10,6 +10,7 @@ import type { RelayFrame, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; +import type { PreviewForward, PreviewLaneEvent } from "../preview/forward.js"; import type { ServiceDeps } from "../service.js"; import type { SubshellMetaStore } from "../subshell-meta.js"; import type { NodeBinaryDeps } from "../update.js"; @@ -131,6 +132,22 @@ export interface CommandContext { */ sendRelayFrame(frame: RelayFrame): void; }; + /** + * The preview forward-lane registry (pane-preview spec 2026-10-21 §4, Task + * 5): the ref→lane map the `preview_open` forward branch registers into and + * `preview_close` cuts from. OPTIONAL by type so every pre-preview context + * fake keeps compiling; the real daemon always provides it, and the preview + * arms refuse loudly when a context does not (the relay precedent). + */ + previewForward?: PreviewForward; + /** + * Hand one preview lane event to the CURRENT established link. + * DELIVER-OR-THROW, and the SAME sender the relay frames ride + * (`relay-send.ts`): the lane's slow-link accounting reads the throw — an + * event that silently returned would be a byte the plane believes left. + * Optional for the same fake-compiling reason as {@link previewForward}. + */ + sendPreviewEvent?: (ev: PreviewLaneEvent) => void; /** * The panes supervised for natural death, keyed by subshellId → * {@link WatcherRegistration} (socket + registration token + unreachable diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 94b13d9ab..85c53be61 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -22,6 +22,8 @@ import type { CommandContext, CommandResult } from "./context.js"; import { execFileRead } from "./file-read.js"; import { execFsLs } from "./fs-ls.js"; import { execLaunch } from "./launch.js"; +import { execPreviewClose } from "./preview-close-exec.js"; +import { execPreviewOpen } from "./preview-open-exec.js"; import { execPreviewPorts } from "./preview-ports-exec.js"; import { execPromptDeliver } from "./prompt.js"; import { execService } from "./service.js"; @@ -130,6 +132,10 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return await execPaneSize(ctx, cmd); case "pane_cursor": return await execPaneCursor(ctx, cmd); + case "preview_open": + return await execPreviewOpen(ctx, cmd); + case "preview_close": + return execPreviewClose(ctx, cmd); case "preview_ports_get": return await execPreviewPorts(ctx, cmd); case "prompt_deliver": diff --git a/apps/node/agent/src/commands/preview-close-exec.ts b/apps/node/agent/src/commands/preview-close-exec.ts new file mode 100644 index 000000000..fe91afcc5 --- /dev/null +++ b/apps/node/agent/src/commands/preview-close-exec.ts @@ -0,0 +1,23 @@ +import type { JsonValue } from "@internal/subshell-protocol"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * Execute `preview_close` (pane-preview spec 2026-10-21 §4, Task 5): end the + * lane named by the ref. Deliberately NOT gated — the `set_ssh_enabled` and + * `ssh_relay_close` doctrine: a switch-off mid-session must still be able to + * end a live lane, because teardown is not a preview act, and refusing it + * would strand a dialed socket on a machine that has stopped serving + * previews. An unknown ref answers `closed: false` — a close racing the + * lane's own death is routine, and the false answer is the honest record, not + * an error (`forward.ts`'s synchronous registration means there is no + * detached-open race to tombstone; the ref exists from the ack onward). + */ +export function execPreviewClose(ctx: CommandContext, cmd: Cmd<"preview_close">): CommandResult { + const forward = ctx.previewForward; + if (!forward) { + // The impossible-state guard again: no preview plumbing, no lanes to close. + return { ok: false, error: "preview plumbing unavailable in this daemon context" }; + } + const { closed } = forward.closeLane(cmd.ref, cmd.reason); + return { ok: true, data: { ref: cmd.ref, closed } as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/commands/preview-open-exec.ts b/apps/node/agent/src/commands/preview-open-exec.ts new file mode 100644 index 000000000..f71300882 --- /dev/null +++ b/apps/node/agent/src/commands/preview-open-exec.ts @@ -0,0 +1,90 @@ +import { + type JsonValue, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_SESSIONS_PER_NODE, +} from "@internal/subshell-protocol"; +import { getPreviewEnabled } from "../preview/flags.js"; +import type { LaneDeps } from "../preview/forward.js"; +import { resolveSocket } from "./basics.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; +import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; + +/** + * The `preview_open` arm (pane-preview spec 2026-10-21 §4/§6, Task 5): the + * gate ladder a forward-lane request must walk, then the one call that starts + * the mechanism. The ladder's ORDER is its whole point — mirror switch, pane + * liveness, budgets — every refusal answers at the ladder, so a request that + * was never going to register starts no dial, no timer, and no lane. + * + * - **The switch first**, like every preview arm: {@link PREVIEW_NODE_OFF} is + * the shared voice, asked fail-closed before any pane or port is consulted. + * - **The pane second**: the forward twin of `preview_ports_get`'s refusal. + * A dead pane's port can be rebound by a stranger process, and a lane + * dialed onto that is indistinguishable from dialing another pane's server, + * so liveness is law before registration, not a fact the dial trusts. + * - **The budgets last**: 4 lanes per pane, 16 per node (spec §6). At this + * task only forward lanes consume the counter; Task 6's bridges join the + * same numbers, which is why the rungs read + * {@link PreviewForward.laneCount} and never a private tally. + * + * A passing ladder calls {@link PreviewForward.openLane}, which registers + * synchronously and dials OFF the command chain (the ssh-relay A-branch + * posture, restated there): the ack says `pending: true`, and a dial that + * fails later reaches the plane as one `preview_closed`, never a second + * answer to this command. The `screencast` kind is Task 6's branch; until it + * lands the arm answers the integration contract word `unsupported`, which is + * what the plane's compat logic already reads for a node that lacks a + * feature. + */ + +/** The node holds its 16 concurrent lanes; a new open would be the 17th. */ +export const PREVIEW_LANE_OVER_NODE_CAP = "preview-lane-over-node-cap"; + +/** This pane holds its 4 lanes; a new open would be its 5th. */ +export const PREVIEW_LANE_OVER_PANE_CAP = "preview-lane-over-pane-cap"; + +/** The pane the command names is not running here (anymore, or never). */ +export const PREVIEW_PANE_GONE = "preview-pane-gone"; + +/** + * Execute `preview_open`: switch → pane → budgets → register, in that order. + * A malformed pane id refuses `invalid subshell id` through {@link + * resolveSocket}'s throw, before tmux is asked anything — the same id gate + * every pane executor runs first. `deps` is the mechanics seam + * (`forward.ts`); production dispatch omits it and gets real net and real + * (unref'd) timers. + */ +export async function execPreviewOpen( + ctx: CommandContext, + cmd: Cmd<"preview_open">, + deps?: LaneDeps, +): Promise { + if (getPreviewEnabled(ctx.config.dataDir)?.on !== true) return { ok: false, error: PREVIEW_NODE_OFF }; + if (cmd.kind !== "forward") return { ok: false, error: "unsupported" }; + const forward = ctx.previewForward; + const send = ctx.sendPreviewEvent; + if (!forward || !send) { + // Not a user-facing condition: a daemon that runs commands without the + // preview plumbing would half-open lanes with no event path, so this is + // the impossible-state guard, answered loudly (the relay precedent). + return { ok: false, error: "preview plumbing unavailable in this daemon context" }; + } + const socket = await resolveSocket(ctx, cmd.paneId); + const panePid = await ctx.tmux.panePid(socket, cmd.paneId); + if (panePid === null) return { ok: false, error: PREVIEW_PANE_GONE }; + if (forward.laneCount(cmd.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) + return { ok: false, error: PREVIEW_LANE_OVER_PANE_CAP }; + if (forward.laneCount() >= PREVIEW_SESSIONS_PER_NODE) return { ok: false, error: PREVIEW_LANE_OVER_NODE_CAP }; + forward.openLane({ + ref: cmd.ref, + port: cmd.port, + paneId: cmd.paneId, + lifetimeMs: cmd.lifetimeMs, + hooks: { emit: (ev) => send(ev) }, + deps, + }); + // The seam cast is the JSON-safety posture the sibling executors use + // (`node-results.ts` owns the answer contract; a forward ack is a plain + // object by the plan's interface note). + return { ok: true, data: { ref: cmd.ref, pending: true } as unknown as JsonValue }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index a7783e21c..268418d03 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -12,6 +12,7 @@ import { type NodePreviewEnabledWire, type NodeRuntimeReport, type NodeSshEnabledWire, + parsePreviewFrame, parseRelayFrame, relayFrameRefIfOverCap, SeqTracker, @@ -43,6 +44,7 @@ import { binaryPayload, createLinkNegotiator, type LinkNegotiator, type LinkNego import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; +import { PreviewForward } from "./preview/forward.js"; import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; import { type NodeRoute, NodeRouteSelector } from "./route-selection.js"; @@ -511,6 +513,14 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // from Task 7, the A responder) register into it by routing ref. const relaySessions = deps.relaySessions ?? new RelaySessions(log); + // The preview forward-lane registry (pane-preview spec 2026-10-21, Task 5): + // PER-DAEMON like the relay registry — a lane belongs to the plane's proxy + // request, but it may NOT outlive the link it was dialed on: `finish` sweeps + // it with `closeAll("node-dropped")` (the relay's survive reconnects by + // design; a raw TCP lane's peer is the proxy, which died with the socket). + // Production mechanics are the defaults: real net, unref'd real timers. + const previewForward = new PreviewForward(); + // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — // executors hold `ctx` across reconnects. The serial chain does NOT keep @@ -536,6 +546,14 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom return (currentWs as unknown as { bufferedAmount?: number } | undefined)?.bufferedAmount; }, }; + // One pump, two byte legs (pane-preview Task 5): relay frames and preview + // lane events leave through the SAME deliver-or-throw path, so the sealed + // write, the size guard, and the throw the lane's slow-link accounting + // reads are one implementation. The pump reads the CURRENT socket and link + // through a getter, exactly like the stale-socket guard in `send` above: a + // frame sent after this socket died targets the new link if one exists, and + // throws if none does (acceptance (c)). + const laneSend = createDaemonRelaySend(() => ({ ws: currentWs, link })); const ctx: CommandContext = { config, tmux: deps.tmux ?? new TmuxRunner(), @@ -554,8 +572,12 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // if one exists, and throws if none does (acceptance (c)). relay: { sessions: relaySessions, - sendRelayFrame: createDaemonRelaySend(() => ({ ws: currentWs, link })), + sendRelayFrame: laneSend, }, + // The preview lane plumbing (pane-preview spec 2026-10-21, Task 5): the + // ref→lane registry plus its event leg, which IS the relay pump above. + previewForward, + sendPreviewEvent: laneSend, requestRestart: () => { // Deferred, because the daemon is the only sender of `result`: the // executor returns `{ ok: true }`, the frame leaves on this turn, and @@ -1033,6 +1055,20 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom } return; } + // The `preview_data` lane frame is not a command either (pane-preview spec + // §4: the link authenticates the machine, so the byte legs skip per-frame + // signing), and it takes the SAME intake shape: recognized by `type` + // before the JWS path, parsed by the grammar, routed by ref. Malformed or + // over-cap candidates drop with a line, per the parser's own rule: the + // grammar refuses the over-cap blob quietly and lane lifecycle belongs to + // the lane (the SENDER-side over-cap guard, preview/forward.ts, is what + // spec §6 names a close; inbound over-cap is a plane bug and noise here). + if (typeof parsed === "object" && parsed !== null && (parsed as { type?: unknown }).type === "preview_data") { + const frame = parsePreviewFrame(parsed as object); + if (frame !== null) previewForward.ingestInboundFrame(frame); + else log("malformed preview frame dropped"); + return; + } const jws = (parsed as { jws?: unknown } | undefined)?.jws; if (typeof jws !== "string") { send(ws, { type: "error", code: "verify", message: "malformed" }); @@ -1163,6 +1199,13 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // the reconnect loop dials again (the control plane re-`tail_start`s // on the new connection with its own cursors; spec §3.4). stopAllTails(ctx); + // The same rule for preview forward lanes (pane-preview Task 5): a + // lane's peer is this link's proxy lane, and a raw TCP lane must not + // outlive it. `closeAll` dials down every lane with one + // `preview_closed` each; the pump throws for those events (the link is + // gone) and `forward.ts` keeps the log record. The plane learns of the + // lanes' deaths from the dropped link itself. + previewForward.closeAll("node-dropped"); resolve(close); }; // The per-socket link negotiator (spec 2026-09-24). It owns the wire diff --git a/apps/node/agent/src/preview/forward.ts b/apps/node/agent/src/preview/forward.ts new file mode 100644 index 000000000..77693e4a3 --- /dev/null +++ b/apps/node/agent/src/preview/forward.ts @@ -0,0 +1,390 @@ +import { connect as netConnect } from "node:net"; +import { + type NodeEvent, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_BUFFER_MAX_BYTES, + type PreviewCloseReason, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { log } from "../log.js"; + +/** + * The node's forward lanes (pane-preview spec 2026-10-21 §4/§6, Task 5): the + * ref→lane registry behind `preview_open`'s `forward` branch. A lane is the + * machine's raw loopback TCP lane — the plane's same-origin proxy (Task 8) + * dials into it through this daemon, and the pane's dev server answers as if + * the browser were local. + * + * The A-branch contract is `ssh-relay-exec.ts`'s, copied for the reason it + * earned there: the daemon's executor chain serializes every command, so the + * dial runs OFF the chain. `openLane` registers the lane SYNCHRONOUSLY and + * returns; the ack (`pending: true`) is the executor's, answered before the + * socket has done anything. A dial that fails later is not a second answer to + * the plane — it is one `preview_closed` `target-gone` event. + * + * Three things make a forward lane different from a relay session, and the + * command's gate ladder owns all three, so nothing here re-decides them: + * there is no host on the wire (the grammar bounds the port, the dials are + * loopback literals only), pane liveness is a command-time check, and the + * node-side budget (Task 6 will widen it to a shared lane+bridge counter) is + * read through {@link PreviewForward.laneCount}. + */ + +/** The lane events this registry emits: the byte leg and the death notice. */ +export type PreviewLaneEvent = Extract; + +/** + * The dial target's socket, narrowed to what a lane uses — the seam lets a + * test hand a fake that fires `connect`/`data`/`end`/`error` on demand, and + * `net.Socket` satisfies it structurally. + */ +export interface LaneSocket { + on(event: "connect", listener: () => void): unknown; + on(event: "data", listener: (chunk: Buffer) => void): unknown; + on(event: "end" | "close", listener: () => void): unknown; + on(event: "error", listener: (err: Error) => void): unknown; + write(chunk: Buffer): unknown; + destroy(): unknown; +} + +/** The timer seam: same reason as the socket seam (fake the idle/lifetime clocks). */ +export interface LaneTimers { + setTimeout(cb: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +/** The mechanics a caller (or a test) can replace. Production passes none. */ +export interface LaneDeps { + /** Defaults to the real `net.connect`; loopback literals only, whatever it dials. */ + connect?: (options: { host: string; port: number }) => LaneSocket; + now?: () => number; + /** Defaults to global timers UNREF'd — a lane timer must never hold the daemon (or a test) open. */ + timer?: LaneTimers; + /** Traffic-idle budget; defaults to {@link PREVIEW_IDLE_MS}. */ + idleMs?: number; + /** Slow-link budget; defaults to {@link PREVIEW_LANE_BUFFER_MAX_BYTES}. */ + laneBufferMaxBytes?: number; +} + +/** What a lane delivers through: the event sink and its line sink. */ +export interface LaneHooks { + /** + * Hand one lane event to the CURRENT established link. DELIVER-OR-THROW — + * the daemon wires the same pump the relay frames ride (`relay-send.ts`), + * because the slow-link accounting READS the throw: a frame that did not + * leave is unaccepted, and unaccepted bytes are the budget. + */ + emit(ev: PreviewLaneEvent): void; + /** Line sink for lane lifecycle (defaults to the agent log), the `RelayOpenSeams.log` habit. */ + log?: (line: string) => void; +} + +/** One `openLane` call: everything the command's verified body carries. */ +export interface OpenLaneArgs { + ref: string; + port: number; + paneId: string; + lifetimeMs: number; + hooks: LaneHooks; + /** Per-lane seam overrides on top of the registry's own defaults. */ + deps?: LaneDeps; +} + +interface Lane { + ref: string; + paneId: string; + port: number; + lifetimeMs: number; + hooks: LaneHooks; + connect: NonNullable; + timer: LaneTimers; + idleMs: number; + laneBufferMaxBytes: number; + /** Set the moment the dial starts (a pending socket is destroyed on death too). */ + socket?: LaneSocket; + /** Running total of bytes the pump refused to deliver (the slow-link budget). */ + pendingBytes: number; + timers: { idle?: unknown; lifetime?: unknown }; + say: (line: string) => void; +} + +/** The real dial: net's own connect, which the retry posture below wraps. */ +const realConnect: NonNullable = (options) => netConnect(options); + +/** Real timers, unref'd: the watch-tick doctrine — a 15-minute idle arm must not hold the process open. */ +const realTimers: LaneTimers = { + setTimeout: (cb, ms) => { + const t = setTimeout(cb, ms); + t.unref(); + return t; + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), +}; + +export class PreviewForward { + private readonly lanes = new Map(); + /** Registry-wide seam defaults (the daemon passes none; tests pass their fakes). */ + private readonly defaults: LaneDeps; + + constructor(deps: LaneDeps = {}) { + this.defaults = deps; + } + + /** + * Register one lane SYNCHRONOUSLY and start its dial off the chain. Both + * timers arm at open so a stuck dial still dies on budget. A ref the + * registry already owns keeps the OLD lane (clobbering it would orphan a + * live socket behind a second plane-side owner); crypto-random 32-hex refs + * make that unrepresentable, and the plane's broker owns uniqueness. + */ + openLane(args: OpenLaneArgs): void { + if (this.lanes.has(args.ref)) { + log(`preview lane ${args.ref}: open for an already-registered ref, keeping the live lane`); + return; + } + const now = args.deps?.now ?? this.defaults.now ?? Date.now; + const lane: Lane = { + ref: args.ref, + paneId: args.paneId, + port: args.port, + lifetimeMs: args.lifetimeMs, + hooks: args.hooks, + connect: args.deps?.connect ?? this.defaults.connect ?? realConnect, + timer: args.deps?.timer ?? this.defaults.timer ?? realTimers, + idleMs: args.deps?.idleMs ?? this.defaults.idleMs ?? PREVIEW_IDLE_MS, + laneBufferMaxBytes: + args.deps?.laneBufferMaxBytes ?? this.defaults.laneBufferMaxBytes ?? PREVIEW_LANE_BUFFER_MAX_BYTES, + socket: undefined, + pendingBytes: 0, + timers: {}, + say: args.hooks.log ?? ((line: string) => log(line)), + }; + this.lanes.set(lane.ref, lane); + lane.say( + `preview lane ${lane.ref}: opened for pane ${lane.paneId} at ${new Date(now()).toISOString()} (dialing port ${lane.port})`, + ); + this.arm(lane, "idle"); + this.arm(lane, "lifetime"); + this.dial(lane, false); + } + + /** + * Plane-ordered teardown. NOT access-gated (the caller's doctrine: a + * switch-off mid-session must still be able to end a lane — teardown is not + * a preview act) and silent by design: `preview_closed` is the node + * volunteering a death the plane did not order, and the plane knows about + * its own. A later socket death finds no lane, which is the suppression the + * executor's tombstone answer (`closed: false`) describes. + */ + closeLane(ref: string, reason: PreviewCloseReason): { closed: boolean } { + const lane = this.lanes.get(ref); + if (!lane) return { closed: false }; + this.remove(lane); + lane.say(`preview lane ${ref}: closed by the plane (${reason})`); + return { closed: true }; + } + + /** + * One parsed inbound lane frame (the daemon's `preview_data` intake arm). + * A ref with no lane is the ordinary race — a close landing before this + * frame — and drops quietly, like the relay intake drops its own noise. + */ + ingestInboundFrame(frame: PreviewDataFrame): void { + const lane = this.lanes.get(frame.ref); + if (!lane) return; + // The plane's leg is `in` only; an `out` inbound is stream noise, and a + // stranger blob is not this lane's bytes to write anywhere. + if (frame.dir !== "in") return; + this.touch(lane); + try { + // `socket` is set the moment the dial starts, so the only way to miss + // it is a synchronous connect throw — and net buffers writes on a + // connecting socket anyway. + lane.socket?.write(Buffer.from(frame.blob, "base64")); + } catch (err: unknown) { + // The dialed target died under the write: the socket is gone, the lane + // follows it. (The `error`/`end` handlers race this on a real socket; + // the lane-presence guard makes first death win.) + lane.say(`preview lane ${lane.ref}: inbound write failed (${String(err)}), closing target-gone`); + this.die(lane, "target-gone"); + } + } + + /** Lanes now registered, node-wide or for one pane — the command's budget read. */ + laneCount(paneId?: string): number { + if (paneId === undefined) return this.lanes.size; + let n = 0; + for (const lane of this.lanes.values()) if (lane.paneId === paneId) n += 1; + return n; + } + + /** + * The daemon's link-drop hook (and the revoked-switch sweep's forward half): + * every lane dies with exactly one `preview_closed` each. The pump will + * throw for each of these while the socket is gone — `die` treats that as + * the expected answer (the plane reads the dropped link itself); what the + * sweep guarantees is the machine-side state: no lane outlives the link it + * served. + */ + closeAll(reason: PreviewCloseReason): void { + for (const lane of [...this.lanes.values()]) this.die(lane, reason); + } + + /* ---------------------------------------------------------------- */ + /* dialing */ + /* ---------------------------------------------------------------- */ + + /** Dial loopback v4 first; ONE v6 retry; both failures are `target-gone`. */ + private dial(lane: Lane, v6: boolean): void { + const host = v6 ? "::1" : "127.0.0.1"; + let socket: LaneSocket; + try { + socket = lane.connect({ host, port: lane.port }); + } catch (err: unknown) { + this.dialFailed(lane, v6, err); + return; + } + lane.socket = socket; + let up = false; + socket.on("connect", () => { + if (this.lanes.get(lane.ref) !== lane) { + // The lane closed while this dial was in flight (link drop, plane + // cut): this socket has no owner, so it must not keep the target's + // half-open session alive behind the sweep. + try { + socket.destroy(); + } catch { + /* already gone */ + } + return; + } + up = true; + lane.say(`preview lane ${lane.ref}: connected (${host}:${lane.port})`); + this.touch(lane); + }); + socket.on("data", (chunk) => { + if (this.lanes.get(lane.ref) !== lane) return; + this.onSocketData(lane, chunk); + }); + // A real target EOF arrives as `end` then `close`; the lane-presence + // guard in `die` collapses the pair into the single event spec §6 owes. + socket.on("end", () => this.die(lane, "target-gone")); + socket.on("close", () => this.die(lane, "target-gone")); + socket.on("error", (err) => { + if (this.lanes.get(lane.ref) !== lane) return; + if (!up) this.dialFailed(lane, v6, err); + // Post-connect failure is the target process dying mid-session. + else this.die(lane, "target-gone"); + }); + } + + private dialFailed(lane: Lane, v6: boolean, err: unknown): void { + if (this.lanes.get(lane.ref) !== lane) return; + if (!v6) { + // The one retry is not a second attempt at a different SERVICE — loopback + // v4 and v6 are the same host, and a server bound to only one family is + // routine. The lane's budget timers, not a dial timeout, bound the total. + lane.say(`preview lane ${lane.ref}: 127.0.0.1:${lane.port} refused (${String(err)}), retrying ::1`); + this.dial(lane, true); + return; + } + this.die(lane, "target-gone"); + } + + /* ---------------------------------------------------------------- */ + /* the pump */ + /* ---------------------------------------------------------------- */ + + /** + * One socket read heading plane-ward. The over-cap guard is the sender half + * of spec §6's named close: the receiving parser refuses an over-cap blob + * and the daemon drops what will not parse, so an unchecked sender would + * corrupt the lane SILENTLY mid-stream. Net's 64 KiB chunks make this a + * guard, not a routine. An over-cap read emits ZERO bytes and closes the + * lane `over-cap` — never fragmentation. + */ + private onSocketData(lane: Lane, chunk: Buffer): void { + if (chunk.length > PREVIEW_FRAME_MAX_BYTES) { + this.die(lane, "over-cap"); + return; + } + this.touch(lane); + const frame = { + type: "preview_data", + ref: lane.ref, + dir: "out", + blob: chunk.toString("base64"), + } as const; + try { + lane.hooks.emit(frame); + } catch (err: unknown) { + // Deliver-or-throw: the throw is the slow-link signal. The bytes join + // the unaccepted total; the lane survives until that total passes the + // budget (spec §5's number), then dies `slow-link`. Nothing re-sends + // them — a lane with a dropped hole is already broken, and the budget + // is what bounds how long "temporarily undeliverable" may pretend. + lane.pendingBytes += chunk.length; + if (lane.pendingBytes > lane.laneBufferMaxBytes) { + lane.say( + `preview lane ${lane.ref}: ${lane.pendingBytes}B undeliverable (budget ${lane.laneBufferMaxBytes}B), closing slow-link (${String(err)})`, + ); + this.die(lane, "slow-link"); + } + } + } + + /* ---------------------------------------------------------------- */ + /* lifecycle */ + /* ---------------------------------------------------------------- */ + + /** Activity on EITHER leg re-arms the idle timer; connect counts as activity too. */ + private touch(lane: Lane): void { + this.disarm(lane, "idle"); + this.arm(lane, "idle"); + } + + private arm(lane: Lane, which: "idle" | "lifetime"): void { + // Both budgets die with the same word: the closed set has no lane-only + // lifetime reason, and the plane's UI maps `idle` to "the lane stopped". + lane.timers[which] = lane.timer.setTimeout( + () => this.die(lane, "idle"), + which === "idle" ? lane.idleMs : lane.lifetimeMs, + ); + } + + private disarm(lane: Lane, which: "idle" | "lifetime"): void { + const handle = lane.timers[which]; + if (handle !== undefined) { + lane.timer.clearTimeout(handle); + lane.timers[which] = undefined; + } + } + + /** The ONE death path: deregister, disarm, destroy, then exactly one event. */ + private die(lane: Lane, reason: PreviewCloseReason): void { + if (this.lanes.get(lane.ref) !== lane) return; + this.remove(lane); + lane.say(`preview lane ${lane.ref}: closing (${reason})`); + try { + lane.hooks.emit({ type: "preview_closed", ref: lane.ref, reason }); + } catch (err: unknown) { + // Expected exactly once: the link-drop sweep closes lanes whose link is + // already gone. The plane reads that link drop itself; the event is the + // courtesy, and the agent log keeps the record. + log(`preview lane ${lane.ref}: preview_closed (${reason}) undeliverable: ${String(err)}`); + } + } + + /** Deregister first, then release the lane's holds (timers, socket). */ + private remove(lane: Lane): void { + this.lanes.delete(lane.ref); + this.disarm(lane, "idle"); + this.disarm(lane, "lifetime"); + try { + lane.socket?.destroy(); + } catch { + /* already gone */ + } + } +} diff --git a/apps/node/agent/src/relay-send.ts b/apps/node/agent/src/relay-send.ts index 2edc7ac2d..e64858ee8 100644 --- a/apps/node/agent/src/relay-send.ts +++ b/apps/node/agent/src/relay-send.ts @@ -1,10 +1,15 @@ import { NODE_MAX_FRAME_BYTES, type RelayFrame } from "@internal/subshell-protocol"; import { binaryPayload } from "./link-crypto.js"; +import type { PreviewLaneEvent } from "./preview/forward.js"; /** - * The daemon's relay pump, factored out of `daemon.ts` so the + * The daemon's byte-lane pump, factored out of `daemon.ts` so the * deliver-or-throw contract is testable without a socket (Task 8 acceptance * (c), the node-side mirror of the plane's `sendRelayFrameOverNodeSocket`). + * Since pane-preview Task 5 the SAME pump carries the preview lanes' events + * (`preview_data` out, `preview_closed`): the lane's slow-link accounting + * reads this throw, and one sealed path for every lane frame is what keeps + * the two byte legs' delivery promises identical by construction. * * `send` in the daemon LOGS a dropped event; a relay frame may not be dropped * quietly. The proxy parks a phantom request on a send it believes delivered @@ -35,8 +40,8 @@ export interface RelaySendState { * protocol-18 socket has no plaintext path at all), an oversize envelope, or * a failing write. On success the socket received a Buffer of sealed bytes. */ -export function createDaemonRelaySend(getState: () => RelaySendState): (frame: RelayFrame) => void { - return (frame: RelayFrame): void => { +export function createDaemonRelaySend(getState: () => RelaySendState): (frame: RelayFrame | PreviewLaneEvent) => void { + return (frame: RelayFrame | PreviewLaneEvent): void => { const { ws, link } = getState(); if (!ws) throw new Error("relay send refused: no live socket"); const session = link?.established() ? link.session() : undefined; From 10e225c98b8ce37525dde212f12f79a26ddeb53c Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 21:26:44 -0700 Subject: [PATCH 034/139] fix(node): guard lane death on socket identity so the ::1 retry survives its v4 close --- .../src/__tests__/preview-forward.test.ts | 98 ++++++++++++++++++- .../agent/src/commands/preview-open-exec.ts | 5 +- apps/node/agent/src/preview/forward.ts | 26 ++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/node/agent/src/__tests__/preview-forward.test.ts b/apps/node/agent/src/__tests__/preview-forward.test.ts index bc3bae5fe..194677acb 100644 --- a/apps/node/agent/src/__tests__/preview-forward.test.ts +++ b/apps/node/agent/src/__tests__/preview-forward.test.ts @@ -196,6 +196,22 @@ async function waitUntil(cond: () => boolean, what: string, ms = 3000): Promise< } } +/** True when this host can bind an IPv6 loopback listener (probed once at collect). */ +const HAS_V6_LOOPBACK = await (async (): Promise => { + const probe = createServer(); + try { + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "::1", () => resolve()); + }); + return true; + } catch { + return false; + } finally { + probe.close(); + } +})(); + const closeEvents = (events: PreviewLaneEvent[]): { ref: string; reason: PreviewCloseReason }[] => events .filter((e): e is Extract => e.type === "preview_closed") @@ -235,11 +251,15 @@ describe("(a) pending ack, off-chain dial (the ssh-relay A-branch contract)", () expect(ping.kind).toBe("pong"); expect(h.attempts).toEqual([{ host: "127.0.0.1", port: 4321 }]); - // The eventual failure: v4 refused, the ONE v6 retry refused, and the - // plane is told `target-gone` exactly once. + // The eventual failure: v4 refused (a real connect failure is an `error` + // THEN a guaranteed `close`), the ONE v6 retry refused the same way, and + // the plane is told `target-gone` exactly once. h.sockets[0]?.fire("error", new Error("connect ECONNREFUSED 127.0.0.1:4321")); + h.sockets[0]?.fire("close"); expect(h.attempts.map((a) => a.host)).toEqual(["127.0.0.1", "::1"]); + expect(h.forward.laneCount()).toBe(1); // the dead v4 dial's close must not end the retry h.sockets[1]?.fire("error", new Error("connect ECONNREFUSED ::1:4321")); + h.sockets[1]?.fire("close"); expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "target-gone" }]); expect(h.forward.laneCount()).toBe(0); @@ -249,6 +269,78 @@ describe("(a) pending ack, off-chain dial (the ssh-relay A-branch contract)", () data: { ref: refFor(1), closed: false }, }); }); + + it("the v4 dial's close does not kill the v6 retry: error+close survives to a live ::1", () => { + // The MAJOR-1 posture (Task 5 review): a real socket whose connect fails + // emits `error` and then, on a nextTick, a guaranteed `close`. The close + // lands AFTER the synchronous retry is in flight, and it must not be read + // as the LANE dying (which would destroy the pending ::1 socket and emit + // target-gone before the v6 handshake, precisely on the one-family server + // the retry exists for). + const h = makeHarness("retry-survives"); + h.forward.openLane({ + ref: refFor(1), + port: 4321, + paneId: PANE, + lifetimeMs: 60_000, + hooks: { emit: (ev) => h.events.push(ev) }, + }); + h.sockets[0]?.fire("error", new Error("connect ECONNREFUSED 127.0.0.1:4321")); + expect(h.attempts.map((a) => a.host)).toEqual(["127.0.0.1", "::1"]); // retry already in flight + h.sockets[0]?.fire("close"); // the dead v4 socket's own death notice + expect(h.forward.laneCount()).toBe(1); // the lane lives + expect(closeEvents(h.events)).toHaveLength(0); // and reports nothing + expect(h.sockets[0]?.destroyed).toBe(true); // the superseded socket is released by the retry itself + expect(h.sockets[1]?.destroyed).toBe(false); // the in-flight ::1 dial survives + h.sockets[1]?.fire("connect"); + expect(h.forward.laneCount()).toBe(1); + expect(h.events).toHaveLength(0); + }); + + // The end-to-end twin, real net on both legs (case (b)'s habit): a server + // bound to ::1 ONLY, so the production dial must take its v4 refusal and + // land on v6. A host without IPv6 loopback cannot represent this; the skip + // says so out loud. The fake-sequence case above runs everywhere. + it.skipIf(!HAS_V6_LOOPBACK)("a v6-only loopback server is reached through the real retry", async () => { + const received: Buffer[] = []; + let connected = false; + const server = createServer((sock) => { + sock.on("data", (d) => { + received.push(Buffer.from(d)); + sock.write(d); + }); + }); + server.once("connection", () => { + connected = true; + }); + await new Promise((r) => server.listen(0, "::1", r)); + const port = (server.address() as AddressInfo).port; + const h = makeHarness("v6-only", { deps: { connect: undefined } }); // real net, fake clock + try { + const res = await dispatchCommand(h.ctx, openCmd({ port })); + expect(res.ok).toBe(true); + // The v4 refusal (error then close) must ride out; stop at whichever + // real event arrives first, so a pre-fix death reads as itself. + await waitUntil(() => connected || closeEvents(h.events).length > 0, "the v6 dial or a lane death"); + expect(closeEvents(h.events)).toHaveLength(0); + expect(connected).toBe(true); + expect(h.forward.laneCount()).toBe(1); + await new Promise((r) => setTimeout(r, 50)); // let any late v4 socket close land + expect(h.forward.laneCount()).toBe(1); + expect(closeEvents(h.events)).toHaveLength(0); + h.forward.ingestInboundFrame({ + type: "preview_data", + ref: refFor(1), + dir: "in", + blob: Buffer.from("ping").toString("base64"), + }); + await waitUntil(() => dataEvents(h.events).length >= 1, "the echoed bytes back on the v6 lane"); + expect(Buffer.concat(received).toString()).toBe("ping"); + } finally { + h.forward.closeAll("viewer-left"); + server.close(); + } + }); }); /* ------------------------------------------------------------------ */ @@ -414,7 +506,7 @@ describe("(e) idle and target death", () => { expect(g.forward.closeLane(refFor(2), "viewer-left")).toEqual({ closed: true }); g.sockets[0]?.fire("end"); // the late EOF finds no lane g.sockets[0]?.fire("close"); - g.clock.fire(250_000_000); // and no timer can resurrect it either + g.clock.fire(900_000); // the ARMED idle budget, at the ms FakeClock matches: no timer can resurrect it expect(g.events).toHaveLength(0); }); }); diff --git a/apps/node/agent/src/commands/preview-open-exec.ts b/apps/node/agent/src/commands/preview-open-exec.ts index f71300882..f94f28b4c 100644 --- a/apps/node/agent/src/commands/preview-open-exec.ts +++ b/apps/node/agent/src/commands/preview-open-exec.ts @@ -85,6 +85,9 @@ export async function execPreviewOpen( }); // The seam cast is the JSON-safety posture the sibling executors use // (`node-results.ts` owns the answer contract; a forward ack is a plain - // object by the plan's interface note). + // object by the plan's interface note). One edge worth naming: if the ref + // is already registered, `openLane` keeps the live lane and returns, so + // this ack then describes the already-live lane (a dial that started at an + // earlier open), not a dial that started here (forward.ts:142-144). return { ok: true, data: { ref: cmd.ref, pending: true } as unknown as JsonValue }; } diff --git a/apps/node/agent/src/preview/forward.ts b/apps/node/agent/src/preview/forward.ts index 77693e4a3..f8dbaf694 100644 --- a/apps/node/agent/src/preview/forward.ts +++ b/apps/node/agent/src/preview/forward.ts @@ -267,10 +267,19 @@ export class PreviewForward { if (this.lanes.get(lane.ref) !== lane) return; this.onSocketData(lane, chunk); }); - // A real target EOF arrives as `end` then `close`; the lane-presence - // guard in `die` collapses the pair into the single event spec §6 owes. - socket.on("end", () => this.die(lane, "target-gone")); - socket.on("close", () => this.die(lane, "target-gone")); + // A failed connect's `error` is followed on a nextTick by a guaranteed + // `close`, which can land after the retry has already taken `lane.socket`; + // without the SOCKET-identity guard that late close reads as the lane + // dying and kills the in-flight ::1 dial (Task 5 review, MAJOR-1). On a + // live lane the real EOF pair (`end` then `close`) still collapses to one + // event: `remove` destroys without clearing `lane.socket`, so the second + // handler passes identity and dies inside `die`'s lane-presence guard. + socket.on("end", () => { + if (lane.socket === socket) this.die(lane, "target-gone"); + }); + socket.on("close", () => { + if (lane.socket === socket) this.die(lane, "target-gone"); + }); socket.on("error", (err) => { if (this.lanes.get(lane.ref) !== lane) return; if (!up) this.dialFailed(lane, v6, err); @@ -286,6 +295,15 @@ export class PreviewForward { // v4 and v6 are the same host, and a server bound to only one family is // routine. The lane's budget timers, not a dial timeout, bound the total. lane.say(`preview lane ${lane.ref}: 127.0.0.1:${lane.port} refused (${String(err)}), retrying ::1`); + // Release the refused socket before the retry overwrites `lane.socket`: + // its guaranteed `close` would otherwise find the lane live (the retry + // is in flight) and `remove` would destroy the ::1 dial through the + // stale reference (Task 5 review, MAJOR-1). + try { + lane.socket?.destroy(); + } catch { + /* already gone */ + } this.dial(lane, true); return; } From 87ae875b2432c8c433f4904a5603b044297947f2 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 21:57:23 -0700 Subject: [PATCH 035/139] feat(node): CDP screencast bridge, always-ack with dropping backpressure --- .../src/__tests__/preview-cdp-bridge.test.ts | 858 ++++++++++++++++++ apps/node/agent/src/commands/context.ts | 9 +- .../agent/src/commands/preview-close-exec.ts | 27 +- .../agent/src/commands/preview-open-exec.ts | 140 ++- apps/node/agent/src/daemon.ts | 17 +- apps/node/agent/src/preview/cdp-bridge.ts | 482 ++++++++++ apps/node/agent/src/preview/forward.ts | 42 +- 7 files changed, 1512 insertions(+), 63 deletions(-) create mode 100644 apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts create mode 100644 apps/node/agent/src/preview/cdp-bridge.ts diff --git a/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts b/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts new file mode 100644 index 000000000..b7c3ca019 --- /dev/null +++ b/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts @@ -0,0 +1,858 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_SESSIONS_PER_NODE, + PREVIEW_WATCH_FPS_DEFAULT, + PREVIEW_WATCH_FRAMES_BUFFERED, + PREVIEW_WATCH_JPEG_QUALITY, + type PreviewOpenCommand, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, +} from "@internal/subshell-protocol"; +import type { Server, ServerWebSocket } from "bun"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { + execPreviewOpen, + PREVIEW_LANE_OVER_NODE_CAP, + PREVIEW_NOT_CDP, + PREVIEW_PANE_GONE, + PREVIEW_WATCH_OVER_NODE_CAP, +} from "../commands/preview-open-exec.js"; +import { PREVIEW_NODE_OFF } from "../commands/preview-ports-exec.js"; +import { + BRIDGE_HANDSHAKE_BUDGET_MS, + type BridgeDeps, + type BridgeWs, + bridgeCloseAll, + bridgeCount, + clampFps, + type PreviewClosedEvent, + type PreviewStreamEvent, + startBridge, + stopBridge, +} from "../preview/cdp-bridge.js"; +import { setPreviewEnabled } from "../preview/flags.js"; +import { type LaneSocket, PreviewForward, type PreviewLaneEvent, setActivePreviewForward } from "../preview/forward.js"; + +/** + * The CDP screencast bridge (pane-preview spec 2026-10-21 §5, Task 6): the + * watch bridge behind `preview_open`'s `screencast` branch, the always-ack / + * dropping-backpressure emission contract, and the SHARED node budget Task 6 + * adds to both open branches. + * + * The fake CDP is a real in-proc `Bun.serve` on a real loopback port: it + * answers `/json/list`, records every message the bridge sends, and pushes + * `Page.screencastFrame` notifications on the upgraded socket. Transport is + * therefore real (global `fetch`, global `WebSocket`, a genuine mid-stream + * close); only the clocks are fake (the `now`/`timer` seam) so the fps + * throttle, the handshake budget, and the lifetime fire by hand instead of + * by wall time. + */ + +const STAMP = "2026-10-21T10:00:00.000Z"; +const PANES = [ + "11111111-2222-4333-8444-555555555555", + "22222222-2222-4333-8444-555555555555", + "33333333-2222-4333-8444-555555555555", + "44444444-2222-4333-8444-555555555555", + "55555555-2222-4333-8444-555555555555", +] as const; +const PANE = PANES[0]; + +/** 32 lowercase hex, valid against PREVIEW_REF_RE, unique per sequence number. */ +const refFor = (n: number): string => n.toString(16).padStart(32, "0"); + +let base: string; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-preview-bridge-"))); +}); + +afterAll(() => { + rmSync(base, { recursive: true, force: true }); +}); + +/* ------------------------------------------------------------------ */ +/* the fake CDP: real Bun.serve, /json/list + a recording ws */ +/* ------------------------------------------------------------------ */ + +interface FakeCdpOpts { + /** HTTP status for /json/list (default 200). */ + listStatus?: number; + /** The targets array answered on /json/list. */ + targets?: () => unknown; +} + +interface WsData { + messages: Record[]; +} + +class FakeCdp { + readonly server: Server; + readonly sockets: ServerWebSocket[] = []; + listRequests = 0; + + constructor(opts: FakeCdpOpts = {}) { + this.server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === "/json/list") { + this.listRequests += 1; + if (opts.listStatus && opts.listStatus !== 200) { + return new Response(JSON.stringify({ message: "Not found" }), { status: opts.listStatus }); + } + const targets = + opts.targets ?? (() => [{ type: "page", url: "about:blank", webSocketDebuggerUrl: this.wsUrl("T1") }]); + return Response.json(targets()); + } + if (path.startsWith("/devtools/") && srv.upgrade(req, { data: { messages: [] } })) { + return undefined as unknown as Response; + } + return new Response("nope", { status: 404 }); + }, + websocket: { + open: (ws) => { + this.sockets.push(ws); + }, + message: (ws, msg) => { + if (typeof msg === "string") ws.data.messages.push(JSON.parse(msg) as Record); + }, + }, + }); + } + + /** Bun's port is optional on the type; a started fake always has one. */ + port(): number { + const p = this.server.port; + if (typeof p !== "number") throw new Error("fake CDP has no bound port"); + return p; + } + + wsUrl(id: string): string { + return `ws://127.0.0.1:${this.port()}/devtools/page/${id}`; + } + + /** Every message the bridge has sent across every upgraded socket, in arrival order per socket. */ + messages(): Record[] { + return this.sockets.flatMap((ws) => ws.data.messages); + } + + acks(): { sessionId: unknown }[] { + return this.messages() + .filter((m) => m.method === "Page.screencastFrameAck") + .map((m) => (m.params ?? {}) as { sessionId: unknown }); + } + + /** Push one `Page.screencastFrame` notification on the most recent socket. */ + pushFrame(opts: { data: string; w?: number; h?: number; sessionId?: string } = { data: "AA==" }): void { + const ws = this.sockets[this.sockets.length - 1]; + if (!ws) throw new Error("fake CDP has no upgraded socket to push on"); + ws.send( + JSON.stringify({ + method: "Page.screencastFrame", + params: { + data: opts.data, + metadata: { deviceWidth: opts.w ?? 800, deviceHeight: opts.h ?? 600 }, + sessionId: opts.sessionId ?? "sess-1", + }, + }), + ); + } + + closeLastSocket(): void { + const ws = this.sockets[this.sockets.length - 1]; + if (!ws) throw new Error("fake CDP has no upgraded socket to close"); + ws.close(); + } + + stop(): void { + this.server.stop(true); + } +} + +/* ------------------------------------------------------------------ */ +/* clocks and harnesses */ +/* ------------------------------------------------------------------ */ + +type Timer = { cb: () => void; ms: number; cancelled: boolean; fired: boolean }; + +class FakeClock { + timers: Timer[] = []; + + setTimeout(cb: () => void, ms: number): unknown { + const t: Timer = { cb, ms, cancelled: false, fired: false }; + this.timers.push(t); + return t; + } + + clearTimeout(handle: unknown): void { + if (typeof handle === "object" && handle !== null) (handle as Timer).cancelled = true; + } + + /** Run every live timer scheduled at exactly `ms`; returns how many fired. */ + fire(ms: number): number { + let n = 0; + for (const t of this.timers) { + if (!t.cancelled && !t.fired && t.ms === ms) { + t.fired = true; + n += 1; + t.cb(); + } + } + return n; + } + + liveAt(ms: number): number { + return this.timers.filter((t) => !t.cancelled && !t.fired && t.ms === ms).length; + } +} + +/** A fake clock + counter: the bridge's `now` seam, advanced by the test. */ +function makeNow(start = 1000): { now: () => number; set: (t: number) => void; advance: (d: number) => void } { + let t = start; + return { now: () => t, set: (v) => (t = v), advance: (d) => (t += d) }; +} + +class FakeSocket { + private handlers = new Map void)[]>(); + destroyed = false; + + on(event: string, listener: (arg?: unknown) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(listener); + this.handlers.set(event, list); + return this; + } + + fire(event: string, arg?: unknown): void { + for (const l of this.handlers.get(event) ?? []) l(arg); + } + + write(_chunk: Uint8Array | string): boolean { + return true; + } + + destroy(): void { + this.destroyed = true; + } +} + +function makeHarness( + tag: string, + opts: { previewOn?: boolean; panePid?: number | null } = {}, +): { ctx: CommandContext; forward: PreviewForward; events: PreviewLaneEvent[]; sockets: FakeSocket[] } { + const dataDir = join(base, tag, "data"); + mkdirSync(dataDir, { recursive: true }); + if (opts.previewOn ?? true) setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + const events: PreviewLaneEvent[] = []; + const sockets: FakeSocket[] = []; + const forward = new PreviewForward({ + connect: () => { + const s = new FakeSocket(); + sockets.push(s); + return s as unknown as LaneSocket; + }, + timer: new FakeClock(), + }); + const ctx = { + config: { dataDir, nodeId: "node-self" }, + meta: { get: async () => undefined }, + tmux: { panePid: async () => (opts.panePid === undefined ? 100 : opts.panePid) }, + previewForward: forward, + sendPreviewEvent: (ev: PreviewLaneEvent) => { + events.push(ev); + }, + } as unknown as CommandContext; + return { ctx, forward, events, sockets }; +} + +function openCmd(over: Partial = {}): PreviewOpenCommand { + return { + type: "preview_open", + ref: refFor(1), + kind: "screencast", + paneId: PANE, + port: 0, + lifetimeMs: 60_000, + ...over, + }; +} + +/** Poll until `cond`, then return. */ +async function waitUntil(cond: () => boolean, what: string, ms = 3000): Promise { + const until = Date.now() + ms; + while (!cond()) { + if (Date.now() > until) throw new Error(`timed out: ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + +/** Start one bridge against a live FakeCdp with production transport and fake clocks. */ +async function startTestBridge( + cdp: FakeCdp, + ref: string, + hooks: { + emitStream: (ev: PreviewStreamEvent) => undefined | Promise; + emitClosed: (ev: PreviewClosedEvent) => void; + deps?: BridgeDeps; + }, + over: { fps?: number; lifetimeMs?: number } = {}, +): Promise { + const clock = new FakeClock(); + await startBridge({ + ref, + cdpPort: cdp.port(), + fps: over.fps ?? PREVIEW_WATCH_FPS_DEFAULT, // the helper stands for the branch's default + lifetimeMs: over.lifetimeMs ?? 60_000, + emitStream: hooks.emitStream, + emitClosed: hooks.emitClosed, + deps: hooks.deps ?? { timer: clock }, + }); +} + +/** The frame data: a tiny base64 JPEG stand-in (nonempty, strict base64). */ +const SMALL_BLOB = Buffer.from("jpeg-bytes").toString("base64"); +const BIG_BLOB = Buffer.alloc(PREVIEW_FRAME_MAX_BYTES + 1, 7).toString("base64"); + +/* ------------------------------------------------------------------ */ +/* (a) the handshake: what goes out, what the open answers */ +/* ------------------------------------------------------------------ */ + +describe("(a) the CDP handshake", () => { + it("after Page.enable it starts the screencast at quality 60 / everyNthFrame 1, and the open acks subscribers: 1", async () => { + const cdp = new FakeCdp(); + const h = makeHarness("handshake"); + try { + const res = await execPreviewOpen(h.ctx, openCmd({ port: cdp.port(), ref: refFor(1) })); + expect(res).toEqual({ ok: true, data: { ref: refFor(1), subscribers: 1 } }); + // Task 1's validator accepts the screencast result arm. + expect(parseNodePreviewOpenResult(res.ok === true ? res.data : null)).toEqual({ + ref: refFor(1), + subscribers: 1, + }); + expect(bridgeCount()).toBe(1); + await waitUntil(() => cdp.messages().length >= 2, "Page.enable and Page.startScreencast to arrive"); + const [enable, start] = cdp.messages(); + expect(enable?.method).toBe("Page.enable"); + expect(start?.method).toBe("Page.startScreencast"); + expect(start?.params).toEqual({ format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (b) always-ack, dropping throttle */ +/* ------------------------------------------------------------------ */ + +describe("(b) the ack is unconditional, the emission is throttled", () => { + it("three frames in one tick: at most one emission, three acks with the sessionId echoed", async () => { + const cdp = new FakeCdp(); + const streams: PreviewStreamEvent[] = []; + const clock = new FakeClock(); + const t = makeNow(); + try { + await startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: (ev) => { + streams.push(ev); + }, + emitClosed: () => {}, + deps: { timer: clock, now: t.now }, + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + cdp.pushFrame({ data: SMALL_BLOB, sessionId: "sess-A" }); + cdp.pushFrame({ data: SMALL_BLOB, sessionId: "sess-B" }); + cdp.pushFrame({ data: SMALL_BLOB, sessionId: "sess-C" }); + await waitUntil(() => cdp.acks().length === 3, "three screencastFrameAcks"); + // Every capture got its immediate ack, whatever the emitter did with it. + expect(cdp.acks().map((a) => a.sessionId)).toEqual(["sess-A", "sess-B", "sess-C"]); + // One tick, one emission: the extras are DROPPED at the emit boundary. + expect(streams).toHaveLength(1); + expect(streams[0]).toEqual({ + type: "preview_stream", + ref: refFor(1), + seq: 1, + w: 800, + h: 600, + ts: t.now(), + blob: SMALL_BLOB, + }); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("fps absent defaults to 5: one frame per 200 ms of the fake clock", async () => { + const cdp = new FakeCdp(); + const clock = new FakeClock(); + const t = makeNow(); + const h = makeHarness("fps-default"); + try { + // The branch, not the wire, owns the default (node-frames.ts rebuilds + // the command without `fps` and leaves the defaulting to Task 6). The + // executor wires the bridge to the context's sender, so the emissions + // land in the harness recorder. + await execPreviewOpen(h.ctx, openCmd({ port: cdp.port(), ref: refFor(1) }), undefined, { + timer: clock, + now: t.now, + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + const streams = (): PreviewStreamEvent[] => + h.events.filter((e): e is PreviewStreamEvent => e.type === "preview_stream"); + cdp.pushFrame({ data: SMALL_BLOB }); + await waitUntil(() => cdp.acks().length === 1, "the first ack"); + t.advance(100); + cdp.pushFrame({ data: SMALL_BLOB }); + await waitUntil(() => cdp.acks().length === 2, "the second ack"); + t.advance(100); // 200 ms total: the default-5 interval (ceil(1000/5)) + cdp.pushFrame({ data: SMALL_BLOB }); + await waitUntil(() => cdp.acks().length === 3, "the third ack"); + await waitUntil(() => streams().length >= 2, "the resumed emission"); + expect(streams()).toHaveLength(2); // frames at 0 and 200 ms pass; 100 ms drops + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("clampFps bounds the rate to [1, 5] and keeps the default", () => { + expect(clampFps(0)).toBe(1); + expect(clampFps(15)).toBe(5); + expect(clampFps(PREVIEW_WATCH_FPS_DEFAULT)).toBe(5); + }); +}); + +/* ------------------------------------------------------------------ */ +/* (c) not-cdp: everything the handshake distrusts */ +/* ------------------------------------------------------------------ */ + +describe("(c) not-cdp", () => { + it("a webSocketDebuggerUrl on a non-loopback host is refused without a dial", async () => { + const cdp = new FakeCdp({ + targets: () => [{ type: "page", webSocketDebuggerUrl: "ws://9.9.9.9:9222/devtools/page/T1" }], + }); + try { + await expect( + startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: () => {}, + emitClosed: () => {}, + }), + ).rejects.toThrow("not-cdp"); + expect(bridgeCount()).toBe(0); + // The URL came from /proc's port, answered by a socket: distrusted, never dialed. + expect(cdp.sockets).toHaveLength(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("a /json/list that 404s makes the open answer the not-cdp refusal, with no bridge left behind", async () => { + const cdp = new FakeCdp({ listStatus: 404 }); + const h = makeHarness("not-cdp-404"); + try { + const res = await execPreviewOpen(h.ctx, openCmd({ port: cdp.port(), ref: refFor(1) })); + expect(res).toEqual({ ok: false, error: PREVIEW_NOT_CDP }); + expect(bridgeCount()).toBe(0); + expect(h.events).toHaveLength(0); // the plane never held a ref: no close event to volunteer + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("a websocket that never opens dies on the 3 s handshake budget with not-cdp", async () => { + const cdp = new FakeCdp(); + const clock = new FakeClock(); + let hung: { closed: boolean } | undefined; + class HangingWs { + onopen: ((ev: unknown) => void) | null = null; + onerror: ((err: unknown) => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((msg: { data: string | Buffer }) => void) | null = null; + closed = false; + constructor() { + hung = this; + } + send(_data: string): void {} + close(): void { + this.closed = true; + } + } + try { + const starting = startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: () => {}, + emitClosed: () => {}, + deps: { timer: clock, WebSocketImpl: HangingWs as unknown as new (url: string) => BridgeWs }, + }); + await waitUntil(() => hung !== undefined, "the websocket dial to start"); + expect(clock.fire(BRIDGE_HANDSHAKE_BUDGET_MS)).toBe(1); + await expect(starting).rejects.toThrow("not-cdp"); + expect(bridgeCount()).toBe(0); + expect(hung?.closed).toBe(true); // the hung socket is released, no half-started state + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (d) bridge deaths */ +/* ------------------------------------------------------------------ */ + +describe("(d) target death and lifetime", () => { + it("a CDP socket close mid-stream emits preview_closed target-gone exactly once", async () => { + const cdp = new FakeCdp(); + const closed: PreviewClosedEvent[] = []; + try { + await startTestBridge(cdp, refFor(1), { + emitStream: () => {}, + emitClosed: (ev) => closed.push(ev), + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + cdp.closeLastSocket(); + await waitUntil(() => closed.length >= 1, "the target-gone notice"); + await new Promise((r) => setTimeout(r, 20)); // the close/error pair collapses to one + expect(closed).toEqual([{ type: "preview_closed", ref: refFor(1), reason: "target-gone" }]); + expect(bridgeCount()).toBe(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("lifetime expiry emits preview_closed idle exactly once", async () => { + const cdp = new FakeCdp(); + const closed: PreviewClosedEvent[] = []; + const clock = new FakeClock(); + try { + await startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 40, // the seam exists to be small; the wire clamps to >= 60 s + emitStream: () => {}, + emitClosed: (ev) => closed.push(ev), + deps: { timer: clock }, + }); + expect(clock.fire(40)).toBe(1); + expect(closed).toEqual([{ type: "preview_closed", ref: refFor(1), reason: "idle" }]); + clock.fire(40); // a second fire of a dead timer changes nothing + expect(closed).toHaveLength(1); + expect(bridgeCount()).toBe(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("a target that sends NO frames stays alive past the old 60 s traffic window, with no close", async () => { + const cdp = new FakeCdp(); + const closed: PreviewClosedEvent[] = []; + const clock = new FakeClock(); + try { + await startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 3_600_000, + emitStream: () => {}, + emitClosed: (ev) => closed.push(ev), + deps: { timer: clock }, + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + // The anti-test for traffic-idle: a static page repaints nothing and + // keeps its watch. Zero frames, zero closes, and no timer armed at the + // old 60 s traffic window (the handshake budget is discharged too). + expect(clock.liveAt(60_000)).toBe(0); + expect(clock.liveAt(BRIDGE_HANDSHAKE_BUDGET_MS)).toBe(0); + expect(clock.liveAt(3_600_000)).toBe(1); // only the lifetime remains + expect(closed).toHaveLength(0); + expect(bridgeCount()).toBe(1); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (e) the emit boundary: size cap and drainable buffer cap */ +/* ------------------------------------------------------------------ */ + +describe("(e) the emit boundary", () => { + it("a blob over PREVIEW_FRAME_MAX_BYTES is dropped (acked, bridge alive) and the next frame still emits", async () => { + const cdp = new FakeCdp(); + const streams: PreviewStreamEvent[] = []; + const clock = new FakeClock(); + const t = makeNow(); + try { + await startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: (ev) => { + streams.push(ev); + }, + emitClosed: () => {}, + deps: { timer: clock, now: t.now }, + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + cdp.pushFrame({ data: BIG_BLOB, sessionId: "sess-BIG" }); + cdp.pushFrame({ data: SMALL_BLOB, sessionId: "sess-SMALL" }); + await waitUntil(() => cdp.acks().length === 2, "both acks (the ack contract is unconditional)"); + // The plane's parser refuses an over-cap blob; an emitting bridge would + // corrupt the stream silently, so it is dropped at the boundary instead. + await waitUntil(() => streams.length >= 1, "the within-cap frame"); + expect(streams).toHaveLength(1); + expect(streams[0]?.blob).toBe(SMALL_BLOB); + expect(bridgeCount()).toBe(1); // a dropped frame does not close the bridge + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("the buffered cap is DRAINABLE: a stalled emitStream drops past-cap frames, and emission resumes after the stall", async () => { + const cdp = new FakeCdp(); + const streams: PreviewStreamEvent[] = []; + const clock = new FakeClock(); + const t = makeNow(); + let release: () => void = () => {}; + const stalled = new Promise((r) => (release = r)); + let first = true; + try { + await startBridge({ + ref: refFor(1), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: (ev) => { + streams.push(ev); + if (first) { + first = false; + return stalled; // the pump is "behind": the queue fills behind this + } + return undefined; + }, + emitClosed: () => {}, + deps: { timer: clock, now: t.now }, + }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + // Six frames, one per throttle window (spaced so only the queue cap can + // drop them). The first is held by the stall; the registry may hold at + // most PREVIEW_WATCH_FRAMES_BUFFERED, so exactly one is dropped. + for (let i = 0; i < 6; i += 1) { + if (i > 0) t.advance(200); + cdp.pushFrame({ data: SMALL_BLOB }); + await waitUntil(() => cdp.acks().length === i + 1, `ack ${i + 1}`); + } + expect(bridgeCount()).toBe(1); // drops never close the bridge + expect(streams).toHaveLength(1); // the stalled emission is the only attempt so far + release(); + await waitUntil(() => streams.length === PREVIEW_WATCH_FRAMES_BUFFERED, "the queue to drain"); + expect(streams).toHaveLength(PREVIEW_WATCH_FRAMES_BUFFERED); + // The counter-without-decrement design would go permanently dark here; + // this resume is the assertion that tells the two designs apart. + t.advance(200); + cdp.pushFrame({ data: SMALL_BLOB }); + await waitUntil(() => cdp.acks().length === 7, "the seventh ack"); + await waitUntil(() => streams.length === PREVIEW_WATCH_FRAMES_BUFFERED + 1, "emission to resume"); + expect(streams.map((s) => s.seq)).toEqual([1, 2, 3, 4, 5, 6]); + expect(bridgeCount()).toBe(1); + } finally { + release(); + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (f) the gate ladder on the screencast branch */ +/* ------------------------------------------------------------------ */ + +describe("(f) the gate ladder, screencast twin", () => { + it("an off switch refuses preview-node-off BEFORE the CDP handshake", async () => { + const cdp = new FakeCdp(); + const h = makeHarness("gate-off", { previewOn: false }); + try { + const res = await execPreviewOpen(h.ctx, openCmd({ port: cdp.port() })); + expect(res).toEqual({ ok: false, error: PREVIEW_NODE_OFF }); + expect(cdp.listRequests).toBe(0); // not even /json/list was asked + expect(bridgeCount()).toBe(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("an absent pane refuses preview-pane-gone with zero requests reaching the CDP server", async () => { + const cdp = new FakeCdp(); + const h = makeHarness("pane-gone", { panePid: null }); + try { + const res = await execPreviewOpen(h.ctx, openCmd({ port: cdp.port() })); + expect(res).toEqual({ ok: false, error: PREVIEW_PANE_GONE }); + expect(cdp.listRequests).toBe(0); + expect(bridgeCount()).toBe(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (g) the close fall-through and the sweep */ +/* ------------------------------------------------------------------ */ + +describe("(g) preview_close falls forward to the bridge", () => { + it("a bridge ref stops exactly once (forward miss -> bridge hit); an unknown ref keeps the tombstone", async () => { + const cdp = new FakeCdp(); + const h = makeHarness("close-fallthrough"); + try { + await startTestBridge(cdp, refFor(1), { emitStream: () => {}, emitClosed: (ev) => h.events.push(ev) }); + await waitUntil(() => cdp.messages().length >= 2, "the screencast to start"); + const res = await dispatchCommand(h.ctx, { type: "preview_close", ref: refFor(1), reason: "viewer-left" }); + expect(res).toEqual({ ok: true, data: { ref: refFor(1), closed: true } }); + expect(parseNodePreviewCloseResult(res.ok === true ? res.data : null)).toEqual({ + ref: refFor(1), + closed: true, + }); + expect(bridgeCount()).toBe(0); + expect(h.events).toHaveLength(0); // the plane ordered this death; it is not volunteered + // The tombstone twins: a second close, and a ref neither registry owns. + expect(await dispatchCommand(h.ctx, { type: "preview_close", ref: refFor(1), reason: "viewer-left" })).toEqual({ + ok: true, + data: { ref: refFor(1), closed: false }, + }); + expect(await dispatchCommand(h.ctx, { type: "preview_close", ref: refFor(9), reason: "viewer-left" })).toEqual({ + ok: true, + data: { ref: refFor(9), closed: false }, + }); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("stopBridge answers false for a ref the bridge registry does not own", () => { + expect(stopBridge(refFor(42), "viewer-left")).toBe(false); + }); + + it("bridgeCloseAll stops every bridge with exactly one emission each", async () => { + const cdp = new FakeCdp(); + const closed: PreviewClosedEvent[] = []; + try { + await startTestBridge(cdp, refFor(1), { emitStream: () => {}, emitClosed: (ev) => closed.push(ev) }); + await waitUntil(() => cdp.sockets.length === 1, "the first socket"); + await startTestBridge(cdp, refFor(2), { emitStream: () => {}, emitClosed: (ev) => closed.push(ev) }); + await waitUntil(() => cdp.sockets.length === 2, "the second socket"); + expect(bridgeCount()).toBe(2); + bridgeCloseAll("node-dropped"); + expect(closed).toEqual([ + { type: "preview_closed", ref: refFor(1), reason: "node-dropped" }, + { type: "preview_closed", ref: refFor(2), reason: "node-dropped" }, + ]); + expect(bridgeCount()).toBe(0); + } finally { + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* (h) the SHARED node budget: one counter over both registries */ +/* ------------------------------------------------------------------ */ + +describe("(h) the shared node budget", () => { + it("15 lanes + 1 bridge fill the node; the NEXT bridge refuses watch-over-node-cap before the dial", async () => { + expect(PREVIEW_SESSIONS_PER_NODE).toBe(16); + const cdp = new FakeCdp(); + const h = makeHarness("shared-budget-bridge"); + setActivePreviewForward(h.forward); // forwardCount() now reads THIS registry + try { + // 15 lanes spread 4-per-pane (the per-pane cap is its own rung; this + // case is about the NODE sum, so the lanes must not trip it first). + for (let i = 1; i <= 15; i += 1) { + const paneId = PANES[Math.floor((i - 1) / 4) % PANES.length] ?? PANE; + expect((await dispatchCommand(h.ctx, { ...openCmd(), kind: "forward", ref: refFor(i), paneId })).ok).toBe(true); + } + expect(h.forward.laneCount()).toBe(15); + // The bridge that fills slot 16 starts (its CDP requests are the proof). + const before = cdp.listRequests; + const ok16 = await dispatchCommand(h.ctx, openCmd({ ref: refFor(16), port: cdp.port() })); + expect(ok16).toEqual({ ok: true, data: { ref: refFor(16), subscribers: 1 } }); + expect(cdp.listRequests).toBeGreaterThan(before); + expect(bridgeCount()).toBe(1); + // The 17th of any mix refuses BEFORE the handshake: zero new requests. + const mid = cdp.listRequests; + expect(await dispatchCommand(h.ctx, openCmd({ ref: refFor(17), port: cdp.port() }))).toEqual({ + ok: false, + error: PREVIEW_WATCH_OVER_NODE_CAP, + }); + expect(cdp.listRequests).toBe(mid); + expect(bridgeCount()).toBe(1); + } finally { + setActivePreviewForward(undefined); + h.forward.closeAll("viewer-left"); + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); + + it("15 bridges + 1 lane fill the node; the NEXT lane refuses lane-over-node-cap before its connect", async () => { + const cdp = new FakeCdp(); + const h = makeHarness("shared-budget-lane"); + try { + for (let i = 1; i <= 15; i += 1) { + await startBridge({ + ref: refFor(i), + cdpPort: cdp.port(), + fps: 5, + lifetimeMs: 60_000, + emitStream: () => {}, + emitClosed: () => {}, + }); + } + expect(bridgeCount()).toBe(15); + // The lane that fills slot 16 opens; its fake connect runs (pending). + expect((await dispatchCommand(h.ctx, { ...openCmd(), kind: "forward", ref: refFor(16) })).ok).toBe(true); + expect(h.sockets).toHaveLength(1); + // The next lane refuses BEFORE the dial: the connect count stays 1. + expect(await dispatchCommand(h.ctx, { ...openCmd(), kind: "forward", ref: refFor(17) })).toEqual({ + ok: false, + error: PREVIEW_LANE_OVER_NODE_CAP, + }); + expect(h.sockets).toHaveLength(1); + } finally { + h.forward.closeAll("viewer-left"); + bridgeCloseAll("viewer-left"); + cdp.stop(); + } + }); +}); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 5b7ef8735..a826ab466 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -141,11 +141,14 @@ export interface CommandContext { */ previewForward?: PreviewForward; /** - * Hand one preview lane event to the CURRENT established link. + * Hand one preview event to the CURRENT established link: a lane's byte + * frame, a bridge's screencast frame, or either one's death notice. * DELIVER-OR-THROW, and the SAME sender the relay frames ride * (`relay-send.ts`): the lane's slow-link accounting reads the throw — an - * event that silently returned would be a byte the plane believes left. - * Optional for the same fake-compiling reason as {@link previewForward}. + * event that silently returned would be a byte the plane believes left — + * and both registries must flow through the one path so that accounting and + * the link-drop sweep stay true for each. Optional for the same + * fake-compiling reason as {@link previewForward}. */ sendPreviewEvent?: (ev: PreviewLaneEvent) => void; /** diff --git a/apps/node/agent/src/commands/preview-close-exec.ts b/apps/node/agent/src/commands/preview-close-exec.ts index fe91afcc5..8c5b1e660 100644 --- a/apps/node/agent/src/commands/preview-close-exec.ts +++ b/apps/node/agent/src/commands/preview-close-exec.ts @@ -1,23 +1,28 @@ import type { JsonValue } from "@internal/subshell-protocol"; +import { stopBridge } from "../preview/cdp-bridge.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; /** - * Execute `preview_close` (pane-preview spec 2026-10-21 §4, Task 5): end the - * lane named by the ref. Deliberately NOT gated — the `set_ssh_enabled` and - * `ssh_relay_close` doctrine: a switch-off mid-session must still be able to - * end a live lane, because teardown is not a preview act, and refusing it - * would strand a dialed socket on a machine that has stopped serving - * previews. An unknown ref answers `closed: false` — a close racing the - * lane's own death is routine, and the false answer is the honest record, not - * an error (`forward.ts`'s synchronous registration means there is no - * detached-open race to tombstone; the ref exists from the ack onward). + * Execute `preview_close` (pane-preview spec 2026-10-21 §4, Tasks 5-6): end + * the lane or bridge named by the ref. Deliberately NOT gated — the + * `set_ssh_enabled` and `ssh_relay_close` doctrine: a switch-off mid-session + * must still be able to end a live session, because teardown is not a preview + * act, and refusing it would strand a dialed socket (or a live screencast) on + * a machine that has stopped serving previews. The plane does not tell the + * node which registry holds a ref, so the lookup falls through: the forward + * registry answers first, and a miss consults the bridge registry. An unknown + * ref answers `closed: false` — a close racing the session's own death is + * routine, and the false answer is the honest record, not an error (both + * registries register before the open acks, so there is no detached-open race + * to tombstone; the ref exists from the ack onward). */ export function execPreviewClose(ctx: CommandContext, cmd: Cmd<"preview_close">): CommandResult { const forward = ctx.previewForward; if (!forward) { - // The impossible-state guard again: no preview plumbing, no lanes to close. + // The impossible-state guard again: no preview plumbing, no sessions to close. return { ok: false, error: "preview plumbing unavailable in this daemon context" }; } const { closed } = forward.closeLane(cmd.ref, cmd.reason); - return { ok: true, data: { ref: cmd.ref, closed } as unknown as JsonValue }; + const stopped = closed ? false : stopBridge(cmd.ref, cmd.reason); + return { ok: true, data: { ref: cmd.ref, closed: closed || stopped } as unknown as JsonValue }; } diff --git a/apps/node/agent/src/commands/preview-open-exec.ts b/apps/node/agent/src/commands/preview-open-exec.ts index f94f28b4c..d3e930812 100644 --- a/apps/node/agent/src/commands/preview-open-exec.ts +++ b/apps/node/agent/src/commands/preview-open-exec.ts @@ -2,44 +2,54 @@ import { type JsonValue, PREVIEW_FORWARD_SESSIONS_PER_PANE, PREVIEW_SESSIONS_PER_NODE, + PREVIEW_WATCH_FPS_DEFAULT, } from "@internal/subshell-protocol"; +import { type BridgeDeps, bridgeCount, startBridge } from "../preview/cdp-bridge.js"; import { getPreviewEnabled } from "../preview/flags.js"; -import type { LaneDeps } from "../preview/forward.js"; +import { forwardCount, type LaneDeps } from "../preview/forward.js"; import { resolveSocket } from "./basics.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; /** - * The `preview_open` arm (pane-preview spec 2026-10-21 §4/§6, Task 5): the - * gate ladder a forward-lane request must walk, then the one call that starts - * the mechanism. The ladder's ORDER is its whole point — mirror switch, pane - * liveness, budgets — every refusal answers at the ladder, so a request that - * was never going to register starts no dial, no timer, and no lane. + * The `preview_open` arm (pane-preview spec 2026-10-21 §4/§6): the gate ladder + * a watch request must walk, then the one call that starts the mechanism. The + * ladder's ORDER is its whole point — mirror switch, pane liveness, the shared + * budget — every refusal answers at the ladder, so a request that was never + * going to register starts no dial, no timer, no lane, and no CDP handshake. * * - **The switch first**, like every preview arm: {@link PREVIEW_NODE_OFF} is * the shared voice, asked fail-closed before any pane or port is consulted. * - **The pane second**: the forward twin of `preview_ports_get`'s refusal. - * A dead pane's port can be rebound by a stranger process, and a lane - * dialed onto that is indistinguishable from dialing another pane's server, - * so liveness is law before registration, not a fact the dial trusts. - * - **The budgets last**: 4 lanes per pane, 16 per node (spec §6). At this - * task only forward lanes consume the counter; Task 6's bridges join the - * same numbers, which is why the rungs read - * {@link PreviewForward.laneCount} and never a private tally. + * A dead pane's port can be rebound by a stranger process, and a lane (or a + * screencast) dialed onto that is indistinguishable from watching another + * pane, so liveness is law before registration, not a fact the dial trusts. + * - **The budget last, and it is ONE number**: spec §6's "16 per node" is a + * sum over BOTH registries (sixteen of any mix), checked before either + * mechanism starts. The lane path consults {@link bridgeCount}, the bridge + * path {@link forwardCount}; a sum living only in one branch is how 32 + * total sessions come back, and a dial-then-refuse would leave a live + * connection behind for a request that was never going to register. Each + * refusal still names its own kind. The 4-per-pane cap stays lane-only: the + * screencast has no per-pane quota in the spec. * - * A passing ladder calls {@link PreviewForward.openLane}, which registers + * The forward branch calls {@link PreviewForward.openLane}, which registers * synchronously and dials OFF the command chain (the ssh-relay A-branch * posture, restated there): the ack says `pending: true`, and a dial that * fails later reaches the plane as one `preview_closed`, never a second - * answer to this command. The `screencast` kind is Task 6's branch; until it - * lands the arm answers the integration contract word `unsupported`, which is - * what the plane's compat logic already reads for a node that lacks a - * feature. + * answer to this command. The screencast branch AWAITS {@link startBridge}: + * the open answers `{ ref, subscribers: 1 }` only once the CDP handshake and + * `Page.startScreencast` have actually landed, and every handshake failure + * answers the {@link PREVIEW_NOT_CDP} refusal with no half-started state — + * the plane never holds a ref it has no live bridge behind. */ -/** The node holds its 16 concurrent lanes; a new open would be the 17th. */ +/** The node holds its 16 concurrent previews; a new LANE would be the 17th. */ export const PREVIEW_LANE_OVER_NODE_CAP = "preview-lane-over-node-cap"; +/** The node holds its 16 concurrent previews; a new BRIDGE would be the 17th. */ +export const PREVIEW_WATCH_OVER_NODE_CAP = "preview-watch-over-node-cap"; + /** This pane holds its 4 lanes; a new open would be its 5th. */ export const PREVIEW_LANE_OVER_PANE_CAP = "preview-lane-over-pane-cap"; @@ -47,20 +57,31 @@ export const PREVIEW_LANE_OVER_PANE_CAP = "preview-lane-over-pane-cap"; export const PREVIEW_PANE_GONE = "preview-pane-gone"; /** - * Execute `preview_open`: switch → pane → budgets → register, in that order. - * A malformed pane id refuses `invalid subshell id` through {@link + * The named port is not a Chrome DevTools endpoint this bridge can use: the + * list refused, the URL answered a non-loopback host, the socket died or + * never opened, or the handshake budget expired. Names the same word the + * `preview_closed` reason set carries, so the plane maps a refusal onto the + * copy it already maps the death onto. + */ +export const PREVIEW_NOT_CDP = "not-cdp"; + +/** + * Execute `preview_open`: switch → pane → shared budget → start, in that + * order. A malformed pane id refuses `invalid subshell id` through {@link * resolveSocket}'s throw, before tmux is asked anything — the same id gate - * every pane executor runs first. `deps` is the mechanics seam - * (`forward.ts`); production dispatch omits it and gets real net and real + * every pane executor runs first. `deps` is the lane mechanics seam + * (`forward.ts`) and `bridgeDeps` the bridge's (`cdp-bridge.ts`); production + * dispatch omits both and gets real net, real fetch/WebSocket, and real * (unref'd) timers. */ export async function execPreviewOpen( ctx: CommandContext, cmd: Cmd<"preview_open">, deps?: LaneDeps, + bridgeDeps?: BridgeDeps, ): Promise { if (getPreviewEnabled(ctx.config.dataDir)?.on !== true) return { ok: false, error: PREVIEW_NODE_OFF }; - if (cmd.kind !== "forward") return { ok: false, error: "unsupported" }; + if (cmd.kind !== "forward" && cmd.kind !== "screencast") return { ok: false, error: "unsupported" }; const forward = ctx.previewForward; const send = ctx.sendPreviewEvent; if (!forward || !send) { @@ -72,22 +93,57 @@ export async function execPreviewOpen( const socket = await resolveSocket(ctx, cmd.paneId); const panePid = await ctx.tmux.panePid(socket, cmd.paneId); if (panePid === null) return { ok: false, error: PREVIEW_PANE_GONE }; - if (forward.laneCount(cmd.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) - return { ok: false, error: PREVIEW_LANE_OVER_PANE_CAP }; - if (forward.laneCount() >= PREVIEW_SESSIONS_PER_NODE) return { ok: false, error: PREVIEW_LANE_OVER_NODE_CAP }; - forward.openLane({ - ref: cmd.ref, - port: cmd.port, - paneId: cmd.paneId, - lifetimeMs: cmd.lifetimeMs, - hooks: { emit: (ev) => send(ev) }, - deps, - }); - // The seam cast is the JSON-safety posture the sibling executors use - // (`node-results.ts` owns the answer contract; a forward ack is a plain - // object by the plan's interface note). One edge worth naming: if the ref - // is already registered, `openLane` keeps the live lane and returns, so - // this ack then describes the already-live lane (a dial that started at an - // earlier open), not a dial that started here (forward.ts:142-144). - return { ok: true, data: { ref: cmd.ref, pending: true } as unknown as JsonValue }; + if (cmd.kind === "forward") { + if (forward.laneCount(cmd.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) + return { ok: false, error: PREVIEW_LANE_OVER_PANE_CAP }; + if (forward.laneCount() + bridgeCount() >= PREVIEW_SESSIONS_PER_NODE) + return { ok: false, error: PREVIEW_LANE_OVER_NODE_CAP }; + forward.openLane({ + ref: cmd.ref, + port: cmd.port, + paneId: cmd.paneId, + lifetimeMs: cmd.lifetimeMs, + hooks: { emit: (ev) => send(ev) }, + deps, + }); + // The seam cast is the JSON-safety posture the sibling executors use + // (`node-results.ts` owns the answer contract; a forward ack is a plain + // object by the plan's interface note). One edge worth naming: if the ref + // is already registered, `openLane` keeps the live lane and returns, so + // this ack then describes the already-live lane (a dial that started at an + // earlier open), not a dial that started here (forward.ts:142-144). + return { ok: true, data: { ref: cmd.ref, pending: true } as unknown as JsonValue }; + } + // The screencast branch: the shared budget BEFORE the dial (a refusal must + // not leave a live CDP connection behind for a request that was never going + // to register), and the absent `fps` defaults HERE — the wire grammar + // rebuilds the command without it and leaves the defaulting to this arm. + if (forwardCount() + bridgeCount() >= PREVIEW_SESSIONS_PER_NODE) + return { ok: false, error: PREVIEW_WATCH_OVER_NODE_CAP }; + try { + await startBridge({ + ref: cmd.ref, + cdpPort: cmd.port, + fps: cmd.fps ?? PREVIEW_WATCH_FPS_DEFAULT, + lifetimeMs: cmd.lifetimeMs, + // Block bodies on purpose: the sender returns void, and the seam's + // accept-signal is `undefined` (a settled return means "accepted"). + emitStream: (ev) => { + send(ev); + }, + emitClosed: (ev) => { + send(ev); + }, + deps: bridgeDeps, + }); + } catch { + // Every handshake failure speaks `not-cdp` (`cdp-bridge.ts`): this port is + // not a DevTools endpoint a watch can be built on. The open refuses; no + // bridge is registered and no event is volunteered (the plane never held + // this ref). + return { ok: false, error: PREVIEW_NOT_CDP }; + } + // One subscriber on birth: the plane's viewer (Task 8). The validator in + // `node-results.ts` accepts this arm beside the lane's `pending: true`. + return { ok: true, data: { ref: cmd.ref, subscribers: 1 } as unknown as JsonValue }; } diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 268418d03..ba16fd943 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -44,7 +44,8 @@ import { binaryPayload, createLinkNegotiator, type LinkNegotiator, type LinkNego import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; -import { PreviewForward } from "./preview/forward.js"; +import { bridgeCloseAll } from "./preview/cdp-bridge.js"; +import { PreviewForward, setActivePreviewForward } from "./preview/forward.js"; import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; import { type NodeRoute, NodeRouteSelector } from "./route-selection.js"; @@ -520,6 +521,12 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // design; a raw TCP lane's peer is the proxy, which died with the socket). // Production mechanics are the defaults: real net, unref'd real timers. const previewForward = new PreviewForward(); + // Publish it as the node-wide count source the SHARED preview budget reads + // (`forwardCount()`), the same way `relaySessions` is this loop's registry: + // the bridge branch of `preview_open` counts lanes through the module handle + // because the sum is one number, not two habits. Cleared in the finally + // below, like the dashboard's restart hook. + setActivePreviewForward(previewForward); // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — @@ -1206,6 +1213,13 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // gone) and `forward.ts` keeps the log record. The plane learns of the // lanes' deaths from the dropped link itself. previewForward.closeAll("node-dropped"); + // The same rule for screencast bridges (pane-preview Task 6): the + // frames a bridge emits ride THIS link, and a continuous-repaint page + // would otherwise keep capturing and emitting forever on a machine + // whose plane link is gone. One `preview_closed` each, exactly as the + // lane sweep above; the pump throws for those too, and the bridge + // keeps the log record. + bridgeCloseAll("node-dropped"); resolve(close); }; // The per-socket link negotiator (spec 2026-09-24). It owns the wire @@ -1523,6 +1537,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom clearInterval(sshDirSweepTimer); if (retentionTimer !== undefined) clearInterval(retentionTimer); registerRestart(null); + setActivePreviewForward(undefined); setDaemonState({ connected: false }); process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); diff --git a/apps/node/agent/src/preview/cdp-bridge.ts b/apps/node/agent/src/preview/cdp-bridge.ts new file mode 100644 index 000000000..c4501a420 --- /dev/null +++ b/apps/node/agent/src/preview/cdp-bridge.ts @@ -0,0 +1,482 @@ +import { + type NodeEvent, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_WATCH_FPS_MAX, + PREVIEW_WATCH_FPS_MIN, + PREVIEW_WATCH_FRAMES_BUFFERED, + PREVIEW_WATCH_JPEG_QUALITY, + type PreviewCloseReason, +} from "@internal/subshell-protocol"; +import { log } from "../log.js"; +import { type LaneTimers, realTimers } from "./forward.js"; + +/** + * The node's CDP watch bridge (pane-preview spec 2026-10-21 §5, Task 6): the + * ref→bridge registry behind `preview_open`'s `screencast` branch. A bridge + * dials the pane's Chrome DevTools port on loopback, starts a JPEG + * screencast, and turns `Page.screencastFrame` notifications into + * `preview_stream` events for the plane's fan-out. + * + * Three contracts hold this module together, and each earned its shape: + * + * - **The ack is unconditional.** Every capture gets its + * `Page.screencastFrameAck` the moment it arrives, whatever the emitter + * later does with it. An unacked frame makes Chrome stop painting the + * screencast, so backpressure here may only ever cost bytes, never the + * capture pipeline. + * - **The rate reduction sits at the emit boundary** (decision 8, a recorded + * deviation from spec §4's pacing at the reduction SITE): capture every + * repaint with `everyNthFrame: 1` and drop the extras against + * `clampFps(fps)` here rather than sizing the CDP pacing formula there. One + * pin-able clamp replaces an untested formula, and a dropped frame costs no + * base64 and no plane-to-node bytes. + * - **Drop, never backpressure.** While `emitStream` is behind, the bridge + * holds at most {@link PREVIEW_WATCH_FRAMES_BUFFERED} frames and drops the + * rest; an over-cap blob is dropped too (the plane's parser refuses one, so + * an emitting bridge would corrupt the stream silently). And there is NO + * traffic-based self-close: a static page repaints nothing and must keep + * its watch. The spec's 60 s idle is zero-SUBSCRIBER idle, which is + * plane-side refcount state this process cannot see. Self-close is only + * lifetime expiry (`idle`) and target death (`target-gone`). + * + * The bridge dials loopback only and distrusts what it reads: the port came + * from the /proc walk (Task 4), but the `webSocketDebuggerUrl` is answered by + * a socket on that port, so a non-loopback host inside it is refused + * `not-cdp`, never followed. + */ + +/** One screencast frame on its way to the plane. */ +export type PreviewStreamEvent = Extract; +/** The bridge's own death notice, the same event the lanes volunteer. */ +export type PreviewClosedEvent = Extract; + +/** The handshake covers `/json/list` plus the websocket reaching open; this is its whole budget. */ +export const BRIDGE_HANDSHAKE_BUDGET_MS = 3000; + +/** The `http://127.0.0.1:/json/list` answer, narrowed to what a bridge reads. */ +interface BridgeFetchResponse { + ok: boolean; + status: number; + json(): Promise; +} + +/** One CDP target row; anything else in the array is a stranger's JSON and ignored. */ +interface CdpTarget { + type?: unknown; + webSocketDebuggerUrl?: unknown; +} + +/** + * The websocket seam. Production reaches the global `WebSocket` (bun ships + * one; no dependency); the seam exists so the handshake-budget case can hand + * a socket that never opens. + */ +export interface BridgeWs { + onopen: ((ev: unknown) => void) | null; + onerror: ((err: unknown) => void) | null; + onclose: (() => void) | null; + onmessage: ((msg: { data: string | Buffer }) => void) | null; + send(data: string): void; + close(): void; +} + +/** The mechanics a caller (or a test) can replace. Production passes none. */ +export interface BridgeDeps { + /** Defaults to the global `fetch`. */ + fetch?: (url: string) => Promise; + /** Defaults to the global `WebSocket`. */ + WebSocketImpl?: new ( + url: string, + ) => BridgeWs; + /** Defaults to `Date.now` (stamps `ts` and drives the fps throttle). */ + now?: () => number; + /** Defaults to the daemon's unref'd timers (same handle `forward.ts` uses). */ + timer?: LaneTimers; +} + +/** What one `preview_open` screencast arm hands the bridge. */ +export interface StartBridgeArgs { + ref: string; + /** The devtools port the plane's discovery attributed to the pane, dialed on loopback only. */ + cdpPort: number; + /** Already defaulted by the branch when the command omitted it; the bridge still clamps. */ + fps: number; + lifetimeMs: number; + /** + * Hand one stream frame to the CURRENT established link. DELIVER-OR-THROW + * (the same sender the lanes ride): a throw is a frame that did not leave, + * and the bridge's answer is to drop it and keep capturing (the link-drop + * sweep in `daemon.ts` ends the bridges when the link itself dies). A + * returned promise means the pump is behind: the queue holds until it + * settles, which is what the buffer cap counts. + */ + emitStream: (ev: PreviewStreamEvent) => undefined | Promise; + /** The same sender, for the death notice. */ + emitClosed: (ev: PreviewClosedEvent) => void; + deps?: BridgeDeps; + /** Line sink for bridge lifecycle (defaults to the agent log), the `RelayOpenSeams.log` habit. */ + log?: (line: string) => void; +} + +interface Bridge { + ref: string; + ws?: BridgeWs; + /** Monotonic per ref; assigned when a frame passes every drop gate and is built. */ + seq: number; + /** The bridge's clock: the `now` seam default (`Date.now`), stamped as `ts`. */ + now: () => number; + intervalMs: number; + /** Last accepted emission's stamp; `-Infinity` means "first frame always emits". */ + lastEmitAt: number; + /** Frames built but not yet accepted by `emitStream` (capped, drained in order). */ + queue: PreviewStreamEvent[]; + draining: boolean; + timer: LaneTimers; + lifetime?: unknown; + emitStream: StartBridgeArgs["emitStream"]; + emitClosed: StartBridgeArgs["emitClosed"]; + say: (line: string) => void; + /** CDP message ids: CDP only needs them distinct per session, and incrementing is distinct. */ + nextId: number; +} + +/** The live bridge registry, module-level like the daemon's other per-machine maps. */ +const bridges = new Map(); + +/** Clamp the command's fps into the protocol's [1, 5] window (pure; exported for its pin). */ +export function clampFps(fps: number): number { + return Math.min(PREVIEW_WATCH_FPS_MAX, Math.max(PREVIEW_WATCH_FPS_MIN, fps)); +} + +/** Bridges now registered: the shared node budget's bridge half (`forwardCount()` is the lane half). */ +export function bridgeCount(): number { + return bridges.size; +} + +/** + * Start one screencast: fetch `/json/list`, pick the newest page target, + * distrust its websocket URL (loopback or refuse), open the socket, and start + * the capture. Resolves `{ subscribers: 1 }` when the screencast is running; + * EVERY handshake failure (non-200, no target, non-loopback URL, dead or hung + * socket, expired budget) rejects with a `not-cdp` Error and leaves no + * half-started state behind. The fps default lives in the branch that reads + * the command, not here; the clamp lives here. + */ +export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: number }> { + if (bridges.has(args.ref)) { + // Same posture as `openLane`: a ref the registry owns keeps its live + // bridge, and crypto-random 32-hex refs make the collision unrepresentable. + log(`preview bridge ${args.ref}: open for an already-registered ref, keeping the live bridge`); + return { subscribers: 1 }; + } + const now = args.deps?.now ?? Date.now; + const timer = args.deps?.timer ?? realTimers; + const fetchImpl: NonNullable = args.deps?.fetch ?? ((url) => globalThis.fetch(url)); + // The one seam cast: the DOM `WebSocket` libtype does not describe the + // narrower surface a bridge uses, and the production default is the global. + const WSImpl: NonNullable = + args.deps?.WebSocketImpl ?? (globalThis.WebSocket as unknown as new (url: string) => BridgeWs); + const say = args.log ?? ((line: string) => log(line)); + + const bridge: Bridge = { + ref: args.ref, + seq: 0, + now, + intervalMs: Math.ceil(1000 / clampFps(args.fps)), + lastEmitAt: -Infinity, + queue: [], + draining: false, + timer, + emitStream: args.emitStream, + emitClosed: args.emitClosed, + say, + nextId: 1, + }; + say( + `preview bridge ${bridge.ref}: starting against 127.0.0.1:${args.cdpPort} ` + + `(fps ${clampFps(args.fps)}, interval ${bridge.intervalMs} ms, lifetime ${args.lifetimeMs} ms)`, + ); + + let budgetHandle: unknown; + let settled = false; + let ws: BridgeWs | undefined; + let handshakeOpen: () => void = () => {}; + let handshakeFail: (why: string) => void = () => {}; + const handshake = new Promise<{ subscribers: number }>((resolve, reject) => { + handshakeOpen = () => { + if (settled) return; + settled = true; + timer.clearTimeout(budgetHandle); + bridges.set(bridge.ref, bridge); + // The lifetime arms only once the mechanism is actually running: the + // handshake budget owns the phase before this, and it never double-fires + // because the promise above has exactly one winner. + bridge.lifetime = timer.setTimeout(() => die(bridge, "idle"), args.lifetimeMs); + resolve({ subscribers: 1 }); + }; + handshakeFail = (why: string) => { + if (settled) return; + settled = true; + timer.clearTimeout(budgetHandle); + try { + ws?.close(); + } catch { + /* already gone */ + } + say(`preview bridge ${bridge.ref}: handshake failed (${why}), refusing not-cdp`); + reject(new Error("not-cdp")); + }; + }); + budgetHandle = timer.setTimeout( + () => handshakeFail(`handshake budget ${BRIDGE_HANDSHAKE_BUDGET_MS} ms expired`), + BRIDGE_HANDSHAKE_BUDGET_MS, + ); + + // The socket's ongoing handlers are wired BEFORE the handshake resolves so a + // frame or a death racing the open lands on the bridge that owns them. + const attachSocket = (url: string): void => { + try { + ws = new WSImpl(url); + } catch (err: unknown) { + handshakeFail(`websocket dial threw (${String(err)})`); + return; + } + bridge.ws = ws; // the frame path acks through the bridge, not just the closure + ws.onopen = () => { + try { + ws?.send(JSON.stringify({ id: bridge.nextId++, method: "Page.enable" })); + ws?.send( + JSON.stringify({ + id: bridge.nextId++, + method: "Page.startScreencast", + params: { format: "jpeg", quality: PREVIEW_WATCH_JPEG_QUALITY, everyNthFrame: 1 }, + }), + ); + } catch (err: unknown) { + handshakeFail(`screencast start threw (${String(err)})`); + return; + } + handshakeOpen(); + }; + ws.onerror = (err) => { + // On an unregistered bridge this is the handshake dying; on a live one + // the paired `close` that follows is the one death path (the die-guard + // makes first death win either way). + if (!bridges.has(bridge.ref)) handshakeFail(`websocket error (${String(err)})`); + else die(bridge, "target-gone"); + }; + ws.onclose = () => { + if (!bridges.has(bridge.ref)) handshakeFail("websocket closed before open"); + else die(bridge, "target-gone"); + }; + ws.onmessage = (msg) => { + if (bridges.get(bridge.ref) !== bridge) return; + onSocketMessage(bridge, msg.data); + }; + }; + + // Step 1: the target list. Non-200, a throw, junk JSON, or no usable target + // are all "this port is not answering CDP", which is what `not-cdp` names. + void (async () => { + let response: BridgeFetchResponse; + try { + response = await fetchImpl(`http://127.0.0.1:${args.cdpPort}/json/list`); + } catch (err: unknown) { + handshakeFail(`list fetch threw (${String(err)})`); + return; + } + if (!response.ok) { + handshakeFail(`list answered ${response.status}`); + return; + } + let targets: unknown; + try { + targets = await response.json(); + } catch (err: unknown) { + handshakeFail(`list JSON was junk (${String(err)})`); + return; + } + if (!Array.isArray(targets) || targets.length === 0) { + handshakeFail("list carried no targets"); + return; + } + // Newest first per CDP's own ordering; a page target wins, else the first + // row stands (a worker-only list still means this port answers CDP). + const rows = targets as CdpTarget[]; + const target = rows.find((t) => t.type === "page") ?? rows[0]; + const url = typeof target?.webSocketDebuggerUrl === "string" ? target.webSocketDebuggerUrl : ""; + if (!url) { + handshakeFail("the chosen target carries no webSocketDebuggerUrl"); + return; + } + // The URL is answered by a socket on a port that came from /proc: + // distrust it. WHATWG parses a v6 host bracketed, so compare the parsed + // hostname with the brackets gone; the constraint names `::1`, and this + // check must not silently narrow it. + let host: string; + try { + host = new URL(url).hostname.replace(/^\[|\]$/g, ""); + } catch { + handshakeFail(`webSocketDebuggerUrl ${url} is not a URL`); + return; + } + if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") { + handshakeFail(`webSocketDebuggerUrl host ${host} is not loopback`); + return; + } + attachSocket(url); + })(); + + return handshake; +} + +/** + * Plane-ordered teardown. NOT access-gated (the `preview_close` doctrine: + * teardown is not a preview act) and silent by design: the plane ordered this + * death. False when the ref is not a bridge, which is what lets the close + * executor fall through to the tombstone answer. + */ +export function stopBridge(ref: string, reason: PreviewCloseReason): boolean { + const bridge = bridges.get(ref); + if (!bridge) return false; + remove(bridge); + bridge.say(`preview bridge ${ref}: closed by the plane (${reason})`); + return true; +} + +/** + * The daemon's link-drop hook beside the lanes': every bridge dies with + * exactly one `preview_closed`. Without it a continuous-repaint page would + * screencast forever after the plane's link died. The pump throws for each of + * these (the link is gone); `die` treats that as the expected answer. + */ +export function bridgeCloseAll(reason: PreviewCloseReason): void { + for (const bridge of [...bridges.values()]) die(bridge, reason); +} + +/* ------------------------------------------------------------------ */ +/* the frame path */ +/* ------------------------------------------------------------------ */ + +/** + * One CDP notification in: ack it IMMEDIATELY and always (an unacked capture + * stalls the browser's screencast), then run the drop gates - size, throttle, + * queue cap - and only then build and queue the event. Dropped frames cost + * nothing else: the bridge stays alive for the next one. + */ +function onSocketMessage(bridge: Bridge, raw: string | Buffer): void { + let msg: { method?: unknown; params?: { data?: unknown; metadata?: unknown; sessionId?: unknown } }; + try { + msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as typeof msg; + } catch { + return; // stream noise, not a CDP object: nothing to ack or emit + } + if (msg?.method !== "Page.screencastFrame") return; // command results and other events are not frames + const params = msg.params ?? {}; + const sessionId = typeof params.sessionId === "string" ? params.sessionId : ""; + try { + bridge.ws?.send(JSON.stringify({ id: bridge.nextId++, method: "Page.screencastFrameAck", params: { sessionId } })); + } catch (err: unknown) { + // The socket died between delivery and ack; the `close` handler is the + // death path, and this bridge is already on its way out. + bridge.say(`preview bridge ${bridge.ref}: ack send failed (${String(err)})`); + } + const blob = typeof params.data === "string" ? params.data : ""; + if (blob.length === 0 || Buffer.from(blob, "base64").length > PREVIEW_FRAME_MAX_BYTES) { + // The plane's parser refuses an over-cap blob, so emitting one would + // corrupt the lane SILENTLY. Drop it; the bridge continues. + bridge.say(`preview bridge ${bridge.ref}: frame over the ${PREVIEW_FRAME_MAX_BYTES}B cap (or empty), dropped`); + return; + } + const meta = (params.metadata ?? {}) as { deviceWidth?: unknown; deviceHeight?: unknown }; + const w = meta.deviceWidth; + const h = meta.deviceHeight; + if (!Number.isInteger(w) || (w as number) <= 0 || !Number.isInteger(h) || (h as number) <= 0) { + bridge.say(`preview bridge ${bridge.ref}: frame metadata had no positive dims, dropped`); + return; + } + // Throttle: `Math.ceil(1000 / fps)` between emissions, extras dropped (the + // decision-8 reduction site). `-Infinity` starts the series so the first + // capture always emits. + const stamp = bridge.now(); + if (stamp - bridge.lastEmitAt < bridge.intervalMs) return; + bridge.lastEmitAt = stamp; + // Queue cap: what the bridge HOLDS, not what the wire promises. A stalled + // pump may hold at most `PREVIEW_WATCH_FRAMES_BUFFERED`; past that, drop. + if (bridge.queue.length >= PREVIEW_WATCH_FRAMES_BUFFERED) { + bridge.say( + `preview bridge ${bridge.ref}: ${bridge.queue.length} frames already held, dropped one (the pump is behind)`, + ); + return; + } + bridge.seq += 1; + bridge.queue.push({ + type: "preview_stream", + ref: bridge.ref, + seq: bridge.seq, + w: w as number, + h: h as number, + ts: stamp, + blob, + }); + void drain(bridge); +} + +/** + * Push the queue at the pump in order. A synchronous `emitStream` (production) + * drains everything in this turn; a returned promise means the pump is behind + * and the queue holds (and caps) until it settles. A throw is an undeliverable + * frame: dropped and logged, never re-sent - a stream with a hole stays a + * stream with a hole, and the link-drop sweep is the real remedy for a dead + * link. + */ +async function drain(bridge: Bridge): Promise { + if (bridge.draining) return; + bridge.draining = true; + while (bridge.queue.length > 0) { + const ev = bridge.queue[0]; + try { + const accepted = bridge.emitStream(ev); + if (accepted && typeof accepted.then === "function") await accepted; + } catch (err: unknown) { + bridge.say(`preview bridge ${bridge.ref}: stream frame undeliverable, dropped (${String(err)})`); + } + bridge.queue.shift(); + if (bridges.get(bridge.ref) !== bridge) break; // closed mid-drain: stop feeding a dead bridge + } + bridge.draining = false; +} + +/* ------------------------------------------------------------------ */ +/* lifecycle */ +/* ------------------------------------------------------------------ */ + +/** The ONE death path: deregister, disarm, close, then exactly one event. */ +function die(bridge: Bridge, reason: PreviewCloseReason): void { + if (bridges.get(bridge.ref) !== bridge) return; + remove(bridge); + bridge.say(`preview bridge ${bridge.ref}: closing (${reason})`); + try { + bridge.emitClosed({ type: "preview_closed", ref: bridge.ref, reason }); + } catch (err: unknown) { + // Expected exactly once: the link-drop sweep closes bridges whose link is + // already gone. The plane reads that link drop itself; the agent log keeps + // the record. + log(`preview bridge ${bridge.ref}: preview_closed (${reason}) undeliverable: ${String(err)}`); + } +} + +/** Deregister first, then release the bridge's holds (timer, socket, queue). */ +function remove(bridge: Bridge): void { + bridges.delete(bridge.ref); + if (bridge.lifetime !== undefined) { + bridge.timer.clearTimeout(bridge.lifetime); + bridge.lifetime = undefined; + } + bridge.queue.length = 0; + try { + bridge.ws?.close(); + } catch { + /* already gone */ + } +} diff --git a/apps/node/agent/src/preview/forward.ts b/apps/node/agent/src/preview/forward.ts index f8dbaf694..d2103e2b0 100644 --- a/apps/node/agent/src/preview/forward.ts +++ b/apps/node/agent/src/preview/forward.ts @@ -27,12 +27,19 @@ import { log } from "../log.js"; * command's gate ladder owns all three, so nothing here re-decides them: * there is no host on the wire (the grammar bounds the port, the dials are * loopback literals only), pane liveness is a command-time check, and the - * node-side budget (Task 6 will widen it to a shared lane+bridge counter) is - * read through {@link PreviewForward.laneCount}. + * node-side budget is the SHARED lane+bridge counter (Task 6): the lane half + * is read through {@link PreviewForward.laneCount} (or {@link forwardCount} + * from the other branch's side), and a sum check at the open executor keeps + * the node to {@link PREVIEW_SESSIONS_PER_NODE} of any mix. */ -/** The lane events this registry emits: the byte leg and the death notice. */ -export type PreviewLaneEvent = Extract; +/** + * The preview events the daemon's single sender carries: the lane's byte leg, + * the bridge's screencast leg (widened here for Task 6 — the sender is one + * deliver-or-throw path, so slow-link accounting and link-drop behavior stay + * true for both registries), and the death notice both share. + */ +export type PreviewLaneEvent = Extract; /** * The dial target's socket, narrowed to what a lane uses — the seam lets a @@ -112,8 +119,12 @@ interface Lane { /** The real dial: net's own connect, which the retry posture below wraps. */ const realConnect: NonNullable = (options) => netConnect(options); -/** Real timers, unref'd: the watch-tick doctrine — a 15-minute idle arm must not hold the process open. */ -const realTimers: LaneTimers = { +/** + * Real timers, unref'd: the watch-tick doctrine — a 15-minute idle arm must + * not hold the process open. Exported because the CDP bridge (`cdp-bridge.ts`) + * arms its handshake and lifetime budgets under the same rule. + */ +export const realTimers: LaneTimers = { setTimeout: (cb, ms) => { const t = setTimeout(cb, ms); t.unref(); @@ -406,3 +417,22 @@ export class PreviewForward { } } } + +/** + * The daemon's live registry, published so the SHARED node budget (spec §6's + * "16 per node" over lanes AND bridges, Task 6) can be read from anywhere + * without threading the instance. `daemon.ts` owns this lifecycle like + * `relaySessions`: set at boot, cleared when the loop unwinds; no active + * registry answers 0, which is the honest "nothing is open here". + */ +let activeForward: PreviewForward | undefined; + +/** Publish (or clear) the daemon's registry as the node-wide count source. @internal test seams may set it. */ +export function setActivePreviewForward(registry: PreviewForward | undefined): void { + activeForward = registry; +} + +/** Open lanes node-wide, from the active registry: one half of the shared budget the other half reads as {@link bridgeCount}. */ +export function forwardCount(): number { + return activeForward?.laneCount() ?? 0; +} From c7e99e89b8394d6f318ad4b50b0777a1bb1a7391 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 22:16:07 -0700 Subject: [PATCH 036/139] fix(node): cancel in-flight CDP handshakes in the link-drop sweep --- .../src/__tests__/preview-cdp-bridge.test.ts | 76 ++++++++++++++++++- .../agent/src/commands/preview-open-exec.ts | 20 ++--- apps/node/agent/src/preview/cdp-bridge.ts | 66 +++++++++++++++- apps/node/agent/src/preview/forward.ts | 16 ++-- packages/subshell-protocol/src/node-frames.ts | 2 +- 5 files changed, 158 insertions(+), 22 deletions(-) diff --git a/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts b/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts index b7c3ca019..c96b8c61f 100644 --- a/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts +++ b/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts @@ -783,6 +783,80 @@ describe("(g) preview_close falls forward to the bridge", () => { cdp.stop(); } }); + + it("the sweep cancels an in-flight handshake: the late open never registers, sends nothing, volunteers nothing", async () => { + // MINOR-1 (Task 6 review): a bridge registers only once the handshake + // lands, so for up to the budget window a screencast open is invisible to + // bridgeCloseAll. The plane's link must not be able to die inside that + // window and leave a bridge streaming to nobody until its lifetime expires. + const clock = new FakeClock(); + let listRequests = 0; + const sends: string[] = []; + let late: HeldWs | undefined; + class HeldWs { + onopen: ((ev: unknown) => void) | null = null; + onerror: ((err: unknown) => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((msg: { data: string | Buffer }) => void) | null = null; + closed = false; + constructor() { + late = this; + } + send(data: string): void { + sends.push(data); + } + close(): void { + this.closed = true; + } + open(): void { + this.onopen?.({}); + } + } + const events: PreviewLaneEvent[] = []; + // Observe the eventual outcome from the start: the fix rejects during the + // sweep, long before the test awaits, and an unattached rejection would + // leak into the next test. + const outcome = startBridge({ + ref: refFor(1), + cdpPort: 9222, + fps: 5, + lifetimeMs: 60_000, + emitStream: (ev) => { + events.push(ev); + }, + emitClosed: (ev) => { + events.push(ev); + }, + deps: { + timer: clock, + fetch: async () => { + listRequests += 1; + return { + ok: true, + status: 200, + json: async () => [{ type: "page", webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/T1" }], + }; + }, + WebSocketImpl: HeldWs as unknown as new (url: string) => BridgeWs, + }, + }).then( + () => ({ status: "resolved" as const }), + (err: unknown) => ({ status: "rejected" as const, why: String(err) }), + ); + try { + await waitUntil(() => listRequests === 1 && late !== undefined, "the list answer and the websocket dial"); + bridgeCloseAll("node-dropped"); // the link dies with the open not yet fired + expect(late?.closed).toBe(true); // the sweep releases the in-flight socket at once + late?.open(); // and the open lands anyway, after the sweep has run + expect(await outcome).toEqual({ status: "rejected", why: "Error: node-dropped" }); + expect(bridgeCount()).toBe(0); // the late handshakeOpen must not register + expect(sends).toHaveLength(0); // no Page.enable, no screencast, on a dead link + expect(events).toHaveLength(0); // nothing volunteered: the plane never held this ref + expect(clock.liveAt(60_000)).toBe(0); // no lifetime armed for a bridge that never was + } finally { + bridgeCloseAll("viewer-left"); + } + }); }); /* ------------------------------------------------------------------ */ @@ -794,7 +868,7 @@ describe("(h) the shared node budget", () => { expect(PREVIEW_SESSIONS_PER_NODE).toBe(16); const cdp = new FakeCdp(); const h = makeHarness("shared-budget-bridge"); - setActivePreviewForward(h.forward); // forwardCount() now reads THIS registry + setActivePreviewForward(h.forward); // the daemon boot handle; the executor reads THIS registry directly try { // 15 lanes spread 4-per-pane (the per-pane cap is its own rung; this // case is about the NODE sum, so the lanes must not trip it first). diff --git a/apps/node/agent/src/commands/preview-open-exec.ts b/apps/node/agent/src/commands/preview-open-exec.ts index d3e930812..76bde732c 100644 --- a/apps/node/agent/src/commands/preview-open-exec.ts +++ b/apps/node/agent/src/commands/preview-open-exec.ts @@ -6,7 +6,7 @@ import { } from "@internal/subshell-protocol"; import { type BridgeDeps, bridgeCount, startBridge } from "../preview/cdp-bridge.js"; import { getPreviewEnabled } from "../preview/flags.js"; -import { forwardCount, type LaneDeps } from "../preview/forward.js"; +import type { LaneDeps } from "../preview/forward.js"; import { resolveSocket } from "./basics.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; @@ -26,12 +26,12 @@ import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; * pane, so liveness is law before registration, not a fact the dial trusts. * - **The budget last, and it is ONE number**: spec §6's "16 per node" is a * sum over BOTH registries (sixteen of any mix), checked before either - * mechanism starts. The lane path consults {@link bridgeCount}, the bridge - * path {@link forwardCount}; a sum living only in one branch is how 32 - * total sessions come back, and a dial-then-refuse would leave a live - * connection behind for a request that was never going to register. Each - * refusal still names its own kind. The 4-per-pane cap stays lane-only: the - * screencast has no per-pane quota in the spec. + * mechanism starts. Both rungs sum the context registry's node-wide + * `laneCount()` with {@link bridgeCount}; a sum living only in one branch + * is how 32 total sessions come back, and a dial-then-refuse would leave a + * live connection behind for a request that was never going to register. + * Each refusal still names its own kind. The 4-per-pane cap stays lane-only: + * the screencast has no per-pane quota in the spec. * * The forward branch calls {@link PreviewForward.openLane}, which registers * synchronously and dials OFF the command chain (the ssh-relay A-branch @@ -40,7 +40,7 @@ import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; * answer to this command. The screencast branch AWAITS {@link startBridge}: * the open answers `{ ref, subscribers: 1 }` only once the CDP handshake and * `Page.startScreencast` have actually landed, and every handshake failure - * answers the {@link PREVIEW_NOT_CDP} refusal with no half-started state — + * answers the {@link PREVIEW_NOT_CDP} refusal with no half-started state: * the plane never holds a ref it has no live bridge behind. */ @@ -116,9 +116,9 @@ export async function execPreviewOpen( } // The screencast branch: the shared budget BEFORE the dial (a refusal must // not leave a live CDP connection behind for a request that was never going - // to register), and the absent `fps` defaults HERE — the wire grammar + // to register), and the absent `fps` defaults HERE: the wire grammar // rebuilds the command without it and leaves the defaulting to this arm. - if (forwardCount() + bridgeCount() >= PREVIEW_SESSIONS_PER_NODE) + if (forward.laneCount() + bridgeCount() >= PREVIEW_SESSIONS_PER_NODE) return { ok: false, error: PREVIEW_WATCH_OVER_NODE_CAP }; try { await startBridge({ diff --git a/apps/node/agent/src/preview/cdp-bridge.ts b/apps/node/agent/src/preview/cdp-bridge.ts index c4501a420..876a93535 100644 --- a/apps/node/agent/src/preview/cdp-bridge.ts +++ b/apps/node/agent/src/preview/cdp-bridge.ts @@ -143,12 +143,27 @@ interface Bridge { /** The live bridge registry, module-level like the daemon's other per-machine maps. */ const bridges = new Map(); +/** + * Handshakes in flight, keyed by ref. A bridge joins {@link bridges} only + * once its handshake lands, which would leave a screencast open invisible to + * {@link bridgeCloseAll} for the budget window, exactly the hazard the + * link-drop sweep exists to close. Every start registers HERE first, so the + * sweep can cancel it: `cancel` rejects the awaiting `startBridge` and marks + * the handshake settled, and a late open then closes its socket instead of + * registering. + */ +interface PendingHandshake { + bridge: Bridge; + cancel: (reason: string) => void; +} +const pending = new Map(); + /** Clamp the command's fps into the protocol's [1, 5] window (pure; exported for its pin). */ export function clampFps(fps: number): number { return Math.min(PREVIEW_WATCH_FPS_MAX, Math.max(PREVIEW_WATCH_FPS_MIN, fps)); } -/** Bridges now registered: the shared node budget's bridge half (`forwardCount()` is the lane half). */ +/** Bridges now registered: the shared node budget's bridge half (the lane half is the registry's `laneCount()`). */ export function bridgeCount(): number { return bridges.size; } @@ -202,10 +217,18 @@ export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: let ws: BridgeWs | undefined; let handshakeOpen: () => void = () => {}; let handshakeFail: (why: string) => void = () => {}; + let handshakeCancel: (reason: string) => void = () => {}; + // Drop only OUR pending entry: a second handshake under the same ref (an + // unrepresentable collision, but this map is global) must not be orphaned + // by the first one settling. + const clearPending = (): void => { + if (pending.get(args.ref)?.bridge === bridge) pending.delete(args.ref); + }; const handshake = new Promise<{ subscribers: number }>((resolve, reject) => { handshakeOpen = () => { if (settled) return; settled = true; + clearPending(); timer.clearTimeout(budgetHandle); bridges.set(bridge.ref, bridge); // The lifetime arms only once the mechanism is actually running: the @@ -217,6 +240,7 @@ export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: handshakeFail = (why: string) => { if (settled) return; settled = true; + clearPending(); timer.clearTimeout(budgetHandle); try { ws?.close(); @@ -226,7 +250,25 @@ export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: say(`preview bridge ${bridge.ref}: handshake failed (${why}), refusing not-cdp`); reject(new Error("not-cdp")); }; + handshakeCancel = (reason: string) => { + if (settled) return; // the budget or a failure already owned this handshake + settled = true; + clearPending(); + timer.clearTimeout(budgetHandle); + try { + ws?.close(); + } catch { + /* already gone */ + } + // NO `preview_closed` is volunteered: the plane never held this ref. The + // awaiting open simply answers a refusal, and the open arm speaks + // `not-cdp` whatever the handshake throws, so the message only carries + // the sweep's reason for the log. + say(`preview bridge ${bridge.ref}: handshake cancelled by the link-drop sweep (${reason})`); + reject(new Error(reason)); + }; }); + pending.set(args.ref, { bridge, cancel: handshakeCancel }); budgetHandle = timer.setTimeout( () => handshakeFail(`handshake budget ${BRIDGE_HANDSHAKE_BUDGET_MS} ms expired`), BRIDGE_HANDSHAKE_BUDGET_MS, @@ -243,6 +285,17 @@ export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: } bridge.ws = ws; // the frame path acks through the bridge, not just the closure ws.onopen = () => { + if (settled) { + // The handshake was cancelled by the sweep (or the budget fired) + // while this open was in flight: the socket has no owner, so close + // it instead of starting a screencast nobody registered for. + try { + ws?.close(); + } catch { + /* already gone */ + } + return; + } try { ws?.send(JSON.stringify({ id: bridge.nextId++, method: "Page.enable" })); ws?.send( @@ -324,6 +377,7 @@ export async function startBridge(args: StartBridgeArgs): Promise<{ subscribers: handshakeFail(`webSocketDebuggerUrl host ${host} is not loopback`); return; } + if (settled) return; // cancelled while the list fetch was in flight: no dial now attachSocket(url); })(); @@ -346,11 +400,15 @@ export function stopBridge(ref: string, reason: PreviewCloseReason): boolean { /** * The daemon's link-drop hook beside the lanes': every bridge dies with - * exactly one `preview_closed`. Without it a continuous-repaint page would - * screencast forever after the plane's link died. The pump throws for each of - * these (the link is gone); `die` treats that as the expected answer. + * exactly one `preview_closed`, and every IN-FLIGHT HANDSHAKE is cancelled + * (see the {@link pending} note) so a late open can never register behind a + * dead link. Without either, a continuous-repaint page would screencast + * forever after the plane's link died. The pump throws for each of these (the + * link is gone); `die` treats that as the expected answer, and a cancelled + * handshake volunteers nothing: the plane never held that ref. */ export function bridgeCloseAll(reason: PreviewCloseReason): void { + for (const entry of [...pending.values()]) entry.cancel(reason); for (const bridge of [...bridges.values()]) die(bridge, reason); } diff --git a/apps/node/agent/src/preview/forward.ts b/apps/node/agent/src/preview/forward.ts index d2103e2b0..ee3e3bd1a 100644 --- a/apps/node/agent/src/preview/forward.ts +++ b/apps/node/agent/src/preview/forward.ts @@ -27,15 +27,15 @@ import { log } from "../log.js"; * command's gate ladder owns all three, so nothing here re-decides them: * there is no host on the wire (the grammar bounds the port, the dials are * loopback literals only), pane liveness is a command-time check, and the - * node-side budget is the SHARED lane+bridge counter (Task 6): the lane half - * is read through {@link PreviewForward.laneCount} (or {@link forwardCount} - * from the other branch's side), and a sum check at the open executor keeps - * the node to {@link PREVIEW_SESSIONS_PER_NODE} of any mix. + * node-side budget is the SHARED lane+bridge counter (Task 6): the open + * executor sums the node-wide {@link PreviewForward.laneCount} with the + * bridge registry's count before either mechanism starts, keeping the node to + * {@link PREVIEW_SESSIONS_PER_NODE} of any mix. */ /** * The preview events the daemon's single sender carries: the lane's byte leg, - * the bridge's screencast leg (widened here for Task 6 — the sender is one + * the bridge's screencast leg (widened here for Task 6: the sender is one * deliver-or-throw path, so slow-link accounting and link-drop behavior stay * true for both registries), and the death notice both share. */ @@ -432,7 +432,11 @@ export function setActivePreviewForward(registry: PreviewForward | undefined): v activeForward = registry; } -/** Open lanes node-wide, from the active registry: one half of the shared budget the other half reads as {@link bridgeCount}. */ +/** + * Open lanes node-wide, through the active registry. The open executor reads + * the lane half straight off its own context registry; this is the same + * number for callers that hold no instance. + */ export function forwardCount(): number { return activeForward?.laneCount() ?? 0; } diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index c37740a4c..b18932cba 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -1624,7 +1624,7 @@ export interface PreviewOpenCommand { paneId: string; /** Dial target (forward) or devtools port (screencast). The node dials loopback only. */ port: number; - /** Screencast only; the bridge clamps it to [1, 5] and defaults it. Forward forbids it. */ + /** Screencast only; the open EXECUTOR branch defaults an absent fps and the bridge clamps it to [1, 5]. Forward forbids it. */ fps?: number; /** Self-expiry: the node closes the lane by this deadline whatever the plane does. */ lifetimeMs: number; From b300a84f7ac2e4444d62908130807d1b2bdbab6d Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 23:15:16 -0700 Subject: [PATCH 037/139] feat(node): Xvfb+x11vnc virtual displays, gated, launched with the pane --- .../src/__tests__/preview-display.test.ts | 807 ++++++++++++++++++ apps/node/agent/src/commands/context.ts | 13 + apps/node/agent/src/commands/launch.ts | 59 +- .../agent/src/commands/preview-ports-exec.ts | 30 +- apps/node/agent/src/commands/report.ts | 13 + apps/node/agent/src/daemon.ts | 66 +- apps/node/agent/src/preview/display.ts | 662 ++++++++++++++ apps/node/agent/src/preview/ports.ts | 16 +- .../pane-runtime/src/__tests__/launch.test.ts | 70 +- packages/pane-runtime/src/launch.ts | 26 +- 10 files changed, 1733 insertions(+), 29 deletions(-) create mode 100644 apps/node/agent/src/__tests__/preview-display.test.ts create mode 100644 apps/node/agent/src/preview/display.ts diff --git a/apps/node/agent/src/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts new file mode 100644 index 000000000..7c94a4e84 --- /dev/null +++ b/apps/node/agent/src/__tests__/preview-display.test.ts @@ -0,0 +1,807 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HARNESS_BINARY_PLACEHOLDER, type NodeEvent, parseNodePreviewPortsResult } from "@internal/subshell-protocol"; +import type { Cmd, CommandContext } from "../commands/context.js"; +import { execLaunch } from "../commands/launch.js"; +import { execPreviewPorts, PREVIEW_NODE_OFF, type PreviewPortsSeams } from "../commands/preview-ports-exec.js"; +import { runExitWatchTick } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { + type DisplayChild, + type DisplayDeps, + PREVIEW_DISPLAY_MISSING_BINARIES, + PREVIEW_DISPLAY_NOT_READY, + PREVIEW_DISPLAY_UNSUPPORTED, + PreviewDisplays, + xauthorityPath, +} from "../preview/display.js"; +import { setPreviewEnabled } from "../preview/flags.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; + +/** + * Virtual displays (pane-preview spec §5, Task 7): the Xvfb + x11vnc pair a + * `screen` launch allocates, the ports answer's `screen` half, and the + * orphan sweep. NO real Xvfb or x11vnc is ever spawned here: every mechanic + * runs through the injected `{ spawn, spawnSync, which, sleep, now, bindProbe, + * kill }` deps, and the argv-exactness case is what makes the fakes honest - + * it is the only assertion that can catch a broken invocation, because + * everything else in this suite runs on doubles. Posture copied from + * preview-ports.test.ts: real temp dirs, the mirror written by its own + * writer, refusals asserted as thrown words. + */ + +const STAMP = "2026-10-22T10:00:00.000Z"; +const PANE = "11111111-2222-4333-8444-555555555555"; +const HOUR_MS = 3_600_000; + +let base: string; +let claudePathOriginal: string | undefined; + +beforeAll(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "subshell-preview-display-"))); + // The harness binary "exists" (/bin/sh - never spawned; the pane command + // is assembled against it and thrown away). Same determinism trick as + // commands-launch.test.ts: the resolve rule reads CLAUDE_PATH, so the + // binary-found branch is real on every host. + claudePathOriginal = process.env.CLAUDE_PATH; + process.env.CLAUDE_PATH = "/bin/sh"; +}); + +afterAll(() => { + if (claudePathOriginal === undefined) delete process.env.CLAUDE_PATH; + else process.env.CLAUDE_PATH = claudePathOriginal; + rmSync(base, { recursive: true, force: true }); +}); + +/** A per-test data dir with the preview mirror switched ON. */ +function onDir(tag: string): string { + const dataDir = join(base, tag, "data"); + mkdirSync(dataDir, { recursive: true }); + setPreviewEnabled(dataDir, { on: true, changedAt: STAMP }); + return dataDir; +} + +/** A child double: the test fires `exited` when it wants the watch to run. */ +class FakeChild implements DisplayChild { + readonly pid: number; + readonly exited: Promise; + #resolve!: (code: number) => void; + + constructor(pid: number) { + this.pid = pid; + this.exited = new Promise((r) => { + this.#resolve = r; + }); + } + + fireExit(code = 1): void { + this.#resolve(code); + } +} + +type DepCall = + | { kind: "spawnSync"; argv: string[] } + | { kind: "spawn"; argv: string[]; logPath: string; child: FakeChild; jsonAtSpawn: string | null } + | { kind: "kill"; pid: number; signal: number | string }; + +interface DepOverrides { + /** Binary presence table (name -> path, or null for missing). */ + which?: Record; + /** Exit code the xauth mint answers (default 0). */ + xauthRc?: number; + /** Ports already occupied BEFORE anything spawns (the collision case). */ + busyRfbPorts?: Set; + /** Default true: the x11vnc spawn marks its chosen rfb port occupied, the way a listening server occupies it. */ + x11vncListens?: boolean; + /** Default true: the X socket answers immediately (fake for `/tmp/.X11-unix/XNN`). */ + xSocketReady?: boolean; + /** Readiness budget override. */ + readyBudgetMs?: number; +} + +/** + * The injected-deps harness. `calls` is the ONE ordered log across xauth, + * both spawns, and every kill, because the brief's load-bearing assertion is + * that `xauth add` fires BEFORE the Xvfb spawn - two separate arrays cannot + * show the ordering, one can. Every kill lands in the log, including the + * signal-0 liveness probes; `killPids()` is the strikes-only view and + * `probes()` the probe-only view. + */ +function makeDeps(over: DepOverrides = {}) { + const busy = over.busyRfbPorts ?? new Set(); + const alivePids = new Set(); + const calls: DepCall[] = []; + const whichMap: Record = { + Xvfb: "/usr/bin/Xvfb", + x11vnc: "/usr/bin/x11vnc", + xauth: "/usr/bin/xauth", + ...over.which, + }; + let pidSeq = 4100; + let clock = Date.now(); + const deps: DisplayDeps = { + spawnSync: (argv) => { + calls.push({ kind: "spawnSync", argv }); + if (String(argv[0]).endsWith("xauth")) writeFileSync(String(argv[2]), "", { mode: 0o600 }); + return over.xauthRc ?? 0; + }, + spawn: (argv, opts) => { + const child = new FakeChild(pidSeq++); + alivePids.add(child.pid); + const rfbIdx = argv.indexOf("-rfbport"); + if (over.x11vncListens !== false && rfbIdx >= 0) busy.add(Number(argv[rfbIdx + 1])); + const jsonFile = join(opts.logPath, "..", "display.json"); + calls.push({ + kind: "spawn", + argv, + logPath: opts.logPath, + child, + jsonAtSpawn: existsSync(jsonFile) ? readFileSync(jsonFile, "utf8") : null, + }); + return child; + }, + which: (name) => whichMap[name] ?? null, + sleep: async () => { + clock += 100; + }, + now: () => clock, + bindProbe: async (port) => busy.has(port), + kill: (pid, signal = "SIGTERM") => { + calls.push({ kind: "kill", pid, signal }); + if (!alivePids.has(pid)) throw Object.assign(new Error(`fake kill: no such pid ${pid}`), { code: "ESRCH" }); + if (signal !== 0) alivePids.delete(pid); + }, + xSocketReady: () => over.xSocketReady ?? true, + ...(over.readyBudgetMs === undefined ? {} : { readyBudgetMs: over.readyBudgetMs }), + }; + const spawns = (): Array> => + calls.filter((c): c is Extract => c.kind === "spawn"); + const killPids = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal !== 0 ? [c.pid] : [])); + const probes = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal === 0 ? [c.pid] : [])); + return { deps, calls, busy, alivePids, spawns, killPids, probes }; +} + +/** Let the resolved `exited` promises run their watch handlers. */ +async function flush(): Promise { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +/** Age a path (dir or lock) past the sweep's one-hour launch floor. */ +function ageOut(...paths: string[]): void { + const old = new Date(Date.now() - 2 * HOUR_MS); + for (const p of paths) utimesSync(p, old, old); +} + +/* ------------------------------------------------------------------ */ +/* openFor: allocation, argv exactness, modes */ +/* ------------------------------------------------------------------ */ + +describe("PreviewDisplays.openFor", () => { + it("allocates :90/5910 with the EXACT argv and ORDER, 0700 dir, 0600 files, incremental display.json", async () => { + const dataDir = onDir("happy"); + const displaysRoot = join(dataDir, "displays"); + const paneDir = join(displaysRoot, PANE); + const auth = xauthorityPath(dataDir, PANE); + const { deps, calls, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + + const r = await reg.openFor(PANE, { width: 1280, height: 1024 }); + expect(r).toEqual({ display: ":90", rfbPort: 5910 }); + + // THE load-bearing assertion (the plan's earlier xauth line measured + // broken while staying green): xauth `add` fires FIRST, then Xvfb, then + // x11vnc, each with exactly these arguments. The argv[0] is the path + // `which` resolved; the flags are the contract. + expect(calls.map((c) => c.kind)).toEqual(["spawnSync", "spawn", "spawn"]); + const xauth = calls[0] as Extract; + expect(xauth.argv[0]).toBe("/usr/bin/xauth"); + const cookie = xauth.argv[6] as string; + expect(cookie).toMatch(/^[0-9a-f]{32}$/); // 32 hex, minted before anything spawns + expect(xauth.argv.slice(1)).toEqual(["-f", auth, "add", ":90", ".", cookie]); + const xvfb = spawns()[0]; + expect(xvfb?.argv).toEqual([ + "/usr/bin/Xvfb", + ":90", + "-auth", + auth, + "-screen", + "0", + "1280x1024x24", + "-nolisten", + "tcp", + ]); + const vnc = spawns()[1]; + expect(vnc?.argv).toEqual([ + "/usr/bin/x11vnc", + "-display", + ":90", + "-auth", + auth, + "-localhost", + "-forever", + "-shared", + "-rfbport", + "5910", + ]); + + // Modes: state dir 0700, every file this module writes 0600. + expect(statSync(paneDir).mode & 0o777).toBe(0o700); + for (const f of ["Xvfb.log", "x11vnc.log", "display.env", "display.json", "Xauthority"]) { + expect(statSync(join(paneDir, f)).mode & 0o777, f).toBe(0o600); + } + expect(xvfb?.logPath).toBe(join(paneDir, "Xvfb.log")); + expect(vnc?.logPath).toBe(join(paneDir, "x11vnc.log")); + + // display.json is written INCREMENTALLY: at the x11vnc spawn it already + // names the Xvfb pid (every crash window leaves the pids that exist), and + // the final record is the full four-field pair. + const atVncSpawn = JSON.parse(String(vnc?.jsonAtSpawn)); + expect(atVncSpawn).toEqual({ display: ":90", xvfbPid: xvfb?.child.pid }); + const final = JSON.parse(readFileSync(join(paneDir, "display.json"), "utf8")); + expect(final).toEqual({ display: ":90", rfbPort: 5910, xvfbPid: xvfb?.child.pid, x11vncPid: vnc?.child.pid }); + + // The pinned env file an operator can source. + const envFile = readFileSync(join(paneDir, "display.env"), "utf8"); + expect(envFile).toContain("DISPLAY=:90"); + expect(envFile).toContain(`XAUTHORITY=${auth}`); + + // The lock ties the NUMBER, the dir carries the pane: the pairing the + // sweep reads off the layout. + expect(existsSync(join(displaysRoot, ".display-90.lock"))).toBe(true); + expect(reg.getFor(PANE)).toEqual({ display: ":90", rfbPort: 5910 }); + expect(reg.lostFor(PANE)).toBeNull(); + }); + + it("a taken lockfile pushes the allocation to :91", async () => { + const dataDir = onDir("taken-lock"); + const displaysRoot = join(dataDir, "displays"); + mkdirSync(displaysRoot, { recursive: true }); + writeFileSync(join(displaysRoot, ".display-90.lock"), "", { mode: 0o600 }); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + + const r = await reg.openFor(PANE, { width: 800, height: 600 }); + expect(r.display).toBe(":91"); + expect(existsSync(join(displaysRoot, ".display-91.lock"))).toBe(true); + expect(spawns()[0]?.argv).toEqual([ + "/usr/bin/Xvfb", + ":91", + "-auth", + xauthorityPath(dataDir, PANE), + "-screen", + "0", + "800x600x24", + "-nolisten", + "tcp", + ]); + }); + + it("the rfb-port chooser retries on collision: a busy 5910 lands 5911 in the result", async () => { + const dataDir = onDir("rfb-collision"); + const { deps } = makeDeps({ busyRfbPorts: new Set([5910]) }); + const reg = new PreviewDisplays(dataDir, deps); + const r = await reg.openFor(PANE, { width: 640, height: 480 }); + expect(r).toEqual({ display: ":90", rfbPort: 5911 }); + }); + + it("a missing x11vnc refuses naming it, and leaves no lock and no dir behind", async () => { + const dataDir = onDir("missing-vnc"); + const { deps, spawns } = makeDeps({ which: { x11vnc: null } }); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow( + `${PREVIEW_DISPLAY_MISSING_BINARIES}: x11vnc`, + ); + expect(spawns()).toHaveLength(0); + const root = join(dataDir, "displays"); + expect(existsSync(join(root, PANE))).toBe(false); + expect(existsSync(join(root, ".display-90.lock"))).toBe(false); + }); + + it("readiness polls the X socket, then the rfb port, and refuses preview-display-not-ready after the budget", async () => { + const dataDir = onDir("not-ready"); + const { deps, spawns, killPids } = makeDeps({ xSocketReady: false }); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY); + // The refusal tears down what it started: both children signalled, lock + // unlinked, dir gone - the caller refuses the launch, not half a display. + expect(spawns()).toHaveLength(2); + const pids = spawns().map((s) => s.child.pid); + expect(pids.every((p) => killPids().includes(p))).toBe(true); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + expect(reg.getFor(PANE)).toBeNull(); + }); + + it("an X socket that comes up but an rfb server that never listens also refuses preview-display-not-ready", async () => { + // The second readiness phase, with its own pin: xSocketReady true, but the + // x11vnc double never occupies the port, so the rfb poll exhausts the + // budget and the teardown runs. + const dataDir = onDir("rfb-never-ready"); + const { deps, killPids } = makeDeps({ x11vncListens: false }); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY); + expect(killPids().length).toBe(2); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + }); + + it.skipIf(process.platform === "linux")( + "openFor refuses preview-display-unsupported on non-Linux, before touching fs", + async () => { + // Mirror of preview-ports.test.ts's twin: on a non-Linux host the + // refusal must arrive with nothing spawned. On Linux the platform + // branch cannot be exercised without a seam, and there is no seam + // because a Linux node is where this runs. + const dataDir = onDir("non-linux"); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_UNSUPPORTED); + expect(spawns()).toHaveLength(0); + }, + ); + + it("openFor on a REGISTERED pane is idempotent and spawns nothing", async () => { + const dataDir = onDir("idempotent"); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + const first = await reg.openFor(PANE, { width: 640, height: 480 }); + const second = await reg.openFor(PANE, { width: 1024, height: 768 }); + expect(second).toEqual(first); + expect(spawns()).toHaveLength(2); // still only the original Xvfb + x11vnc + }); +}); + +/* ------------------------------------------------------------------ */ +/* the child-exit watch (spec §9 mid-pane loss) */ +/* ------------------------------------------------------------------ */ + +describe("the child-exit watch", () => { + it("a registered display whose x11vnc exits marks lostFor, reaps lock and dir, and leaves the pane alone", async () => { + const dataDir = onDir("child-exit"); + const store = new SubshellMetaStore(dataDir); + await store.record({ + subshellId: PANE, + cwd: base, + socket: "sock", + harnessId: "claude-code", + name: "m", + startedAt: STAMP, + }); + const { deps, spawns, killPids } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + + spawns()[1]?.child.fireExit(); + await flush(); + + expect(reg.lostFor(PANE)).toBe("x11vnc"); // the ports answer names the loss + expect(reg.getFor(PANE)).toBeNull(); + // The same teardown closeFor runs: the lock unlinked, the dir reaped. + expect(killPids().length).toBeGreaterThan(0); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + // The pane's row is untouched: its terminal keeps streaming on a dead + // DISPLAY, which is spec §9's posture. + expect(await store.get(PANE)).toBeDefined(); + + // The next openFor clears the marker. + const r = await reg.openFor(PANE, { width: 640, height: 480 }); + expect(reg.lostFor(PANE)).toBeNull(); + expect(r.display).toBe(":90"); + }); + + it("a teardown through closeFor does not re-enter as a loss", async () => { + const dataDir = onDir("close-not-loss"); + const { deps, spawns, killPids } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + + reg.closeFor(PANE); + spawns()[0]?.child.fireExit(); + spawns()[1]?.child.fireExit(); + await flush(); + + expect(reg.lostFor(PANE)).toBeNull(); // a deliberate close is not a loss + const pids = spawns().map((s) => s.child.pid); + expect(pids.every((p) => killPids().includes(p))).toBe(true); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the ports executor wiring (decision 6's supply line) */ +/* ------------------------------------------------------------------ */ + +describe("the preview_ports_get executor reads the display registry", () => { + function portsCtx(dataDir: string, previewDisplays?: PreviewDisplays): CommandContext { + return { + config: { dataDir, nodeId: "node-self" }, + meta: { get: async () => undefined }, + tmux: { panePid: async () => 100 }, + previewDisplays, + } as unknown as CommandContext; + } + + function passthroughScan(seen: unknown[]): PreviewPortsSeams { + return { + scan: async (opts) => { + seen.push(opts.screen); + return { ports: [], cdpPort: null, screen: opts.screen ?? null }; + }, + }; + } + + const portsCmd = { type: "preview_ports_get", paneId: PANE } as Cmd<"preview_ports_get">; + + it("a registered display answers screen { display, rfbPort } and survives the Task 1 validator", async () => { + const dataDir = onDir("ports-registered"); + const { deps } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + const r = await reg.openFor(PANE, { width: 640, height: 480 }); + const seen: unknown[] = []; + const res = await execPreviewPorts(portsCtx(dataDir, reg), portsCmd, passthroughScan(seen)); + expect(res).toEqual({ ok: true, data: { ports: [], cdpPort: null, screen: r } }); + const parsed = parseNodePreviewPortsResult(res.ok ? res.data : null); + expect(parsed).toEqual({ ports: [], cdpPort: null, screen: { display: ":90", rfbPort: 5910 } }); + expect(parsed?.screenLost).toBeUndefined(); + }); + + it("a pane with no display answers screen null and NO screenLost", async () => { + const dataDir = onDir("ports-none"); + const { deps } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + const seen: unknown[] = []; + const res = await execPreviewPorts(portsCtx(dataDir, reg), portsCmd, passthroughScan(seen)); + expect(res.ok && res.data).toEqual({ ports: [], cdpPort: null, screen: null }); + const parsed = parseNodePreviewPortsResult(res.ok ? res.data : null); + expect(parsed).toEqual({ ports: [], cdpPort: null, screen: null }); + expect(parsed?.screenLost).toBeUndefined(); + }); + + it("the dead-marker shape screen null + screenLost rides the validator with the marker SURVIVING the rebuild", async () => { + // Task 1 Step 5's hand-rolled validator strips unlisted members, so this + // is the only line between the node naming the loss and Task 14's manual + // leg seeing nothing. + const dataDir = onDir("ports-lost"); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + spawns()[1]?.child.fireExit(); // x11vnc dies mid-pane + await flush(); + + const seen: unknown[] = []; + const res = await execPreviewPorts(portsCtx(dataDir, reg), portsCmd, passthroughScan(seen)); + expect(res.ok && res.data).toEqual({ ports: [], cdpPort: null, screen: null, screenLost: "x11vnc" }); + const parsed = parseNodePreviewPortsResult(res.ok ? res.data : null); + expect(parsed).not.toBeNull(); + expect(parsed?.screen).toBeNull(); + expect(parsed?.screenLost).toBe("x11vnc"); // the pairing rule: null screen + named loss + }); + + it("a context with no display registry answers screen null (every pre-Task-7 fake keeps compiling)", async () => { + const dataDir = onDir("ports-no-registry"); + const seen: unknown[] = []; + const res = await execPreviewPorts(portsCtx(dataDir), portsCmd, passthroughScan(seen)); + expect(res.ok && res.data).toEqual({ ports: [], cdpPort: null, screen: null }); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the switch-off gate, on both entry points */ +/* ------------------------------------------------------------------ */ + +describe("the preview switch, off", () => { + it("openFor refuses preview-node-off BEFORE any spawn, lock, or directory", async () => { + const dataDir = join(base, "off-open", "data"); + mkdirSync(dataDir, { recursive: true }); // mirror file deliberately absent + const { deps, calls } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_NODE_OFF); + expect(calls).toHaveLength(0); + expect(existsSync(join(dataDir, "displays"))).toBe(false); + }); + + it("the launch.screen path refuses naming the switch with the same voice, before the meta record", async () => { + const dataDir = join(base, "off-launch", "data"); + mkdirSync(dataDir, { recursive: true }); + const { deps, calls } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + const { ctx, calls: tmuxCalls, events } = makeLaunchCtx(dataDir, reg); + const res = await execLaunch(ctx, launchCmd({ screen: { width: 640, height: 480 } })); + expect(res).toEqual({ ok: false, error: PREVIEW_NODE_OFF }); + expect(tmuxCalls).toHaveLength(0); // the pane never spawned + expect(calls).toHaveLength(0); // neither did a display + expect(events).toHaveLength(0); + expect(await ctx.meta.get(PANE)).toBeUndefined(); // refused BEFORE the meta record + expect(existsSync(join(dataDir, "displays"))).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* pane death + the sweep */ +/* ------------------------------------------------------------------ */ + +describe("pane death and the orphan sweep", () => { + it("a pane death reported through the exit watcher closes its display (SIGTERM both children, lock unlinked)", async () => { + const dataDir = onDir("death-close"); + const { deps, spawns, killPids } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + const pids = spawns().map((s) => s.child.pid); + + const { ctx } = makeLaunchCtx(dataDir, reg); + // The record rides on ctx.meta's OWN store: the meta store's authoritative + // mirror is per-instance, and two stores over one dir would test the + // cache, not the death report. + await ctx.meta.record({ + subshellId: PANE, + cwd: base, + socket: "sock", + harnessId: "claude-code", + name: "m", + startedAt: STAMP, + }); + ctx.watchers.set(PANE, { socket: "sock", token: Symbol(PANE), unreachable: 0 }); + await runExitWatchTick(ctx); + + expect(pids.every((p) => killPids().includes(p))).toBe(true); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(reg.getFor(PANE)).toBeNull(); + expect(await ctx.meta.get(PANE)).toBeUndefined(); // the death itself still reported + }); + + it("sweepOrphans closes a REGISTERED display whose pane id is absent from the alive list", async () => { + const dataDir = onDir("sweep-registered"); + const { deps, spawns, killPids } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + const pids = spawns().map((s) => s.child.pid); + + const r = await reg.sweepOrphans(["someone-else"]); + expect(r.reaped).toBe(1); + expect(reg.getFor(PANE)).toBeNull(); + expect(pids.every((p) => killPids().includes(p))).toBe(true); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + }); + + it("the restart trio: a live pane's dir rehydrates, a dead pane's dir is reaped, and a live pane whose recorded children are gone is reaped, not rehydrated", async () => { + // A plain daemon restart clears the in-memory registry but leaves the + // dirs; a service-manager restart can take the whole cgroup while tmux + // and the pane survive, leaving a dir whose sockets answer nothing. Only + // the DISK walk can tell these apart, which is why this case rebuilds the + // module state against the same temp dataDir. + const dataDir = onDir("restart-trio"); + const LIVE = "11111111-2222-4333-8444-555555555555"; + const DEAD = "22222222-2222-4222-8222-222222222222"; + const GONE = "33333333-3333-4333-8333-333333333333"; + + const shared = makeDeps(); // ONE busy/alive world across all three opens + const reg1 = new PreviewDisplays(dataDir, shared.deps); + const rLive = await reg1.openFor(LIVE, { width: 640, height: 480 }); // :90 / 5910 + const rDead = await reg1.openFor(DEAD, { width: 640, height: 480 }); // :91 / 5911 + const rGone = await reg1.openFor(GONE, { width: 640, height: 480 }); // :92 / 5912 + expect([rLive.display, rDead.display, rGone.display]).toEqual([":90", ":91", ":92"]); + + // The DEAD record BEFORE the restart wipes the process world: the sweep + // must at least PROBE its pids (a dead pid is skipped, never SIGTERM'd). + const deadRecord = JSON.parse(readFileSync(join(dataDir, "displays", DEAD, "display.json"), "utf8")) as { + xvfbPid: number; + x11vncPid: number; + }; + + // Age the dirs past the sweep's launch-window floor: the pass that matters + // here is about crashes, not races, and the ssh sweep's floor rule is + // cloned verbatim. + ageOut( + join(dataDir, "displays", LIVE), + join(dataDir, "displays", DEAD), + join(dataDir, "displays", GONE), + join(dataDir, "displays", ".display-90.lock"), + join(dataDir, "displays", ".display-91.lock"), + join(dataDir, "displays", ".display-92.lock"), + ); + + // The cleared in-memory registry: a NEW instance over the same data dir, + // with the rfb sockets that survived re-verified (5910 and 5911 still + // occupied) and GONE's 5912 refusing - the cgroup took it. + shared.busy.delete(5912); + shared.alivePids.clear(); // the restart took every child process down with it + const reg2 = new PreviewDisplays(dataDir, shared.deps); + expect(reg2.getFor(LIVE)).toBeNull(); + + const r = await reg2.sweepOrphans([LIVE, GONE]); // LIVE and GONE's panes survived tmux-wise; DEAD did not + + // LIVE: children alive, so the registry entry comes back from + // display.json and the ports-style read answers again. + expect(reg2.getFor(LIVE)).toEqual({ display: ":90", rfbPort: 5910 }); + expect(existsSync(join(dataDir, "displays", LIVE))).toBe(true); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(true); + + // DEAD: reaped, dir and lock gone. + expect(reg2.getFor(DEAD)).toBeNull(); + expect(existsSync(join(dataDir, "displays", DEAD))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-91.lock"))).toBe(false); + + // GONE: pane alive but children dead -> REAP, not rehydrate. Installing a + // record for a dead socket would strand the Screen affordance and aim + // every later openFor at `preview-display-not-ready` forever. + expect(reg2.getFor(GONE)).toBeNull(); + expect(existsSync(join(dataDir, "displays", GONE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-92.lock"))).toBe(false); + + expect(r.reaped).toBe(2); + expect(r.rehydrated).toBe(1); + // The reap went through the kill seam: DEAD's recorded children were + // probed (and, being dead, never struck). + const probed = shared.probes(); + expect(probed).toContain(deadRecord.xvfbPid); + expect(probed).toContain(deadRecord.x11vncPid); + const struck = new Set(shared.killPids()); + expect(struck.has(deadRecord.xvfbPid)).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the launch.screen path */ +/* ------------------------------------------------------------------ */ + +describe("the launch.screen path", () => { + it("opens the display before assembling, injects DISPLAY/XAUTHORITY into the pane env, builds the exit hook from that same env, and echoes screen in the result", async () => { + const dataDir = onDir("launch-happy"); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + const { ctx, calls } = makeLaunchCtx(dataDir, reg); + let paneCmd = ""; + let hook = ""; + try { + // Re-stub newSubshell to capture the assembled pane command and hook. + (ctx.tmux as unknown as { newSubshell: (...a: unknown[]) => void }).newSubshell = (...args: unknown[]) => { + calls.push({ method: "newSubshell", args }); + paneCmd = String(args[3]); + hook = String(args[4] ?? ""); + }; + const res = await execLaunch( + ctx, + launchCmd({ + screen: { width: 1280, height: 1024 }, + // The preset overrides a REPORTER key: the hook string carries only + // those three keys, so a preset-sourced value inside it is what + // proves the hook env came from the full paneEnvFor chain (the one + // map DISPLAY rides at :267) rather than raw subshellEnv. + preset: { + name: "p", + env: { SUBSHELL_BASE_URL: "http://from-preset" }, + flags: [], + settings: null, + configIsolation: false, + }, + }), + ); + expect(res).toEqual({ ok: true, data: { screen: { display: ":90", rfbPort: 5910 } } }); + } finally { + // disarm the real exit watcher execLaunch armed, before its first tick + ctx.watchers.clear(); + clearInterval(ctx.watchTick); + ctx.watchTick = undefined; + } + expect(spawns()).toHaveLength(2); + expect(paneCmd).toContain("DISPLAY=':90'"); + expect(paneCmd).toContain(`XAUTHORITY='${xauthorityPath(dataDir, PANE)}'`); + // The death report must be built from the pane's env, not a partial slice + // of it (launch.ts's parity rule): the hook string is assembled from the + // SAME paneEnvFor map the pane command is, and the display layer rides + // that map too. `exitHookFor` keeps only the credential keys in the + // string, so the observable agreement is the preset-sourced credential + // showing through; the display-in-the-map property is pinned by the + // pane-runtime trio (one map, two consumers). + expect(hook).not.toBe(""); + expect(hook).toContain("SUBSHELL_BASE_URL='http://from-preset'"); + expect(hook).toContain("'exit'"); + }); + + it("a display that refuses fails the launch with its named reason and rolls back the meta record", async () => { + const dataDir = onDir("launch-refuses"); + const { deps } = makeDeps({ which: { Xvfb: null } }); + const reg = new PreviewDisplays(dataDir, deps); + const { ctx, calls } = makeLaunchCtx(dataDir, reg); + await expect(execLaunch(ctx, launchCmd({ screen: { width: 640, height: 480 } }))).rejects.toThrow( + `${PREVIEW_DISPLAY_MISSING_BINARIES}: Xvfb`, + ); + expect(calls.some((c) => c.method === "newSubshell")).toBe(false); + expect(await ctx.meta.get(PANE)).toBeUndefined(); // meta recorded, then forgotten + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* shared launch-context harness (shape copied from */ +/* commands-launch.test.ts, trimmed to what these cases exercise) */ +/* ------------------------------------------------------------------ */ + +interface RecordedCall { + method: string; + args: unknown[]; +} + +function makeLaunchCtx( + dataDir: string, + previewDisplays: PreviewDisplays, +): { ctx: CommandContext; calls: RecordedCall[]; events: NodeEvent[] } { + const calls: RecordedCall[] = []; + const events: NodeEvent[] = []; + const method = + (name: string, impl?: (...a: unknown[]) => unknown) => + (...args: unknown[]): unknown => { + calls.push({ method: name, args }); + if (!impl) throw new Error(`fake tmux: unstubbed call ${name}(${JSON.stringify(args)})`); + return impl(...args); + }; + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const ctx: CommandContext = { + config, + tmux: { + newSubshell: method("newSubshell", () => undefined), + pipePane: method("pipePane", () => undefined), + resizeWindow: method("resizeWindow", () => undefined), + listSubshellsChecked: method("listSubshellsChecked", () => ({ ok: true, names: [] })), + paneExitCode: method("paneExitCode", () => null), + killSubshell: method("killSubshell", () => undefined), + hasSubshell: method("hasSubshell", () => false), + } as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => Date.parse("2026-10-22T00:00:00.000Z"), + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + previewDisplays, + }; + return { ctx, calls, events }; +} + +type LaunchCmd = Cmd<"launch">; + +/** The server-shaped launch frame, screen left to the caller. */ +function launchCmd(over: Partial = {}): LaunchCmd { + return { + type: "launch", + subshellId: PANE, + socket: "subshell-preview-display-test", + cwd: base, + harnessId: "claude-code", + preset: { name: "p", env: {}, flags: [], settings: null, configIsolation: false }, + // The full REPORTER_ENV_KEYS set: `exitHookFor` registers nothing without + // all three, and the hook is one thing this frame exists to observe (it + // must carry the same display layer as the pane command). + subshellEnv: { SUBSHELL_API_KEY: "k", SUBSHELL_BASE_URL: "http://plane.invalid", SUBSHELL_ID: PANE }, + subshellName: "s", + cols: 120, + rows: 30, + argv: [HARNESS_BINARY_PLACEHOLDER], + resolve: { binaryName: "claude", envOverride: "CLAUDE_PATH" }, + ...over, + } as unknown as LaunchCmd; +} diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index a826ab466..e98053827 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -10,6 +10,7 @@ import type { RelayFrame, } from "@internal/subshell-protocol"; import type { NodeConfig } from "../config.js"; +import type { PreviewDisplays } from "../preview/display.js"; import type { PreviewForward, PreviewLaneEvent } from "../preview/forward.js"; import type { ServiceDeps } from "../service.js"; import type { SubshellMetaStore } from "../subshell-meta.js"; @@ -151,6 +152,18 @@ export interface CommandContext { * fake-compiling reason as {@link previewForward}. */ sendPreviewEvent?: (ev: PreviewLaneEvent) => void; + /** + * The virtual-display registry (pane-preview spec §5, Task 7): the + * per-daemon `Xvfb` + `x11vnc` allocation the `launch` frame's `screen` + * path opens into, the ports arm reads for its `screen` half, and the + * death report closes. OPTIONAL by the same fake-compiling reason as + * {@link previewForward}; the real daemon always provides it. The launch + * arm refuses LOUDLY when a `screen` launch meets a context without one + * (a machine silently dropping the screen it was asked for would be the + * bug), while the ports arm answers `screen: null` without it, which is + * exactly the pre-Task-7 behavior every fake keeps. + */ + previewDisplays?: PreviewDisplays; /** * The panes supervised for natural death, keyed by subshellId → * {@link WatcherRegistration} (socket + registration token + unreachable diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index cce9f6532..47df22359 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -14,9 +14,13 @@ import { DIR_REFUSED_MESSAGE, launchDirAllowed, readAllowedDirs } from "../allow import { log } from "../log.js"; import { readMaintenance, reportableMaintenance } from "../maintenance.js"; import { pathAllowed, realpathRoots } from "../path-policy.js"; +import { PREVIEW_DISPLAY_UNSUPPORTED, xauthorityPath } from "../preview/display.js"; +import { getPreviewEnabled } from "../preview/flags.js"; +import type { PreviewScreenHandle } from "../preview/ports.js"; import { selfInvocation } from "../self-invoke.js"; import { isSubshellId } from "../subshell-meta.js"; import type { Cmd, CommandContext, CommandResult } from "./context.js"; +import { PREVIEW_NODE_OFF } from "./preview-ports-exec.js"; import { reportMaintenance, startExitWatcher } from "./report.js"; import { gateOpen, SSH_GATE_REFUSAL } from "./ssh-shared.js"; @@ -185,6 +189,24 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await enforceMode(derivedPath, 0o600); // umask cannot leak bits past this } + // (3c) Virtual display gate (pane-preview spec §5, Task 7). A `screen` + // launch is refused BEFORE the meta record and before anything spawns, in + // the same order the allocation itself runs: platform first (a machine + // without an X stack says so by name), then the mirror (switch off refuses + // with the one voice every preview entry point uses, the constant pinned + // in preview-ports-exec.ts), then the loud refusal a context without the + // display registry earns - silently dropping the screen the plane asked + // for would be the bug here, not an error message. openFor checks the same + // gates again for its own sake; this block is the launch-side "refuse + // before the record", which is the half reviewers flagged. + if (cmd.screen) { + if (process.platform !== "linux") return { ok: false, error: PREVIEW_DISPLAY_UNSUPPORTED }; + if (getPreviewEnabled(ctx.config.dataDir)?.on !== true) return { ok: false, error: PREVIEW_NODE_OFF }; + if (!ctx.previewDisplays) { + return { ok: false, error: "preview display registry unavailable in this daemon context" }; + } + } + // (4) Record meta FIRST: path policy + socket lookup must see the cwd even // while later steps are in flight (and the rollback below has something to // roll back). `record` creates /subshells with 0700. @@ -205,8 +227,26 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi // same field names, the frame validator already ran — so the cast is the // intended decode, exactly where the brief pins it. const preset = cmd.preset as unknown as PresetDefinition; + let screenHandle: PreviewScreenHandle | undefined; try { - const paneCmd = assembleHarnessCommand(argv, preset, cmd.subshellEnv, mcpPaneEnv); + // (5a) Allocate the pane's display BEFORE assembling (spec §5, Task 7): + // the refusal must fail the launch with its named reason before tmux is + // asked for a pane, and the allocated numbers are what the env layer + // below carries. The handle is echoed in the launch result (Task 1's + // `screen` echo) so the plane learns the display without a re-read. + if (cmd.screen && ctx.previewDisplays) { + screenHandle = await ctx.previewDisplays.openFor(cmd.subshellId, cmd.screen); + } + // The node-allocated pane env: DISPLAY/XAUTHORITY for a screen pane's + // virtual display, named here as the deliberate addition to the chain + // the SUBSHELL_* credentials are - it rides ABOVE the preset in + // paneEnvFor because it describes a resource this machine minted FOR + // this pane. `xauthorityPath` is the same byte-derived path display.ts + // minted the cookie into; the two must agree or the pane cannot connect. + const displayEnv = screenHandle + ? { DISPLAY: screenHandle.display, XAUTHORITY: xauthorityPath(ctx.config.dataDir, cmd.subshellId) } + : undefined; + const paneCmd = assembleHarnessCommand(argv, preset, cmd.subshellEnv, mcpPaneEnv, displayEnv); // The pane reports its OWN death to the control plane, through the same // builder the plane's local launcher uses (spec 2026-09-19 §4.3). It needs // no frame and no protocol bump: the pane already holds the plane's @@ -220,10 +260,17 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi // The SAME layers the pane command is assembled from — including the // preset's, which this used to omit while the plane omitted the MCP // wiring too. Two partial slices of one precedence chain is how a report - // ends up aimed at a different plane from the pane that sent it. - const exitHook = exitHookFor(selfInvocation("report"), paneEnvFor(cmd.subshellEnv, preset, mcpPaneEnv)); + // ends up aimed at a different plane from the pane that sent it. The + // display layer rides this map too: the hook string itself keeps only the + // credential keys, but one map for both consumers is the agreement, so + // the report is built from the same env the pane got. + const exitHook = exitHookFor(selfInvocation("report"), paneEnvFor(cmd.subshellEnv, preset, mcpPaneEnv, displayEnv)); ctx.tmux.newSubshell(cmd.socket, cmd.subshellId, cmd.cwd, paneCmd, exitHook); } catch (err) { + // Roll back in the order things were created: a display this launch + // opened but never got a pane for is closed FIRST (its pids are ours), + // then the meta record goes as it always has. + if (screenHandle) ctx.previewDisplays?.closeFor(cmd.subshellId); await ctx.meta.forget(cmd.subshellId); // nothing spawned — no orphan root for the policy throw err; // dispatcher answers {ok:false}; mirrors LocalLauncher's throw path } @@ -276,5 +323,9 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi // relaunch having re-asserted its meta by the time a stale tick could // forget it. Do not sink startExitWatcher below a meta gap. startExitWatcher(ctx, cmd.subshellId, cmd.socket); - return { ok: true }; + // The `screen` echo (Task 1's launch-result member): a screen pane's + // answer carries the allocated pair so the plane can open its Screen + // affordance without a `preview_ports_get` round trip; a plain launch + // answers exactly `{ ok: true }`, as it always did. + return screenHandle ? { ok: true, data: { screen: screenHandle } } : { ok: true }; } diff --git a/apps/node/agent/src/commands/preview-ports-exec.ts b/apps/node/agent/src/commands/preview-ports-exec.ts index aee9eb07a..72d72d42d 100644 --- a/apps/node/agent/src/commands/preview-ports-exec.ts +++ b/apps/node/agent/src/commands/preview-ports-exec.ts @@ -19,12 +19,16 @@ import type { Cmd, CommandContext, CommandResult } from "./context.js"; * `preview-ports-pane-gone` means the server says the pane is gone (a tmux * error reads the same and is answered the same: the plane re-checks). * - * The `screen` half is HARDWIRED null for this task: the display registry is - * Task 7's, and until it exists this node allocates no displays. The arm - * passes `screen: null` to the scan and returns what the scan echoes, so - * Task 7 amends exactly one call site. `screenLost` (the validator's - * companion member) is likewise never set here — there is no display to have - * lost. + * The `screen` half comes from the display registry (Task 7): a pane with a + * registered display answers `screen: { display, rfbPort }` - decision 6's + * rfbPort discriminator, the field that tells the plane a raw-RFB open is + * possible - and an unregistered one answers `screen: null`. When the + * registry's dead-mid-pane marker names a binary that died while the pane + * lived, the answer pairs the null with `screenLost: ` (the + * validator's pairing rule refuses the two non-null together): the SPA can + * say WHY the Screen affordance is gone instead of silence. A context with + * no registry handle (every pre-Task-7 fake) answers null exactly like the + * old hardwire. */ /** The one-word switch-off refusal every preview arm shares, with the operator's remedy named. */ @@ -57,6 +61,16 @@ export async function execPreviewPorts( const panePid = await ctx.tmux.panePid(socket, cmd.paneId); if (panePid === null) return { ok: false, error: PREVIEW_PORTS_PANE_GONE }; const scan = seams?.scan ?? scanPanePorts; - const r = await scan({ panePid, screen: null }); - return { ok: true, data: { ports: r.ports, cdpPort: r.cdpPort, screen: r.screen } }; + // The display registry read (Task 7's wiring): a registered display rides + // the scan as the plane's rfbPort discriminator, and the dead-mid-pane + // marker pairs with the null exactly the validator's pairing rule wants. + // No registry handle answers null, which is what every pre-Task-7 fake + // (and every pane without a screen) still gets. + const screen = ctx.previewDisplays?.getFor(cmd.paneId) ?? null; + const screenLost = screen === null ? (ctx.previewDisplays?.lostFor(cmd.paneId) ?? null) : null; + const r = await scan({ panePid, screen }); + return { + ok: true, + data: { ports: r.ports, cdpPort: r.cdpPort, screen: r.screen, ...(screenLost ? { screenLost } : {}) }, + }; } diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index ef7f28efc..51a70ca6c 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -500,6 +500,19 @@ async function reportDeath(ctx: CommandContext, socket: string, subshellId: stri log(`ssh config sweep failed for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`); } } + // Close the pane's virtual display (pane-preview spec §5: "the sweep and + // the death report close displays"). Same re-check, same best-effort + // posture: the pane was the display's only reader, so a death without + // this leaves an Xvfb + x11vnc pair and a locked `:90..:199` number + // running forever. `closeFor` deregisters before it signals, so the + // child-exit watch reads our own kill as teardown, never as a loss; + // a throwing reap costs nothing here. A pane that never asked for a + // screen pays an entry miss and one absent-dir check. + try { + ctx.previewDisplays?.closeFor(subshellId); + } catch (err) { + log(`display close failed for ${subshellId}: ${err instanceof Error ? err.message : String(err)}`); + } } } diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index ba16fd943..08201c4db 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -45,6 +45,7 @@ import { clearLock, writeLock } from "./lock.js"; import { log } from "./log.js"; import { createRetentionPass, PANE_LOG_RETENTION_PASS_MS, resolveLogRetention } from "./pane-log-retention.js"; import { bridgeCloseAll } from "./preview/cdp-bridge.js"; +import { PreviewDisplays } from "./preview/display.js"; import { PreviewForward, setActivePreviewForward } from "./preview/forward.js"; import { createDaemonRelaySend } from "./relay-send.js"; import { noteSweepScheduled } from "./retention-settings.js"; @@ -521,12 +522,20 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // design; a raw TCP lane's peer is the proxy, which died with the socket). // Production mechanics are the defaults: real net, unref'd real timers. const previewForward = new PreviewForward(); - // Publish it as the node-wide count source the SHARED preview budget reads - // (`forwardCount()`), the same way `relaySessions` is this loop's registry: - // the bridge branch of `preview_open` counts lanes through the module handle - // because the sum is one number, not two habits. Cleared in the finally - // below, like the dashboard's restart hook. + // Publish it as the node-wide count source (`forwardCount()`) so callers + // that hold no instance still see the truth. The `preview_open` executors + // themselves count lanes through `ctx.previewForward` on BOTH rungs - the + // lane+bridge sum is one number read at one moment, and the context handle + // is the same object this call published. Cleared in the finally below, + // like the dashboard's restart hook. setActivePreviewForward(previewForward); + // The virtual-display registry (pane-preview spec §5, Task 7): PER-DAEMON + // like its siblings. Displays deliberately do NOT die with the link the way + // lanes do: a pane keeps its screen across a reconnect (its terminal never + // blinked), and it is the pane's death - or the hourly sweep's census - + // that closes one. No publish-and-clear dance: nothing counts displays + // through a module handle; the ctx member is the only route. + const previewDisplays = new PreviewDisplays(config.dataDir); // PER-PROCESS executor context (spec §3.4/§7): built once, survives every // reconnect. `ws` is a STABLE wrapper routing to the CURRENT socket — @@ -585,6 +594,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // ref→lane registry plus its event leg, which IS the relay pump above. previewForward, sendPreviewEvent: laneSend, + previewDisplays, requestRestart: () => { // Deferred, because the daemon is the only sender of `result`: the // executor returns `{ ok: true }`, the frame leaves on this turn, and @@ -705,6 +715,26 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom }); }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); sshDirSweepTimer.unref?.(); + // Orphaned virtual displays (spec §5, Task 7 Step 5): boot pass plus the + // same hourly beat, unref'd and fire-and-forget like the two above. The + // sweep needs the ALIVE CENSUS, and the census is the pane-log-retention + // shape, not a tmux list: one `hasSubshell` per meta record, and a probe + // that cannot answer counts the pane ALIVE (unknown is not dead - the same + // rule the exit watcher's blip counter exists for). Dirs with no meta row + // cannot be probed at all and ride the sweep's one-hour launch floor. + const sweepDisplays = async (): Promise => { + const alive = await collectLivePaneIds(ctx); + await previewDisplays.sweepOrphans(alive); + }; + void sweepDisplays().catch((err: unknown) => { + log(`display sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + const displaySweepTimer = setInterval(() => { + void sweepDisplays().catch((err: unknown) => { + log(`display sweep failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); + displaySweepTimer.unref?.(); // SERIAL command executor (spec §3.4): verified commands queue here so pane // effects land in arrival order across the whole daemon life — surviving // reconnects. A command verified pre-`seqTracker.reset` executing post-reset @@ -1535,6 +1565,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom } finally { clearInterval(transferSweepTimer); clearInterval(sshDirSweepTimer); + clearInterval(displaySweepTimer); if (retentionTimer !== undefined) clearInterval(retentionTimer); registerRestart(null); setActivePreviewForward(undefined); @@ -1549,6 +1580,31 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom } } +/** + * The alive-pane census the display sweep needs: every meta record probed on + * its OWN socket with `hasSubshell` (the two-seam shape pane-log-retention + * runs - per-record, not a `list-panes` enumeration), where a probe that + * cannot answer counts the pane ALIVE. "Unknown is not dead" is the same rule + * the exit watcher's unreachable-counter exists for: a wedged tmux server + * must never turn an hourly sweep into a display massacre. + */ +async function collectLivePaneIds(ctx: CommandContext): Promise { + const alive = new Set(); + const records = await ctx.meta.list().catch((err: unknown) => { + log(`display census: meta.list failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + }); + if (!records) return []; + for (const m of records) { + try { + if (await ctx.tmux.hasSubshell(m.socket, m.subshellId)) alive.add(m.subshellId); + } catch { + alive.add(m.subshellId); // unknown is not dead + } + } + return [...alive]; +} + /** Extra seams for {@link probeOnline}. */ export interface OnlineProbeDeps { /** WebSocket client implementation (default `globalThis.WebSocket`). */ diff --git a/apps/node/agent/src/preview/display.ts b/apps/node/agent/src/preview/display.ts new file mode 100644 index 000000000..baf004879 --- /dev/null +++ b/apps/node/agent/src/preview/display.ts @@ -0,0 +1,662 @@ +import { randomBytes } from "node:crypto"; +import { + chmodSync, + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { connect as netConnect } from "node:net"; +import { join } from "node:path"; +import type { NodePreviewScreenLost } from "@internal/subshell-protocol"; +import { PREVIEW_NODE_OFF } from "../commands/preview-ports-exec.js"; +import { log } from "../log.js"; +import { pathAllowed } from "../path-policy.js"; +import { ORPHAN_SSH_DIR_MIN_AGE_MS } from "../ssh-dir-retention.js"; +import { isSubshellId } from "../subshell-meta.js"; +import { getPreviewEnabled } from "./flags.js"; +// The handle type lives in ports.ts (Task 4 defined it for the wire row); a +// TYPE ALIAS there, not the protocol's interface, is what keeps command +// results legal: `CommandResult.data` is `Record`, and an +// interface has no implicit index signature, so the protocol's +// `NodePreviewScreenWire` is structurally identical but refuses to serialize +// through it. The alias satisfies that interface at every use site. +import type { PreviewScreenHandle } from "./ports.js"; + +/** + * Virtual displays (pane-preview spec §5, Task 7): the Xvfb + x11vnc pair a + * `screen` launch allocates, per pane, on this machine. A pane that asks for + * a screen gets its OWN display number - `:90` upward, each claimed through + * an O_EXCL lockfile so two launches can never mint the same one - its own + * xauth cookie minted BEFORE anything spawns, and its own loopback-only RFB + * port, which is what the plane's proxy later dials (decision 6's + * rfbPort discriminator). Everything here is allocation and lifecycle, not + * pixels: the screencast rides the CDP bridge's own plane, and a pane's + * plain terminal keeps streaming whether or not its display is alive. + * + * Two facts earn the ceremony: + * - `xauth add` (not `generate`) is the mint that works with no live display. + * `generate` opens the display to read a cookie - Xvfb is not up yet - and + * its `-f` is a GLOBAL option that a post-subcommand `-f` silently stops + * honoring; both halves measured on a real host (rc=1). The pane's + * `XAUTHORITY` and x11vnc's `-auth` read the SAME file, so whatever cookie + * Xvfb ends up writing into it is the one both sides use. + * - The daemon's own kills go through {@link PreviewDisplays.closeFor} (or + * the sweep), which deregisters BEFORE signalling, so the child-exit watch + * never mistakes a teardown for a loss. Only a mid-pane vanish - a child + * dying while the entry is still registered - marks + * {@link PreviewDisplays.lostFor}, and it marks without touching the pane: + * spec §9's posture is a darkened Screen affordance that can say WHY, not + * a killed terminal. + * + * Durability is deliberately half-hearted: `display.json` records the pids + * incrementally (every crash window leaves the pids that exist) so the + * restart sweep can kill or re-verify children the in-memory registry lost, + * but the `lostFor` marker is memory-only - after a daemon restart the + * affordance simply disappears, safely, because the sweep has already freed + * the lock and dir the dead display left. + */ + +/** The display-number range spec §5 bounds: nothing on a node lives below `:90`. */ +const DISPLAY_MIN = 90; +const DISPLAY_MAX = 199; +/** Where the RFB chooser starts. 5900-5909 stay the range of real X displays. */ +const RFB_PORT_MIN = 5910; +const RFB_PORT_PROBES = 100; +/** How long Xvfb and x11vnc get to come up before the allocation is refused. */ +const DEFAULT_READY_BUDGET_MS = 5000; +/** Readiness poll cadence. */ +const READY_POLL_MS = 100; + +/** The platform refusal, named by the switch's spec §5 rule: no X server stack, out loud. */ +export const PREVIEW_DISPLAY_UNSUPPORTED = "preview-display-unsupported"; +/** Missing-binary refusal; the thrown message names the binary (`: x11vnc`). */ +export const PREVIEW_DISPLAY_MISSING_BINARIES = "preview-display-missing-binaries"; +/** Nothing came up inside the budget (or no number/port was free); detail names which. */ +export const PREVIEW_DISPLAY_NOT_READY = "preview-display-not-ready"; + +/** + * The X auth file for one pane's display, byte-derived from `dataDir` the + * same way ssh-render derives the per-pane config path (the mirror's pane-id + * guard applies; the same shape feeds the launch env's `XAUTHORITY`). + */ +export function xauthorityPath(dataDir: string, paneId: string): string { + return join(dataDir, "displays", paneId, "Xauthority"); +} + +/** A spawned display child, narrowed to what a lane of this registry uses. */ +export interface DisplayChild { + pid: number; + /** Resolves with the exit code; the watch treats a rejection like an exit. */ + exited: Promise; +} + +/** The mechanics a caller (or a test) can replace. Production passes none. */ +export interface DisplayDeps { + /** Spawn a long-lived child with BOTH streams appended to `logPath`. Defaults to `Bun.spawn` over an O_APPEND 0600 fd. */ + spawn?: (argv: string[], opts: { logPath: string }) => DisplayChild; + /** Run to completion; returns the exit code. Defaults to `Bun.spawnSync`. */ + spawnSync?: (argv: string[]) => number; + /** The ONE binary-lookup seam (wraps `Bun.which`): the refusal names the missing binary. */ + which?: (name: string) => string | null; + /** Readiness poll sleep. */ + sleep?: (ms: number) => Promise; + /** Clock (budget math); defaults to `Date.now`. */ + now?: () => number; + /** + * Is `port` already occupied? The chooser SKIPS occupied ports; readiness + * and rehydration POLL for the port to answer YES (x11vnc listening). + * One seam, both directions, the way one bind-probe answers both questions. + */ + bindProbe?: (port: number) => Promise; + /** Signal a pid (0 = liveness probe). Throws ESRCH-style for dead pids, like `process.kill`. */ + kill?: (pid: number, signal?: number | string) => void; + /** Does `/tmp/.X11-unix/X` exist? Its own seam because `-nolisten tcp` leaves no port to bind-probe. */ + xSocketReady?: (displayNumber: number) => boolean; + /** Readiness budget; defaults to {@link DEFAULT_READY_BUDGET_MS}. */ + readyBudgetMs?: number; +} + +/** The one record the sweep reads off disk. Written incrementally, newest field last. */ +interface DisplayRecord { + display?: unknown; + rfbPort?: unknown; + xvfbPid?: unknown; + x11vncPid?: unknown; +} + +interface DisplayEntry { + paneId: string; + display: string; + displayNumber: number; + rfbPort: number; + xvfbPid: number; + x11vncPid: number; + /** Spawned handles when THIS process started the display; undefined on a rehydrated entry. */ + xvfbChild?: DisplayChild; + vncChild?: DisplayChild; +} + +/** What a `sweepOrphans` pass did, for the one log line it earns. */ +export interface DisplaySweepResult { + reaped: number; + rehydrated: number; + locksFreed: number; +} + +const bunSpawn = (argv: string[], opts: { logPath: string }): DisplayChild => { + // One O_APPEND fd opened at 0600 serves BOTH streams: two Bun-file + // redirects would each truncate the same path. The child inherits the fd; + // this copy closes the moment spawn returns. + const fd = openSync(opts.logPath, "a", 0o600); + try { + const child = Bun.spawn(argv, { stdin: "ignore", stdout: fd, stderr: fd }); + return { pid: child.pid, exited: child.exited }; + } finally { + closeSync(fd); + } +}; + +const bunSpawnSync = (argv: string[]): number => { + const r = Bun.spawnSync(argv, { stdin: "ignore" }); + return r.exitCode ?? 1; +}; + +const bunWhich = (name: string): string | null => Bun.which(name) ?? null; + +const realSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Is something already listening on this loopback port? A connect SUCCEEDING + * means occupied (the probe's socket is destroyed instantly - an RFB server + * just sees a client blink); a refused connect means free; a HANG counts as + * occupied because a socket that accepts-nothing still reserves the port. + */ +const portOccupied = (port: number): Promise => + new Promise((resolve) => { + const s = netConnect({ host: "127.0.0.1", port }); + let done = false; + const finish = (v: boolean): void => { + if (done) return; + done = true; + s.destroy(); + resolve(v); + }; + s.setTimeout(500); + s.on("connect", () => finish(true)); + s.on("timeout", () => finish(true)); + s.on("error", () => finish(false)); + }); + +const socketExists = (displayNumber: number): boolean => existsSync(`/tmp/.X11-unix/X${displayNumber}`); + +/** + * Per-daemon display registry, exactly like `RelaySessions` and + * `PreviewForward`: built at boot, injected through + * {@link import("../commands/context.js").CommandContext}, and nothing out + * of it survives the process except the dirs and locks the sweep reconciles. + */ +export class PreviewDisplays { + readonly dataDir: string; + private readonly defaults: DisplayDeps; + private readonly entries = new Map(); + /** paneId -> the binary that died mid-pane; memory-only by the header's reason. */ + private readonly lost = new Map(); + + constructor(dataDir: string, deps: DisplayDeps = {}) { + this.dataDir = dataDir; + this.defaults = deps; + } + + /** The registered display for this pane, or null (the ports arm's `screen` half). */ + getFor(paneId: string): PreviewScreenHandle | null { + const e = this.entries.get(paneId); + return e ? { display: e.display, rfbPort: e.rfbPort } : null; + } + + /** The named mid-pane loss for this pane, or null. Set by the exit watch, cleared by the next open. */ + lostFor(paneId: string): NodePreviewScreenLost | null { + return this.lost.get(paneId) ?? null; + } + + /** Registered displays node-wide (the shared-budget read; also handy to tests). */ + count(): number { + return this.entries.size; + } + + /** + * Allocate the pane's display: platform -> mirror -> binaries -> lock -> + * files -> Xvfb -> RFB port -> x11vnc -> readiness, and a REFUSAL throws + * (the dispatcher answers `{ ok: false, error: message }`) with whatever + * it started already torn down. The gate order is the spec's: the switch + * is checked HERE so every entry point refuses with one voice, and the + * binaries are checked before the lock/dir exist, so a machine that cannot + * serve a screen leaves no litter. + * + * Idempotent for a registered pane (a relaunch re-reads its own handle and + * spawns nothing). `over` layers per-call seams on the constructor's. + */ + async openFor( + paneId: string, + size: { width: number; height: number }, + over?: DisplayDeps, + ): Promise { + const d = this.deps(over); + if (process.platform !== "linux") throw new Error(PREVIEW_DISPLAY_UNSUPPORTED); + if (getPreviewEnabled(this.dataDir)?.on !== true) throw new Error(PREVIEW_NODE_OFF); + if (!isSubshellId(paneId)) throw new Error("invalid subshell id"); + const existing = this.entries.get(paneId); + if (existing) return { display: existing.display, rfbPort: existing.rfbPort }; + + const xvfbBin = d.which("Xvfb"); + if (!xvfbBin) throw new Error(`${PREVIEW_DISPLAY_MISSING_BINARIES}: Xvfb`); + const vncBin = d.which("x11vnc"); + if (!vncBin) throw new Error(`${PREVIEW_DISPLAY_MISSING_BINARIES}: x11vnc`); + const xauthBin = d.which("xauth"); + if (!xauthBin) throw new Error(`${PREVIEW_DISPLAY_MISSING_BINARIES}: xauth`); + + const root = join(this.dataDir, "displays"); + mkdirSync(root, { recursive: true, mode: 0o700 }); + const displayNumber = this.claimNumber(root); + const display = `:${displayNumber}`; + const lockPath = join(root, `.display-${displayNumber}.lock`); + const paneDir = join(root, paneId); + const auth = xauthorityPath(this.dataDir, paneId); + let xvfbChild: DisplayChild | undefined; + let vncChild: DisplayChild | undefined; + try { + // The lock's CONTENT names the holder - a human reading `.display-91.lock` + // mid-incident wants the pane, and the sweep pairs dir-to-lock anyway. + writeFileSync(lockPath, `${paneId}\n`, { mode: 0o600 }); + mkdirSync(paneDir, { recursive: true, mode: 0o700 }); + + // Mint the cookie BEFORE anything spawns (the header's xauth account). + const cookie = randomBytes(16).toString("hex"); + const rc = d.spawnSync([xauthBin, "-f", auth, "add", display, ".", cookie]); + if (rc !== 0) throw new Error(`${PREVIEW_DISPLAY_NOT_READY}: xauth add failed (rc ${rc})`); + chmodSync(auth, 0o600); // whoever created the file, this module's file mode is 0600 + + // Pre-create both logs at 0600 (the spawn fd appends; the mode is set here). + const xvfbLog = join(paneDir, "Xvfb.log"); + const vncLog = join(paneDir, "x11vnc.log"); + for (const logFile of [xvfbLog, vncLog]) { + const fd = openSync(logFile, "a", 0o600); + closeSync(fd); + } + const envFile = join(paneDir, "display.env"); + writeFileSync(envFile, `DISPLAY=${display}\nXAUTHORITY=${auth}\n`, { mode: 0o600 }); + chmodSync(envFile, 0o600); + + xvfbChild = d.spawn( + [xvfbBin, display, "-auth", auth, "-screen", "0", `${size.width}x${size.height}x24`, "-nolisten", "tcp"], + { logPath: xvfbLog }, + ); + writeRecord(paneDir, { display, xvfbPid: xvfbChild.pid }); + + const rfbPort = await this.chooseRfbPort(d); + vncChild = d.spawn( + [vncBin, "-display", display, "-auth", auth, "-localhost", "-forever", "-shared", "-rfbport", String(rfbPort)], + { logPath: vncLog }, + ); + writeRecord(paneDir, { display, rfbPort, xvfbPid: xvfbChild.pid, x11vncPid: vncChild.pid }); + + await this.awaitReady(displayNumber, rfbPort, d); + + const entry: DisplayEntry = { + paneId, + display, + displayNumber, + rfbPort, + xvfbPid: xvfbChild.pid, + x11vncPid: vncChild.pid, + xvfbChild, + vncChild, + }; + this.entries.set(paneId, entry); + this.lost.delete(paneId); // a fresh display clears the old loss story + this.watchChild(paneId, entry, "Xvfb", xvfbChild); + this.watchChild(paneId, entry, "x11vnc", vncChild); + log(`preview display ${display}: opened for pane ${paneId} (rfb 127.0.0.1:${rfbPort})`); + return { display, rfbPort }; + } catch (err: unknown) { + // Whatever started, none of it outlives the refusal. teardown reads only + // the child pids, so the fields the failure never reached stay zeroed. + this.teardown( + { + paneId, + display, + displayNumber, + rfbPort: 0, + xvfbPid: xvfbChild?.pid ?? 0, + x11vncPid: vncChild?.pid ?? 0, + }, + paneDir, + lockPath, + ); + throw err; + } + } + + /** + * Plane/daemon-ordered teardown of one pane's display: deregister FIRST (so + * the child-exit watch cannot read our own kill as a loss), signal both + * children if they still probe alive, then reap dir and lock. Synchronous + * because its caller (the death report, the sweep) is a plain line of + * cleanup, and an fs failure there must cost a log line, never the report. + */ + closeFor(paneId: string): void { + const entry = this.entries.get(paneId); + if (entry) { + this.entries.delete(paneId); + this.lost.delete(paneId); + this.teardown(entry, join(this.dataDir, "displays", paneId), this.lockPathFor(entry.displayNumber)); + return; + } + // Unregistered belt: a half-open launch left a dir whose lock the record + // still names. Reap the dir and (only when the record is readable) its + // lock; a lock with no record is the sweep's floor-guarded business. + const paneDir = join(this.dataDir, "displays", paneId); + const rec = readRecord(paneDir); + const displayNumber = displayNumberOf(rec?.display); + if (!isSubshellId(paneId) || !existsSync(paneDir)) return; + if (Number.isInteger(displayNumber) && displayNumber > 0) { + this.teardown( + { + paneId, + display: `:${displayNumber}`, + displayNumber, + rfbPort: 0, + xvfbPid: intOf(rec?.xvfbPid), + x11vncPid: intOf(rec?.x11vncPid), + }, + paneDir, + this.lockPathFor(displayNumber), + ); + } else { + try { + rmSync(paneDir, { recursive: true, force: true }); + } catch (err: unknown) { + log(`preview display: closeFor ${paneId}: dir reap failed: ${String(err)}`); + } + } + } + + /** + * The periodic reconciliation (spec §5's "the sweep catches what a crash + * orphaned"), run by the daemon at boot and hourly with the alive-pane + * census. THREE passes, and the first is not the one the brief distrusts: + * registry teardown is nearly free and catches what the death report missed; + * the DISK walk is what a daemon restart makes necessary, because the + * restart cleared the registry - so a live pane's dir rehydrates from + * `display.json` (only while `bindProbe` still answers its recorded RFB + * port: children dead means REAP, not a record that would strand the + * affordance forever on a dead socket), a dead pane's dir is reaped, and the + * ssh retention's one-hour launch floor rides both, because a dir from an + * in-flight `openFor` is not an orphan. + */ + async sweepOrphans(alivePaneIds: string[]): Promise { + const alive = new Set(alivePaneIds); + const d = this.deps(); + let reaped = 0; + let rehydrated = 0; + let locksFreed = 0; + + for (const [paneId, entry] of [...this.entries]) { + if (alive.has(paneId)) continue; + this.entries.delete(paneId); + this.lost.delete(paneId); + this.teardown(entry, join(this.dataDir, "displays", paneId), this.lockPathFor(entry.displayNumber)); + reaped += 1; + } + + const root = join(this.dataDir, "displays"); + let names: string[]; + try { + names = readdirSync(root); + } catch { + return { reaped, rehydrated, locksFreed }; // ENOENT/ENOTDIR: never any displays here + } + const now = d.now(); + for (const name of names) { + if (!isSubshellId(name)) continue; // our own dirs are id-named; anything else is not ours + if (this.entries.has(name)) continue; // registered = pass 1's (or nobody's) business + const path = join(root, name); + try { + const st = lstatSync(path); // never follow a symlink planted beside our layout + if (!st.isDirectory()) continue; + if (st.mtimeMs > now - ORPHAN_SSH_DIR_MIN_AGE_MS) continue; // launch write->meta window + if (!(await pathAllowed(path, [this.dataDir]))) { + log(`preview display sweep: refusing ${path} (outside the data dir)`); + continue; + } + const rec = readRecord(path); + const displayNumber = displayNumberOf(rec?.display); + const paneAlive = alive.has(name); + const childrenAnswer = + paneAlive && + Number.isInteger(displayNumber) && + displayNumber > 0 && + Number.isInteger(rec?.rfbPort) && + (await d.bindProbe(Number(rec?.rfbPort))); + if (childrenAnswer && rec) { + this.entries.set(name, { + paneId: name, + display: `:${displayNumber}`, + displayNumber, + rfbPort: Number(rec.rfbPort), + xvfbPid: intOf(rec.xvfbPid), + x11vncPid: intOf(rec.x11vncPid), + // No child handles: THIS process did not spawn them, so there is + // no exit promise to watch. A rehydrated display whose children + // die mid-pane reads dead on the next census pass, not lostFor. + }); + rehydrated += 1; + log(`preview display :${displayNumber}: rehydrated for live pane ${name}`); + continue; + } + this.teardown( + { + paneId: name, + display: Number.isInteger(displayNumber) && displayNumber > 0 ? `:${displayNumber}` : "unknown", + displayNumber: Number.isInteger(displayNumber) ? displayNumber : 0, + rfbPort: 0, + xvfbPid: intOf(rec?.xvfbPid), + x11vncPid: intOf(rec?.x11vncPid), + }, + path, + Number.isInteger(displayNumber) && displayNumber > 0 ? this.lockPathFor(displayNumber) : "", + ); + reaped += 1; + } catch (err: unknown) { + log(`preview display sweep: ${path}: ${String(err)}`); // total by construction + } + } + + // Orphan locks: a number no (re)registered entry holds, older than the + // same floor, is a display that is by definition nobody's now. + for (const name of names) { + const m = /^\.display-(\d+)\.lock$/.exec(name); + if (!m) continue; + const number = Number(m[1]); + let held = false; + for (const e of this.entries.values()) { + if (e.displayNumber === number) { + held = true; + break; + } + } + if (held) continue; + const path = join(root, name); + try { + const st = lstatSync(path); + if (st.mtimeMs > now - ORPHAN_SSH_DIR_MIN_AGE_MS) continue; + unlinkSync(path); + locksFreed += 1; + } catch { + /* gone or unreadable: a lock line costs nothing */ + } + } + + if (reaped > 0 || rehydrated > 0 || locksFreed > 0) { + log(`preview display sweep: reaped ${reaped}, rehydrated ${rehydrated}, freed ${locksFreed} lock(s)`); + } + return { reaped, rehydrated, locksFreed }; + } + + /* ---------------------------------------------------------------- */ + /* internals */ + /* ---------------------------------------------------------------- */ + + private deps(over?: DisplayDeps) { + const d = { ...this.defaults, ...stripUndefined(over) }; + return { + spawn: d.spawn ?? bunSpawn, + spawnSync: d.spawnSync ?? bunSpawnSync, + which: d.which ?? bunWhich, + sleep: d.sleep ?? realSleep, + now: d.now ?? Date.now, + bindProbe: d.bindProbe ?? portOccupied, + kill: + d.kill ?? + ((pid: number, signal: number | string = "SIGTERM") => process.kill(pid, signal as NodeJS.Signals | 0)), + xSocketReady: d.xSocketReady ?? socketExists, + readyBudgetMs: d.readyBudgetMs ?? DEFAULT_READY_BUDGET_MS, + }; + } + + /** The `wx` claim loop: the kernel's exclusivity is the lock, :90..:199. */ + private claimNumber(root: string): number { + for (let candidate = DISPLAY_MIN; candidate <= DISPLAY_MAX; candidate++) { + const path = join(root, `.display-${candidate}.lock`); + try { + closeSync(openSync(path, "wx", 0o600)); + return candidate; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; // someone else's number + throw err; + } + } + throw new Error(`${PREVIEW_DISPLAY_NOT_READY}: no free display in :${DISPLAY_MIN}..:${DISPLAY_MAX}`); + } + + /** Bind-probe upward from 5910; the first FREE port wins (a busy one is somebody's server). */ + private async chooseRfbPort(d: ReturnType): Promise { + for (let port = RFB_PORT_MIN; port < RFB_PORT_MIN + RFB_PORT_PROBES; port++) { + if (!(await d.bindProbe(port))) return port; + } + throw new Error(`${PREVIEW_DISPLAY_NOT_READY}: no free rfb port from ${RFB_PORT_MIN}`); + } + + /** X socket first (the server itself), then the RFB port answering yes. Budget, then refuse. */ + private async awaitReady( + displayNumber: number, + rfbPort: number, + d: ReturnType, + ): Promise { + const start = d.now(); + for (;;) { + if (d.xSocketReady(displayNumber) && (await d.bindProbe(rfbPort))) return; + if (d.now() - start >= d.readyBudgetMs) { + throw new Error( + `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} not answering within ${d.readyBudgetMs}ms`, + ); + } + await d.sleep(READY_POLL_MS); + } + } + + /** + * One child's exit watch (spec §9). The ENTRY-IDENTITY guard is the whole + * story: `closeFor` and both sweep passes delete the entry BEFORE killing, + * so the daemon's own teardown lands here as "not registered anymore" and + * is exactly nothing. A death while still registered is the mid-pane loss: + * mark, reap, say which binary, and leave the pane's terminal alone. + */ + private watchChild(paneId: string, entry: DisplayEntry, binary: NodePreviewScreenLost, child: DisplayChild): void { + const onDeath = (detail: string): void => { + if (this.entries.get(paneId) !== entry) return; // deliberate teardown (or a newer display owns the pane) + this.entries.delete(paneId); + this.lost.set(paneId, binary); + log( + `preview display ${entry.display} for pane ${paneId}: ${binary} exited (${detail}); reaped the display, pane left running`, + ); + this.teardown(entry, join(this.dataDir, "displays", paneId), this.lockPathFor(entry.displayNumber)); + }; + child.exited.then((code) => onDeath(`code ${code}`)).catch((err: unknown) => onDeath(`spawn error ${String(err)}`)); + } + + /** The ONE reap path: signal the children that probe alive, then dir, then lock. */ + private teardown(entry: DisplayEntry, paneDir: string, lockPath: string): void { + this.killGuarded(entry.xvfbPid); + this.killGuarded(entry.x11vncPid); + try { + rmSync(paneDir, { recursive: true, force: true }); + } catch (err: unknown) { + log(`preview display: ${paneDir}: dir reap failed: ${String(err)}`); + } + if (!lockPath) return; + try { + unlinkSync(lockPath); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") log(`preview display: ${lockPath}: lock unlink failed: ${String(err)}`); + } + } + + /** Probe-then-SIGTERM: never signal a pid that is not ours to signal, never throw at a caller. */ + private killGuarded(pid: number): void { + if (!Number.isInteger(pid) || pid <= 0) return; + const kill = this.deps().kill; + try { + kill(pid, 0); + } catch { + return; // already gone (the common case on the restart reaps) + } + try { + kill(pid, "SIGTERM"); + } catch { + /* died mid-signal */ + } + } + + private lockPathFor(displayNumber: number): string { + return join(this.dataDir, "displays", `.display-${displayNumber}.lock`); + } +} + +function writeRecord(dir: string, rec: Record): void { + const file = join(dir, "display.json"); + writeFileSync(file, `${JSON.stringify(rec)}\n`, { mode: 0o600 }); + chmodSync(file, 0o600); // the mode is create-only; every rewrite re-pins it +} + +function readRecord(dir: string): DisplayRecord | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, "display.json"), "utf8")) as unknown; + return parsed && typeof parsed === "object" ? (parsed as DisplayRecord) : null; + } catch { + return null; + } +} + +/** ":91" -> 91, anything else -> NaN (the guards downstream are Number.isInteger). */ +function displayNumberOf(display: unknown): number { + return typeof display === "string" && /^:\d+$/.test(display) ? Number(display.slice(1)) : Number.NaN; +} + +function intOf(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) ? value : 0; +} + +function stripUndefined(over: T | undefined): Partial { + if (!over) return {}; + const out: Partial = {}; + for (const [k, v] of Object.entries(over)) if (v !== undefined) (out as Record)[k] = v; + return out; +} diff --git a/apps/node/agent/src/preview/ports.ts b/apps/node/agent/src/preview/ports.ts index 230c02858..d93b159df 100644 --- a/apps/node/agent/src/preview/ports.ts +++ b/apps/node/agent/src/preview/ports.ts @@ -19,7 +19,14 @@ import { join } from "node:path"; /** One listening socket of the pane's tree, as the wire row names it. */ export type PreviewPortRow = { proto: "tcp" | "tcp6"; port: number; pid: number }; -/** The display the node allocated for this pane, when it has one (Task 7 owns the registry; today every caller passes null). */ +/** + * The display the node allocated for this pane, when it has one: + * `PreviewDisplays.getFor` / `openFor` hand these out, and a scan carries one + * through to the `screen` half of the ports answer. A type alias on purpose, + * not the protocol's interface: command results serialize through + * `Record`, which an alias satisfies and an interface (no + * implicit index signature) refuses. + */ export type PreviewScreenHandle = { display: string; rfbPort: number }; /** Everything `scanPanePorts` takes; `procRoot` and `probe` are test seams. */ @@ -77,8 +84,11 @@ export async function scanPanePorts(opts: ScanPanePortsOptions): Promise { const pi = getHarness("pi"); @@ -71,6 +79,66 @@ describe("buildHarnessCommand", () => { }); }); +describe("paneEnvFor extraEnv layer (pane-preview Task 7)", () => { + // The virtual display is the pane env's fifth layer: DISPLAY/XAUTHORITY are + // allocated FOR this pane by the node, so they must beat a preset that names + // the same keys (the same rule that puts the MCP wiring env above the + // preset), and the exit hook must be built from the same map or the death + // report leaves the pane's machine settings behind. With no fifth layer the + // chain must be byte-identical to the four-layer spread it used to be — + // every pane without a display rides that exact string. + + const preset = { + name: "P", + env: { DISPLAY: "from-preset", FOO: "bar" }, + flags: [], + settings: null, + configIsolation: false, + }; + const subshellEnv = { SUBSHELL_API_KEY: "subshell_x" }; + const mcpEnv = { OPENCODE_CONFIG: "/tmp/mcp.json" }; + const displayEnv = { DISPLAY: ":91", XAUTHORITY: "/var/lib/subshell/displays/p/Xauthority" }; + const argv = ["/bin/sh", "-c", "echo hi"]; + + it("the display layer beats the preset layer, and the hook env sees the same map", () => { + const env = paneEnvFor(subshellEnv, preset, mcpEnv, displayEnv); + // A preset DISPLAY describes someone else's screen; the allocation wins. + expect(env.DISPLAY).toBe(":91"); + expect(env.XAUTHORITY).toBe("/var/lib/subshell/displays/p/Xauthority"); + // The other layers still ride: this is an addition to the chain, not a + // replacement of it. + expect(env.FOO).toBe("bar"); + expect(env.SUBSHELL_API_KEY).toBe("subshell_x"); + expect(env.OPENCODE_CONFIG).toBe("/tmp/mcp.json"); + }); + + it("assembleHarnessCommand carries the layer, and the assembled env is exactly paneEnvFor's map", () => { + // :224's exit-hook call is `exitHookFor(reporter, paneEnvFor(..., displayEnv))` + // and the pane command is `assembleHarnessCommand(..., displayEnv)`. The + // agreement this pins is that property: one map, two consumers, so the + // hook can never aim at a different DISPLAY than the pane it reports for. + const cmd = assembleHarnessCommand(argv, preset, subshellEnv, mcpEnv, displayEnv); + const env = paneEnvFor(subshellEnv, preset, mcpEnv, displayEnv); + for (const [k, v] of Object.entries(env)) { + expect(cmd).toContain(`${k}=${shellQuote(String(v))}`); + } + expect(cmd).toContain(`DISPLAY='${displayEnv.DISPLAY}'`); + expect(cmd).not.toContain("from-preset"); + }); + + it("with no extraEnv the chain is byte-identical to the four-layer spread", () => { + // The preservation claim, pinned: today's every-pane string comes from + // exactly this spread, so a pane with no display must assemble it the same + // bytes the old chain produced. + const env = { ...curatedEnv(), ...subshellEnv, ...preset.env, ...mcpEnv }; + const envArgs = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(String(v))}`); + if (!("TERM" in env)) envArgs.push('TERM="$TERM"'); + const expected = `env -i ${envArgs.join(" ")} ${argv.map(shellQuote).join(" ")}`; + expect(assembleHarnessCommand(argv, preset, subshellEnv, mcpEnv)).toBe(expected); + expect(paneEnvFor(subshellEnv, preset, mcpEnv)).toEqual(env); + }); +}); + describe("validateWorkingDir", () => { it("resolves an existing dir to its realpath", async () => { expect(await validateWorkingDir("/tmp")).toBeTypeOf("string"); diff --git a/packages/pane-runtime/src/launch.ts b/packages/pane-runtime/src/launch.ts index 13af09c78..117ba6512 100644 --- a/packages/pane-runtime/src/launch.ts +++ b/packages/pane-runtime/src/launch.ts @@ -52,7 +52,10 @@ export function buildHarnessCommand( * @param argv the complete, already-resolved command line (no placeholder left) * @param preset the wire preset (its `env` layer rides above `subshellEnv`) * @param subshellEnv SUBSHELL_* credentials from the control plane - * @param mcpEnv the MCP registration's wiring env (highest layer; see below) + * @param mcpEnv the MCP registration's wiring env (above the preset; see below) + * @param extraEnv the node-allocated layer (a virtual display's DISPLAY/XAUTHORITY) — + * appended LAST so an env the machine allocated FOR this pane cannot lose to + * a preset that happens to name the same keys * @throws Error when a merged env key is not a valid shell variable name */ export function assembleHarnessCommand( @@ -60,8 +63,9 @@ export function assembleHarnessCommand( preset: PresetDefinition, subshellEnv: Record = {}, mcpEnv?: Record, + extraEnv?: Record, ): string { - const env = paneEnvFor(subshellEnv, preset, mcpEnv); + const env = paneEnvFor(subshellEnv, preset, mcpEnv, extraEnv); // Defense-in-depth at the last chokepoint before the shell string exists: // this catches legacy DB rows and any other env source merged above, // regardless of what the entry-point (preset-save) validation allowed. @@ -84,11 +88,16 @@ export function assembleHarnessCommand( /** * THE pane's environment, in precedence order: the curated host env < * `SUBSHELL_*` credentials (a preset may deliberately override - * `SUBSHELL_BASE_URL`) < the preset's own env < the registration's wiring env. - * Wiring env goes LAST on purpose: a key like `OPENCODE_CONFIG` is transport - * plumbing, not a user knob — a preset setting it would otherwise silently - * drop the subshell's subshell tools while the UI still promised automatic - * registration. + * `SUBSHELL_BASE_URL`) < the preset's own env < the registration's wiring env + * < the node-allocated layer (`extraEnv`). Wiring env goes above the preset on + * purpose: a key like `OPENCODE_CONFIG` is transport plumbing, not a user knob + * — a preset setting it would otherwise silently drop the subshell's subshell + * tools while the UI still promised automatic registration. `extraEnv` rides + * above everything because its keys describe a resource this machine created + * FOR this pane: a virtual display's `DISPLAY`/`XAUTHORITY` allocated by the + * node is truth, and a preset (or stray host env) naming the same key is + * describing a screen that is not this pane's. Omitted, the chain is exactly + * the four-layer map it was before Task 7. * * **Exported because the pane is not the only thing built from these layers.** * A pane's `pane-died` hook must report to the same control plane the pane @@ -113,8 +122,9 @@ export function paneEnvFor( subshellEnv: Record, preset: PresetDefinition, mcpEnv?: Record, + extraEnv?: Record, ): Record { - return { ...curatedEnv(), ...subshellEnv, ...preset.env, ...(mcpEnv ?? {}) }; + return { ...curatedEnv(), ...subshellEnv, ...preset.env, ...(mcpEnv ?? {}), ...(extraEnv ?? {}) }; } /** The minimal host env we pass through to harness processes. */ From c5125d8204e92b6cc4534d506348c06a7cdcf2d0 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Thu, 22 Oct 2026 23:51:02 -0700 Subject: [PATCH 038/139] fix(node): census-trust gate and rehydrated-display death probe in the sweep --- .../src/__tests__/preview-display.test.ts | 154 +++++++++++++++--- .../agent/src/__tests__/subshell-meta.test.ts | 39 +++++ apps/node/agent/src/daemon.ts | 56 +++++-- apps/node/agent/src/preview/display.ts | 127 ++++++++++++--- apps/node/agent/src/subshell-meta.ts | 34 +++- .../pane-runtime/src/__tests__/launch.test.ts | 2 +- packages/pane-runtime/src/launch.ts | 9 +- 7 files changed, 347 insertions(+), 74 deletions(-) diff --git a/apps/node/agent/src/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts index 7c94a4e84..4cb9df660 100644 --- a/apps/node/agent/src/__tests__/preview-display.test.ts +++ b/apps/node/agent/src/__tests__/preview-display.test.ts @@ -18,6 +18,7 @@ import { execLaunch } from "../commands/launch.js"; import { execPreviewPorts, PREVIEW_NODE_OFF, type PreviewPortsSeams } from "../commands/preview-ports-exec.js"; import { runExitWatchTick } from "../commands/report.js"; import type { NodeConfig } from "../config.js"; +import { runDisplaySweep } from "../daemon.js"; import { type DisplayChild, type DisplayDeps, @@ -170,7 +171,7 @@ function makeDeps(over: DepOverrides = {}) { calls.filter((c): c is Extract => c.kind === "spawn"); const killPids = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal !== 0 ? [c.pid] : [])); const probes = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal === 0 ? [c.pid] : [])); - return { deps, calls, busy, alivePids, spawns, killPids, probes }; + return { deps, calls, busy, alivePids, spawns, killPids, probes, advanceClock: (ms: number) => (clock += ms) }; } /** Let the resolved `exited` promises run their watch handlers. */ @@ -184,6 +185,29 @@ function ageOut(...paths: string[]): void { for (const p of paths) utimesSync(p, old, old); } +/* Ports-executor test stand-ins (shared by the wiring group and round 1). */ + +function portsCtx(dataDir: string, previewDisplays?: PreviewDisplays): CommandContext { + return { + config: { dataDir, nodeId: "node-self" }, + meta: { get: async () => undefined }, + tmux: { panePid: async () => 100 }, + previewDisplays, + } as unknown as CommandContext; +} + +/** The scan seam that echoes the screen it is handed, as scanPanePorts does. */ +function passthroughScan(seen: unknown[]): PreviewPortsSeams { + return { + scan: async (opts) => { + seen.push(opts.screen); + return { ports: [], cdpPort: null, screen: opts.screen ?? null }; + }, + }; +} + +const portsCmd = { type: "preview_ports_get", paneId: PANE } as Cmd<"preview_ports_get">; + /* ------------------------------------------------------------------ */ /* openFor: allocation, argv exactness, modes */ /* ------------------------------------------------------------------ */ @@ -425,26 +449,6 @@ describe("the child-exit watch", () => { /* ------------------------------------------------------------------ */ describe("the preview_ports_get executor reads the display registry", () => { - function portsCtx(dataDir: string, previewDisplays?: PreviewDisplays): CommandContext { - return { - config: { dataDir, nodeId: "node-self" }, - meta: { get: async () => undefined }, - tmux: { panePid: async () => 100 }, - previewDisplays, - } as unknown as CommandContext; - } - - function passthroughScan(seen: unknown[]): PreviewPortsSeams { - return { - scan: async (opts) => { - seen.push(opts.screen); - return { ports: [], cdpPort: null, screen: opts.screen ?? null }; - }, - }; - } - - const portsCmd = { type: "preview_ports_get", paneId: PANE } as Cmd<"preview_ports_get">; - it("a registered display answers screen { display, rfbPort } and survives the Task 1 validator", async () => { const dataDir = onDir("ports-registered"); const { deps } = makeDeps(); @@ -565,10 +569,13 @@ describe("pane death and the orphan sweep", () => { it("sweepOrphans closes a REGISTERED display whose pane id is absent from the alive list", async () => { const dataDir = onDir("sweep-registered"); - const { deps, spawns, killPids } = makeDeps(); + const { deps, spawns, killPids, advanceClock } = makeDeps(); const reg = new PreviewDisplays(dataDir, deps); await reg.openFor(PANE, { width: 640, height: 480 }); const pids = spawns().map((s) => s.child.pid); + // Past the fresh-entry floor (round 1): the census-absent reap waits out + // the launch/census race, pinned in both directions by the round 1 group. + advanceClock(61_000); const r = await reg.sweepOrphans(["someone-else"]); expect(r.reaped).toBe(1); @@ -655,6 +662,109 @@ describe("pane death and the orphan sweep", () => { }); }); +/* ------------------------------------------------------------------ */ +/* review round 1: sweep trust, rehydrated death, fresh-entry floor */ +/* ------------------------------------------------------------------ */ + +describe("review round 1: what the sweep sees", () => { + it("a rehydrated entry whose recorded child dies is probed, marked lost, and reaped by the next sweep", async () => { + const dataDir = onDir("round1-rehydrated-death"); + const shared = makeDeps(); + const reg = new PreviewDisplays(dataDir, shared.deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + const [xvfbPid, vncPid] = shared.spawns().map((s) => s.child.pid); + ageOut(join(dataDir, "displays", PANE)); + + // The daemon restart: a fresh instance over the same data dir, rfb still + // answering, both recorded pids still alive -> the entry rehydrates. + const reg2 = new PreviewDisplays(dataDir, shared.deps); + expect(reg2.getFor(PANE)).toBeNull(); + await reg2.sweepOrphans([PANE]); + expect(reg2.getFor(PANE)).toEqual({ display: ":90", rfbPort: 5910 }); + + // x11vnc dies of natural causes afterwards. THIS process has no child + // handle to watch, so only the sweep's signal-0 probe can notice - and + // it must, or the registry answers `screen` for a dead server forever. + shared.alivePids.delete(vncPid); + await reg2.sweepOrphans([PANE]); + + expect(reg2.getFor(PANE)).toBeNull(); // deregistered + expect(reg2.lostFor(PANE)).toBe("x11vnc"); // named, like the exit watch would + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); // dir reaped + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); // lock freed + expect(shared.killPids()).toContain(xvfbPid); // the surviving Xvfb is not orphaned + expect(shared.probes()).toContain(vncPid); // it was the probe that saw the death + + // And the ports answer now names the loss through Task 1's validator. + const seen: unknown[] = []; + const res = await execPreviewPorts(portsCtx(dataDir, reg2), portsCmd, passthroughScan(seen)); + const parsed = parseNodePreviewPortsResult(res.ok ? res.data : null); + expect(parsed?.screen).toBeNull(); + expect(parsed?.screenLost).toBe("x11vnc"); + }); + + it("closeFor on a pane whose dir is already gone clears the stale lost marker", async () => { + const dataDir = onDir("round1-belt-clear"); + const { deps, spawns } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + spawns()[1]?.child.fireExit(); // x11vnc dies mid-pane: the watch marks and reaps + await flush(); + expect(reg.lostFor(PANE)).toBe("x11vnc"); + + reg.closeFor(PANE); // a late death report or plane close hits the unregistered belt + expect(reg.lostFor(PANE)).toBeNull(); // the belt path must clear the marker too + }); + + it("a display registered moments ago survives a sweep whose census does not list its pane, and dies aged", async () => { + const dataDir = onDir("round1-fresh-floor"); + const { deps, advanceClock } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + + // The census raced the launch: the meta row lands after the scan read it, + // so "absent from this census" is not yet "dead". + await reg.sweepOrphans([]); + expect(reg.getFor(PANE)).toEqual({ display: ":90", rfbPort: 5910 }); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(true); + + advanceClock(61_000); // past the one-minute fresh-entry floor + await reg.sweepOrphans([]); + expect(reg.getFor(PANE)).toBeNull(); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(false); + }); + + it("a meta scan that did not complete skips the whole sweep (a live display survives)", async () => { + const dataDir = onDir("round1-untrusted-census"); + const { deps } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + const { ctx } = makeLaunchCtx(dataDir, reg); + // A broken scan arrives as an empty census through the old list(); the + // trust gate reads listComplete and refuses to sweep on it at all. (The + // ENOTDIR-is-emptiness side is pinned in subshell-meta.test.ts.) + ctx.meta = { listComplete: async () => ({ metas: [], scanOk: false }) } as unknown as SubshellMetaStore; + await runDisplaySweep(ctx, reg); + expect(reg.getFor(PANE)).toEqual({ display: ":90", rfbPort: 5910 }); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(true); + expect(existsSync(join(dataDir, "displays", ".display-90.lock"))).toBe(true); + }); + + it("a TRUSTED empty census still reaps a registered display (the legitimate cleanup direction)", async () => { + const dataDir = onDir("round1-trusted-census"); + const { deps, advanceClock } = makeDeps(); + const reg = new PreviewDisplays(dataDir, deps); + await reg.openFor(PANE, { width: 640, height: 480 }); + advanceClock(61_000); // past the fresh-entry floor, so the census alone decides + const { ctx } = makeLaunchCtx(dataDir, reg); + ctx.meta = { listComplete: async () => ({ metas: [], scanOk: true }) } as unknown as SubshellMetaStore; + await runDisplaySweep(ctx, reg); + expect(reg.getFor(PANE)).toBeNull(); + expect(existsSync(join(dataDir, "displays", PANE))).toBe(false); + }); +}); + /* ------------------------------------------------------------------ */ /* the launch.screen path */ /* ------------------------------------------------------------------ */ diff --git a/apps/node/agent/src/__tests__/subshell-meta.test.ts b/apps/node/agent/src/__tests__/subshell-meta.test.ts index edefa1a58..3af2bab75 100644 --- a/apps/node/agent/src/__tests__/subshell-meta.test.ts +++ b/apps/node/agent/src/__tests__/subshell-meta.test.ts @@ -204,3 +204,42 @@ describe("SubshellMetaStore", () => { } }); }); + +describe("SubshellMetaStore.listComplete", () => { + // The display sweep's trust gate: "no meta rows" and "the scan could not + // read the directory" arrive at `list()` identically as [], and sweeping on + // the first one reaps displays. scanOk is the answer to "did you actually + // see the whole directory", false only for a scan that broke mid-read. + // ENOENT and ENOTDIR are legitimate emptiness (a node that never launched + // a pane has no subshells dir at all), so they stay scanOk true. + + it("a healthy scan reports scanOk with every row", async () => { + const { store } = freshStore("complete-healthy"); + await store.record(meta(UUID)); + const scan = await store.listComplete(); + expect(scan.scanOk).toBe(true); + expect(scan.metas.map((m) => m.subshellId)).toEqual([UUID]); + }); + + it("a missing subshells dir is emptiness, not a broken scan", async () => { + const { store } = freshStore("complete-missing"); + const scan = await store.listComplete(); + expect(scan.scanOk).toBe(true); + expect(scan.metas).toEqual([]); + }); + + it("a subshells path that is a file (ENOTDIR) is emptiness, not a broken scan", async () => { + const { store, dataDir } = freshStore("complete-notdir"); + writeFileSync(join(dataDir, "subshells"), "not a dir"); + const scan = await store.listComplete(); + expect(scan.scanOk).toBe(true); + expect(scan.metas).toEqual([]); + }); + + it("list() stays the scan's metas exactly", async () => { + const { store } = freshStore("complete-list"); + await store.record(meta(UUID)); + const scan = await store.listComplete(); + expect(await store.list()).toEqual(scan.metas); + }); +}); diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 08201c4db..ddea255c3 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -717,20 +717,15 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom sshDirSweepTimer.unref?.(); // Orphaned virtual displays (spec §5, Task 7 Step 5): boot pass plus the // same hourly beat, unref'd and fire-and-forget like the two above. The - // sweep needs the ALIVE CENSUS, and the census is the pane-log-retention - // shape, not a tmux list: one `hasSubshell` per meta record, and a probe - // that cannot answer counts the pane ALIVE (unknown is not dead - the same - // rule the exit watcher's blip counter exists for). Dirs with no meta row - // cannot be probed at all and ride the sweep's one-hour launch floor. - const sweepDisplays = async (): Promise => { - const alive = await collectLivePaneIds(ctx); - await previewDisplays.sweepOrphans(alive); - }; - void sweepDisplays().catch((err: unknown) => { + // trust rule is in runDisplaySweep (module level, test-exported): the + // sweep runs ONLY on a census the meta store vouches for, because an + // empty-by-failure census would otherwise tear down every live display on + // the node. Skipping a pass costs an hour; a wrong pass kills live Xvfbs. + void runDisplaySweep(ctx, previewDisplays).catch((err: unknown) => { log(`display sweep failed: ${err instanceof Error ? err.message : String(err)}`); }); const displaySweepTimer = setInterval(() => { - void sweepDisplays().catch((err: unknown) => { + void runDisplaySweep(ctx, previewDisplays).catch((err: unknown) => { log(`display sweep failed: ${err instanceof Error ? err.message : String(err)}`); }); }, deps.retentionMs ?? PANE_LOG_RETENTION_PASS_MS); @@ -1580,22 +1575,47 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom } } +/** + * One display-sweep pass with its trust gate (pane-preview Task 7, review + * round 1): take the census, and if the census cannot vouch for its own + * completeness, skip the pass entirely. `sweepOrphans([])` reaps every + * registered display whose pane the census omitted, so a wedged meta scan + * that reads as "zero panes" must never reach it - a skipped hourly pass + * costs an hour, a wrong pass kills live Xvfb's. + * @internal exported for the sweep-trust tests; the daemon calls it from its + * boot pass and hourly timer. + */ +export async function runDisplaySweep(ctx: CommandContext, displays: PreviewDisplays): Promise { + const alive = await collectLivePaneIds(ctx); + if (alive === null) { + log("display sweep skipped: the subshell-meta scan did not complete (unknown is not dead)"); + return; + } + await displays.sweepOrphans(alive); +} + /** * The alive-pane census the display sweep needs: every meta record probed on * its OWN socket with `hasSubshell` (the two-seam shape pane-log-retention * runs - per-record, not a `list-panes` enumeration), where a probe that * cannot answer counts the pane ALIVE. "Unknown is not dead" is the same rule * the exit watcher's unreachable-counter exists for: a wedged tmux server - * must never turn an hourly sweep into a display massacre. + * must never turn an hourly sweep into a display massacre. The rule has two + * doors, not one: the PER-PANE probe failure counts the pane alive (null + * never fires for it), and a CENSUS-WIDE failure - a meta scan that did not + * complete, per `listComplete`'s `scanOk` - returns null, meaning "no + * answer", which runDisplaySweep skips rather than sweeps on. Dirs with no + * meta row cannot be probed at all and ride the sweep's one-hour launch + * floor. */ -async function collectLivePaneIds(ctx: CommandContext): Promise { - const alive = new Set(); - const records = await ctx.meta.list().catch((err: unknown) => { - log(`display census: meta.list failed: ${err instanceof Error ? err.message : String(err)}`); +async function collectLivePaneIds(ctx: CommandContext): Promise { + const scan = await ctx.meta.listComplete().catch((err: unknown) => { + log(`display census: meta scan threw: ${err instanceof Error ? err.message : String(err)}`); return undefined; }); - if (!records) return []; - for (const m of records) { + if (!scan?.scanOk) return null; // cannot vouch for completeness: not a census, no matter how short + const alive = new Set(); + for (const m of scan.metas) { try { if (await ctx.tmux.hasSubshell(m.socket, m.subshellId)) alive.add(m.subshellId); } catch { diff --git a/apps/node/agent/src/preview/display.ts b/apps/node/agent/src/preview/display.ts index baf004879..efd693846 100644 --- a/apps/node/agent/src/preview/display.ts +++ b/apps/node/agent/src/preview/display.ts @@ -51,9 +51,11 @@ import type { PreviewScreenHandle } from "./ports.js"; * the sweep), which deregisters BEFORE signalling, so the child-exit watch * never mistakes a teardown for a loss. Only a mid-pane vanish - a child * dying while the entry is still registered - marks - * {@link PreviewDisplays.lostFor}, and it marks without touching the pane: - * spec §9's posture is a darkened Screen affordance that can say WHY, not - * a killed terminal. + * {@link PreviewDisplays.lostFor}: the exit watch for children this daemon + * spawned, and the sweep's signal-0 probe of the recorded pids for entries + * it merely rehydrated. Both mark without touching the pane: spec §9's + * posture is a darkened Screen affordance that can say WHY, not a killed + * terminal. * * Durability is deliberately half-hearted: `display.json` records the pids * incrementally (every crash window leaves the pids that exist) so the @@ -141,8 +143,21 @@ interface DisplayEntry { /** Spawned handles when THIS process started the display; undefined on a rehydrated entry. */ xvfbChild?: DisplayChild; vncChild?: DisplayChild; + /** + * Earliest sweep-clock time at which this entry may be torn down for + * ABSENT-FROM-CENSUS. A launch registers its display moments before the + * meta row the census reads exists, so "absent" is not yet "dead" right + * after `openFor`: a fresh entry gets registration + one minute. A + * rehydrated entry's registration time is meaningless (it did not happen + * now), so it carries the record's own mtime under the launch floor it was + * rehydrated past - which is already in the past, and correct. + */ + sweptAfter: number; } +/** Pass 1's fresh-entry floor: the census races the launch by far less than this. */ +const DISPLAY_CENSUS_FRESH_MS = 60_000; + /** What a `sweepOrphans` pass did, for the one log line it earns. */ export interface DisplaySweepResult { reaped: number; @@ -225,11 +240,6 @@ export class PreviewDisplays { return this.lost.get(paneId) ?? null; } - /** Registered displays node-wide (the shared-budget read; also handy to tests). */ - count(): number { - return this.entries.size; - } - /** * Allocate the pane's display: platform -> mirror -> binaries -> lock -> * files -> Xvfb -> RFB port -> x11vnc -> readiness, and a REFUSAL throws @@ -317,6 +327,7 @@ export class PreviewDisplays { x11vncPid: vncChild.pid, xvfbChild, vncChild, + sweptAfter: d.now() + DISPLAY_CENSUS_FRESH_MS, }; this.entries.set(paneId, entry); this.lost.delete(paneId); // a fresh display clears the old loss story @@ -335,6 +346,7 @@ export class PreviewDisplays { rfbPort: 0, xvfbPid: xvfbChild?.pid ?? 0, x11vncPid: vncChild?.pid ?? 0, + sweptAfter: 0, // never registered; the census floor is not its question }, paneDir, lockPath, @@ -361,6 +373,11 @@ export class PreviewDisplays { // Unregistered belt: a half-open launch left a dir whose lock the record // still names. Reap the dir and (only when the record is readable) its // lock; a lock with no record is the sweep's floor-guarded business. + // The pane is being closed, so a loss marker left by its child's exit is + // the story of a display this close ends: clear it even when the dir is + // already gone (the watch reaped it first), or the ports arm would keep + // answering screenLost for a pane the plane has now explicitly closed. + this.lost.delete(paneId); const paneDir = join(this.dataDir, "displays", paneId); const rec = readRecord(paneDir); const displayNumber = displayNumberOf(rec?.display); @@ -374,6 +391,7 @@ export class PreviewDisplays { rfbPort: 0, xvfbPid: intOf(rec?.xvfbPid), x11vncPid: intOf(rec?.x11vncPid), + sweptAfter: 0, // belt teardown, not a registered entry }, paneDir, this.lockPathFor(displayNumber), @@ -390,25 +408,58 @@ export class PreviewDisplays { /** * The periodic reconciliation (spec §5's "the sweep catches what a crash * orphaned"), run by the daemon at boot and hourly with the alive-pane - * census. THREE passes, and the first is not the one the brief distrusts: - * registry teardown is nearly free and catches what the death report missed; - * the DISK walk is what a daemon restart makes necessary, because the - * restart cleared the registry - so a live pane's dir rehydrates from - * `display.json` (only while `bindProbe` still answers its recorded RFB - * port: children dead means REAP, not a record that would strand the - * affordance forever on a dead socket), a dead pane's dir is reaped, and the - * ssh retention's one-hour launch floor rides both, because a dir from an - * in-flight `openFor` is not an orphan. + * census - and by the daemon's wrapper ONLY on a census that completed, + * because sweeping on a census that cannot vouch for its own completeness + * would tear down every live display. THREE passes: + * pass 1 works the registry: a LIVE pane's handle-less (rehydrated) entry + * gets its recorded pids signal-0 probed, so a display that died under a + * live pane gets the same named loss + reap the child-exit watch performs + * for spawned children; a census-absent entry is reaped only once past its + * fresh-entry floor (a launch can register before the census's meta scan + * sees the row); pass 2 walks the DISK, what a daemon restart makes + * necessary because the restart cleared the registry - a live pane's dir + * rehydrates from `display.json` (only while `bindProbe` still answers its + * recorded RFB port: children dead means REAP, not a record that would + * strand the affordance forever on a dead socket), a dead pane's dir is + * reaped, and the ssh retention's one-hour launch floor rides both, + * because a dir from an in-flight `openFor` is not an orphan; pass 3 frees + * aged locks no entry holds. */ async sweepOrphans(alivePaneIds: string[]): Promise { const alive = new Set(alivePaneIds); const d = this.deps(); + const now = d.now(); let reaped = 0; let rehydrated = 0; let locksFreed = 0; for (const [paneId, entry] of [...this.entries]) { - if (alive.has(paneId)) continue; + if (alive.has(paneId)) { + // A rehydrated entry has no child handles, so the child-exit watch + // cannot see its death. The signal-0 probe is the sweep's half of + // that watch: a recorded pid that no longer exists means the display + // died under a live pane, which gets the SAME named loss, deregister, + // and reap the exit watch performs for the displays this daemon + // spawned - otherwise the registry answers `screen` for a dead server + // forever and the plane dials it with no cause to show. + if (!entry.xvfbChild && !entry.vncChild) { + const deadBinary = this.probeRehydratedDeath(entry); + if (deadBinary !== null) { + this.entries.delete(paneId); + this.lost.set(paneId, deadBinary); + log( + `preview display ${entry.display} for pane ${paneId}: recorded ${deadBinary} pid is gone; reaped the display, pane left running`, + ); + this.teardown(entry, join(this.dataDir, "displays", paneId), this.lockPathFor(entry.displayNumber)); + reaped += 1; + } + } + continue; + } + // Absent from the census: only after the fresh-entry floor, because a + // launch can register its display before the census's meta scan sees + // the row. One minute is generous for that race and short for an orphan. + if (now < entry.sweptAfter) continue; this.entries.delete(paneId); this.lost.delete(paneId); this.teardown(entry, join(this.dataDir, "displays", paneId), this.lockPathFor(entry.displayNumber)); @@ -422,7 +473,6 @@ export class PreviewDisplays { } catch { return { reaped, rehydrated, locksFreed }; // ENOENT/ENOTDIR: never any displays here } - const now = d.now(); for (const name of names) { if (!isSubshellId(name)) continue; // our own dirs are id-named; anything else is not ours if (this.entries.has(name)) continue; // registered = pass 1's (or nobody's) business @@ -453,8 +503,13 @@ export class PreviewDisplays { xvfbPid: intOf(rec.xvfbPid), x11vncPid: intOf(rec.x11vncPid), // No child handles: THIS process did not spawn them, so there is - // no exit promise to watch. A rehydrated display whose children - // die mid-pane reads dead on the next census pass, not lostFor. + // no exit promise to watch. The sweep watches instead: pass 1 + // probes the recorded pids every pass while the pane is alive + // (probeRehydratedDeath), and a dead one gets the same named-loss + // teardown the exit watch performs for spawned children. The + // census floor is the record's own age (already past it: that is + // what made rehydration eligible), never "now". + sweptAfter: st.mtimeMs + ORPHAN_SSH_DIR_MIN_AGE_MS, }); rehydrated += 1; log(`preview display :${displayNumber}: rehydrated for live pane ${name}`); @@ -468,6 +523,7 @@ export class PreviewDisplays { rfbPort: 0, xvfbPid: intOf(rec?.xvfbPid), x11vncPid: intOf(rec?.x11vncPid), + sweptAfter: 0, // disk reap of an unregistered dir }, path, Number.isInteger(displayNumber) && displayNumber > 0 ? this.lockPathFor(displayNumber) : "", @@ -611,20 +667,37 @@ export class PreviewDisplays { /** Probe-then-SIGTERM: never signal a pid that is not ours to signal, never throw at a caller. */ private killGuarded(pid: number): void { - if (!Number.isInteger(pid) || pid <= 0) return; - const kill = this.deps().kill; + if (!this.probeAlive(pid)) return; // already gone (the common case on the restart reaps) try { - kill(pid, 0); + this.deps().kill(pid, "SIGTERM"); } catch { - return; // already gone (the common case on the restart reaps) + /* died mid-signal */ } + } + + /** Signal-0 liveness: true only for a real pid the OS still knows. Never signals anything. */ + private probeAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; try { - kill(pid, "SIGTERM"); + this.deps().kill(pid, 0); + return true; } catch { - /* died mid-signal */ + return false; } } + /** + * Which recorded child of a HANDLE-LESS (rehydrated) entry is gone, or null + * when both still probe alive. Xvfb is checked first: when both are dead, + * the server's own loss is the truer name for what the pane lost, and the + * order costs nothing else. + */ + private probeRehydratedDeath(entry: DisplayEntry): NodePreviewScreenLost | null { + if (!this.probeAlive(entry.xvfbPid)) return "Xvfb"; + if (!this.probeAlive(entry.x11vncPid)) return "x11vnc"; + return null; + } + private lockPathFor(displayNumber: number): string { return join(this.dataDir, "displays", `.display-${displayNumber}.lock`); } diff --git a/apps/node/agent/src/subshell-meta.ts b/apps/node/agent/src/subshell-meta.ts index b5dde2e3a..5365a88f1 100644 --- a/apps/node/agent/src/subshell-meta.ts +++ b/apps/node/agent/src/subshell-meta.ts @@ -44,6 +44,20 @@ const META_SUFFIX = ".meta.json"; */ export const isSubshellId = isNodeSubshellId; +/** + * One complete-enough answer from {@link SubshellMetaStore.listComplete}: + * the rows the scan could read, and whether the directory read itself + * completed. Callers that ACT on absence (the display sweep reaping what the + * census does not list) must check `scanOk` first; callers that merely read + * go through `list()`. + */ +export interface MetaScan { + /** Metas for every readable `*.meta.json` file, sorted by id. */ + metas: SubshellMeta[]; + /** False only when readdir failed with something other than ENOENT/ENOTDIR. */ + scanOk: boolean; +} + /** Throws when `id` could not have come from our control plane; callers surface this as a command failure. */ function assertSubshellId(id: string): void { if (!isSubshellId(id)) throw new Error("invalid subshell id"); @@ -206,15 +220,31 @@ export class SubshellMetaStore { * `[]`, non-meta files are ignored, junk files are skipped (one log line each). */ async list(): Promise { + return (await this.listComplete()).metas; + } + + /** + * The same scan as {@link list}, plus the answer to "did you actually see + * the whole directory". The display sweep needs the difference: `list()` + * answers `[]` identically for "no panes were recorded" and "the readdir + * broke", and sweeping displays on the second one tears down every live + * Xvfb. `scanOk` is false ONLY for a readdir failure that is neither ENOENT + * nor ENOTDIR - both of those are legitimate emptiness (a node that never + * launched a pane has no `subshells` dir at all). `list()` delegates, so + * every existing caller sees byte-identical behavior. + */ + async listComplete(): Promise { let names: string[]; + let scanOk = true; try { names = await readdir(this.subshellsDir()); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") { log(`subshell meta scan failed: ${this.subshellsDir()}: ${err instanceof Error ? err.message : String(err)}`); + scanOk = false; } - return []; + names = []; } const out: SubshellMeta[] = []; for (const name of names) { @@ -232,7 +262,7 @@ export class SubshellMetaStore { if (meta) out.push(meta); } out.sort((a, b) => a.subshellId.localeCompare(b.subshellId)); - return out; + return { metas: out, scanOk }; } /** diff --git a/packages/pane-runtime/src/__tests__/launch.test.ts b/packages/pane-runtime/src/__tests__/launch.test.ts index 8d09f7649..57b2cb23b 100644 --- a/packages/pane-runtime/src/__tests__/launch.test.ts +++ b/packages/pane-runtime/src/__tests__/launch.test.ts @@ -85,7 +85,7 @@ describe("paneEnvFor extraEnv layer (pane-preview Task 7)", () => { // the same keys (the same rule that puts the MCP wiring env above the // preset), and the exit hook must be built from the same map or the death // report leaves the pane's machine settings behind. With no fifth layer the - // chain must be byte-identical to the four-layer spread it used to be — + // chain must be byte-identical to the four-layer spread it used to be: // every pane without a display rides that exact string. const preset = { diff --git a/packages/pane-runtime/src/launch.ts b/packages/pane-runtime/src/launch.ts index 117ba6512..3fd4de2a9 100644 --- a/packages/pane-runtime/src/launch.ts +++ b/packages/pane-runtime/src/launch.ts @@ -53,7 +53,7 @@ export function buildHarnessCommand( * @param preset the wire preset (its `env` layer rides above `subshellEnv`) * @param subshellEnv SUBSHELL_* credentials from the control plane * @param mcpEnv the MCP registration's wiring env (above the preset; see below) - * @param extraEnv the node-allocated layer (a virtual display's DISPLAY/XAUTHORITY) — + * @param extraEnv the node-allocated layer (a virtual display's DISPLAY/XAUTHORITY), * appended LAST so an env the machine allocated FOR this pane cannot lose to * a preset that happens to name the same keys * @throws Error when a merged env key is not a valid shell variable name @@ -90,9 +90,10 @@ export function assembleHarnessCommand( * `SUBSHELL_*` credentials (a preset may deliberately override * `SUBSHELL_BASE_URL`) < the preset's own env < the registration's wiring env * < the node-allocated layer (`extraEnv`). Wiring env goes above the preset on - * purpose: a key like `OPENCODE_CONFIG` is transport plumbing, not a user knob - * — a preset setting it would otherwise silently drop the subshell's subshell - * tools while the UI still promised automatic registration. `extraEnv` rides + * purpose: a key like `OPENCODE_CONFIG` is transport plumbing, not a user + * knob. A preset setting it would otherwise silently drop the subshell's + * subshell tools while the UI still promised automatic registration. + * `extraEnv` rides * above everything because its keys describe a resource this machine created * FOR this pane: a virtual display's `DISPLAY`/`XAUTHORITY` allocated by the * node is truth, and a preset (or stray host env) naming the same key is From 5c54dd403abad7b456bb006783761d4038f784f8 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 00:39:31 -0700 Subject: [PATCH 039/139] feat(server): preview broker with lane pools, refcounted watches, and revocation --- .../users/__tests__/users-management.test.ts | 72 + .../src/api/users/set-user-disabled.route.ts | 22 +- .../api/src/api/users/set-user-role.route.ts | 10 + .../services/__tests__/preview-broker.test.ts | 1170 +++++++++++++++++ .../nodes/__tests__/node-ws-handler.test.ts | 111 +- .../api/src/services/nodes/node-ws-handler.ts | 48 +- .../api/src/services/nodes/preview-frames.ts | 50 + .../api/src/services/nodes/relay-frames.ts | 131 +- .../server/api/src/services/preview-broker.ts | 1044 +++++++++++++++ .../api/src/services/subshells.service.ts | 9 + packages/subshell-protocol/src/index.ts | 1 + 11 files changed, 2664 insertions(+), 4 deletions(-) create mode 100644 apps/server/api/src/services/__tests__/preview-broker.test.ts create mode 100644 apps/server/api/src/services/nodes/preview-frames.ts create mode 100644 apps/server/api/src/services/preview-broker.ts diff --git a/apps/server/api/src/api/users/__tests__/users-management.test.ts b/apps/server/api/src/api/users/__tests__/users-management.test.ts index 103c3a91b..72f222e19 100644 --- a/apps/server/api/src/api/users/__tests__/users-management.test.ts +++ b/apps/server/api/src/api/users/__tests__/users-management.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeCommandBody } from "@internal/subshell-protocol"; import { hashPassword, verifyPassword } from "better-auth/crypto"; import { sql } from "kysely"; import { authRateLimitRoutes } from "@/api/auth-rate-limit.route.js"; @@ -18,6 +19,7 @@ import { OWNER_DISABLED_CLOSE_CODE, resetNodeRegistryForTests, } from "@/services/nodes/node-registry.js"; +import { getPreviewBroker, resetForTests as resetPreviewBrokerForTests } from "@/services/preview-broker.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { registerLiveSocket, resetLiveRegistryForTests } from "@/ws/live-registry.js"; import { registerViewer, resetLiveViewersForTests, type WsSocket } from "@/ws/viewers.js"; @@ -180,6 +182,43 @@ describe("admin user management", () => { resetLiveRegistryForTests(); }); + it("closes that user's live preview sessions revoked", async () => { + // The cut this route owes the preview broker is a different object from + // the socket drop above: a preview session is plane-side broker state + // (lanes, watch counts, stream attaches) that closing a /ws/live socket + // does not touch. Without `closeAllForUser`, a demoted account's open + // forward lane keeps proxying bytes to its target port until the + // 15-minute idle sweep finds it. The mechanism is tested in the broker + // suite; this pins that the ROUTE performs the cut (spec 2026-10-21 §7). + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: admin2Id, + paneId: memberSubshellId, + nodeId: "n-preview", + port: 3000, + }); + // A lane, because the lane is the NODE-side state: the sweep's wire + // frame names it (the session ref was the probe, already closed). + await getPreviewBroker().openLaneForConnection(ref); + const res = await req("PATCH", `/${admin2Id}/role`, adminCookie, { role: "user" }); + expect(res.status).toBe(200); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect(sent.some((c) => c.type === "preview_close" && c.reason === "revoked")).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); + it("promotes a member to admin", async () => { const res = await req("PATCH", `/${memberId}/role`, adminCookie, { role: "admin" }); expect(res.status).toBe(200); @@ -444,6 +483,39 @@ describe("admin user management", () => { resetLiveRegistryForTests(); }); + it("closes the disabled user's live preview sessions revoked", async () => { + // The stronger act's twin of the demotion cut: spec 2026-10-21 §7's + // "disable closes live sessions revoked" is a promise about THIS route, + // and a preview session is not a socket. Opened through the real + // broker with a faked command sender, the forward session must be gone + // and its node lanes closed named `revoked` when the write lands. + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: memberId, + paneId: memberSubshellId, + nodeId: "n-preview", + port: 3000, + }); + await getPreviewBroker().openLaneForConnection(ref); + const res = await req("PATCH", `/${memberId}/disabled`, adminCookie, { disabled: true }); + expect(res.status).toBe(200); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect(sent.some((c) => c.type === "preview_close" && c.reason === "revoked")).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); + /** * A fake open terminal attach, registered the way both attach paths do * it: `registerViewer` with a `viewerId`, and the `attachUserId` the diff --git a/apps/server/api/src/api/users/set-user-disabled.route.ts b/apps/server/api/src/api/users/set-user-disabled.route.ts index d6990f813..5c9ef1d5e 100644 --- a/apps/server/api/src/api/users/set-user-disabled.route.ts +++ b/apps/server/api/src/api/users/set-user-disabled.route.ts @@ -8,6 +8,7 @@ import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { audit } from "@/services/audit.js"; import { disconnectNode, getHeld, getLive, OWNER_DISABLED_CLOSE_CODE } from "@/services/nodes/node-registry.js"; import { failConnPendings } from "@/services/nodes/node-rpc.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { dropLiveSocketsFor } from "@/ws/live-registry.js"; import { dropTerminalSocketsFor } from "@/ws/viewers.js"; @@ -82,6 +83,25 @@ export const setUserDisabledRoute = new Elysia().use(requireAdmin).patch( const tokensRevoked = body.disabled ? dropUserTokensFor(target.id) : 0; const terminalSocketsClosed = body.disabled ? dropTerminalSocketsFor(target.id, "account disabled") : 0; const droppedSockets = body.disabled ? dropLiveSocketsFor(target.id, "account disabled") : 0; + // The preview broker's revoke cut, on the same disable-only edge (spec + // 2026-10-21 §7: "disable closes live sessions revoked"). A preview + // session is broker state, not a socket: the feed and terminal drops + // cannot touch a forward lane's node-side lane or a watch count, and + // without this a disabled account's lane keeps proxying to its target + // port until the 15-minute idle sweep. Awaited like the set-node-shares + // relay twin: the answer must not race the cut it describes. Re-enabling + // sweeps nothing, same rule as the three lines above. + const previewSessionsClosed = body.disabled + ? await getPreviewBroker() + .closeAllForUser(target.id, "revoked") + .catch((err: unknown) => { + // Degraded-and-honest, the node-sweep rule below: the disable + // has committed, and a swept-so-far broker is strictly better + // than an unswept one. The count names what was reached. + logger.withError(err).warn(`users: disable of ${target.id} committed, but its preview sweep failed`); + return 0; + }) + : 0; // Operator ruling 2026-09-24: disabling an account takes its enrolled // nodes offline too, and this is the half that makes it true NOW — // `authenticateNodeUpgrade` refuses every later dial-in while the flag @@ -154,7 +174,7 @@ export const setUserDisabledRoute = new Elysia().use(requireAdmin).patch( // when the act cut something and are absent on a re-enable, so the // shape says which edge ran. `tokensRevoked` first because the // sweep order above starts there. - ...(body.disabled ? { tokensRevoked, terminalSocketsClosed, nodesDisconnected } : {}), + ...(body.disabled ? { tokensRevoked, terminalSocketsClosed, nodesDisconnected, previewSessionsClosed } : {}), }), }); return { diff --git a/apps/server/api/src/api/users/set-user-role.route.ts b/apps/server/api/src/api/users/set-user-role.route.ts index 30d61c771..e0644fd16 100644 --- a/apps/server/api/src/api/users/set-user-role.route.ts +++ b/apps/server/api/src/api/users/set-user-role.route.ts @@ -6,6 +6,7 @@ import { db } from "@/db/index.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { DEFAULT_USER_ROLE, USER_ROLES } from "@/db/types/user-role.js"; import { audit } from "@/services/audit.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { dropLiveSocketsFor } from "@/ws/live-registry.js"; const RoleSchema = t.Union( @@ -62,6 +63,15 @@ export const setUserRoleRoute = new Elysia().use(requireAdmin).patch( // authenticates at connect and is never re-checked. Dropping them makes // the client reconnect and re-derive what it may subscribe to. const droppedSockets = dropLiveSocketsFor(target.id); + // The preview broker's revoke cut, riding the same edge (pane-preview + // spec §7: a user write stops that person's preview access NOW). A + // preview session is broker state, not a socket: dropping the feed + // cannot touch a forward lane's node connection or a watch count, and + // without this a just-re-roled account's lane keeps proxying to its + // target port until the 15-minute idle sweep. Awaited like the + // `set-node-shares` relay twin: the answer must not race the cut it + // describes. + await getPreviewBroker().closeAllForUser(target.id, "revoked"); await audit({ actorUserId: user.id, action: "user.role_change", diff --git a/apps/server/api/src/services/__tests__/preview-broker.test.ts b/apps/server/api/src/services/__tests__/preview-broker.test.ts new file mode 100644 index 000000000..3a8d08d9e --- /dev/null +++ b/apps/server/api/src/services/__tests__/preview-broker.test.ts @@ -0,0 +1,1170 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + type NodeCommandBody, + type NodeEvent, + type NodePreviewPortsResult, + type NodePreviewScreenWire, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_QUEUE_MAX, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewCloseReason, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { sql } from "kysely"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { db } from "@/db/index.js"; +import type { ShareEntry } from "@/db/repositories/subshell-shares.repository.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import type { Access } from "@/lib/subshell-access.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { + configurePreviewStreamSender, + getPreviewBroker, + PreviewRefusal, + type PreviewStreamSender, + resetForTests, +} from "@/services/preview-broker.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The plane-side preview broker (spec 2026-10-21 §5-§7, Task 8). Every seam + * is faked except the audit rows: those land in the file's own migrated DB, + * because the audit vocabulary is a deliverable operators read. + * + * The wire fakes record every command so a test can assert both what rode + * the link and what deliberately did not: the idempotent DELETE, the + * wire-only lane close, the node-reported close answered with nothing. + */ + +const NODE = "n-preview"; +const PANE = "p-preview-pane"; + +/** One valid 32-hex ref, for events naming a lane that never existed. */ +const GHOST_REF = "ab".repeat(16); + +interface Harness { + clock: { now: number }; + sent: { nodeId: string; cmd: NodeCommandBody }[]; + linked: { nodeId: string; frame: PreviewDataFrame }[]; + delivered: { socket: unknown; frame: Parameters[1] }[]; + accessCalls: { userId: string; paneId: string }[]; +} + +interface FakeSeams { + ack?: (cmd: NodeCommandBody) => unknown; + accessFor?: (userId: string, paneId: string) => Promise; + /** Pass `null` to build a broker with NO fan-out sender at all. */ + sendToSockets?: PreviewStreamSender | null; +} + +/** + * Builds the broker over named seams. The default ack answers every preview + * command: `preview_open` echoes the ref back (`pending` for a forward lane, + * a lie of `subscribers: 1` for the bridge, which the broker must ignore and + * count itself), `preview_close` accepts, and `preview_ports_get` reports an + * empty machine with no screen. + */ +function makeHarness(over: FakeSeams = {}): Harness { + const clock = { now: 1_780_000_000_000 }; + const h: Harness = { clock, sent: [], linked: [], delivered: [], accessCalls: [] }; + const defaultSendToSockets: PreviewStreamSender = (socket, frame) => { + h.delivered.push({ socket, frame }); + }; + resetForTests({ + now: () => clock.now, + send: async (nodeId: string, cmd: NodeCommandBody): Promise => { + h.sent.push({ nodeId, cmd }); + if (over.ack) return over.ack(cmd); + switch (cmd.type) { + case "preview_open": + return cmd.kind === "forward" ? { ref: cmd.ref, pending: true } : { ref: cmd.ref, subscribers: 1 }; + case "preview_close": + return { ref: cmd.ref, closed: true }; + case "preview_ports_get": + return { ports: [], cdpPort: null, screen: null }; + default: + throw new Error(`preview broker test: unexpected command ${cmd.type}`); + } + }, + linkSend: (nodeId: string, frame: PreviewDataFrame): void => { + h.linked.push({ nodeId, frame }); + }, + accessFor: over.accessFor + ? async (userId: string, paneId: string): Promise => { + h.accessCalls.push({ userId, paneId }); + return (await over.accessFor?.(userId, paneId)) ?? "none"; + } + : undefined, + // `null` means "no sender configured", which is what production looks + // like until Task 11 calls configurePreviewStreamSender. + ...(over.sendToSockets === null ? {} : { sendToSockets: over.sendToSockets ?? defaultSendToSockets }), + }); + return h; +} + +function cmds(h: Harness, type: T): Extract[] { + return h.sent.filter((c) => c.cmd.type === type).map((c) => c.cmd) as Extract[]; +} + +/** Lets fire-and-forget audit writes (real DB) land before the read. */ +async function settle(): Promise { + await new Promise((r) => setTimeout(r, 10)); +} + +async function auditRows(action: string): Promise<{ action: string; metadataJson: string | null }[]> { + return await db.selectFrom("auditEvents").where("action", "=", action).select(["action", "metadataJson"]).execute(); +} + +async function metadataOf(action: string): Promise[]> { + const rows = await auditRows(action); + return rows.map((r) => JSON.parse(String(r.metadataJson)) as Record); +} + +const SCREEN: NodePreviewScreenWire = { display: ":7", rfbPort: 5900 }; + +function portsAnswer(over: Partial = {}): NodePreviewPortsResult { + return { ports: [{ proto: "tcp", port: 8080, pid: 11 }], cdpPort: 9222, screen: null, ...over }; +} + +/** + * A sendable fake of the exported structural socket interface. `withDebtMethod: + * false` builds one WITHOUT `getBufferedAmount` at all (the raw Bun socket + * carries the method, the Elysia wrapper carries neither form; Task 11's + * adapter supplies it, and a sibling fake without it must read as zero debt). + */ +function fakeSocket(opts: { debt?: () => number; withDebtMethod?: boolean } = {}): { + frames: string[]; + send(data: string): unknown; + getBufferedAmount?: () => number; +} { + const frames: string[] = []; + const socket: { frames: string[]; send(data: string): unknown; getBufferedAmount?: () => number } = { + frames, + send: (data: string) => { + frames.push(data); + return data.length; + }, + }; + if (opts.withDebtMethod === false) return socket; + socket.getBufferedAmount = opts.debt ?? (() => 0); + return socket; +} + +const broker = getPreviewBroker; + +function streamEvent(ref: string, seq: number): Extract { + return { type: "preview_stream", ref, seq, w: 100, h: 50, ts: 1_780_000_000_000 + seq, blob: `blob-${seq}` }; +} + +/** The pane's live bridge (screencast session), for tests that never saw its ref. */ +function bridgeFor(paneId: string): ReturnType["sessionForPane"]> { + return broker().sessionForPane(paneId); +} + +beforeEach(async () => { + await ensureMigratedTestDb(); + await db.deleteFrom("auditEvents").execute(); +}); + +describe("openForward: the probe, the mint, the session", () => { + it("sends the probe at the lifetime cap, mints a 32-hex ref, closes the probe wire-only", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + + expect(ref).toMatch(/^[0-9a-f]{32}$/); + const opens = cmds(h, "preview_open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toEqual({ + type: "preview_open", + ref, + kind: "forward", + paneId: PANE, + port: 8080, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + // The probe lane closes on the wire (the node must not park a dead lane), + // and the close is wire-only: the audit pair names SESSIONS, not lanes. + expect(cmds(h, "preview_close")).toEqual([{ type: "preview_close", ref, reason: "viewer-left" }]); + + const openAudits = await metadataOf("node.preview_forward.open"); + expect(openAudits).toHaveLength(1); + expect(openAudits[0]).toEqual({ paneId: PANE, nodeId: NODE, port: 8080, kind: "forward" }); + // The ref is the /pv path credential: it appears in no audit row. + for (const row of await auditRows("node.preview_forward.open")) { + expect(row.metadataJson).not.toContain(ref); + } + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + + const session = broker().sessionFor(ref); + expect(session).not.toBeNull(); + expect(session?.kind).toBe("forward"); + expect(session?.mode).toBe("http"); + expect(session?.createdBy).toBe("u-open"); + expect(session?.port).toBe(8080); + expect(session?.cookieJar.size).toBe(0); + }); + + it("two opens for one pane mint DIFFERENT refs (the ref is a secret, not a slot)", async () => { + makeHarness(); + const a = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const b = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(a.ref).not.toBe(b.ref); + expect(b.ref).toMatch(/^[0-9a-f]{32}$/); + }); + + it("a node refusal propagates named and records nothing", async () => { + const h = makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_open") { + throw new NodeRpcError("failed", 'node "x" reported: refused', NODE, "preview-lane-over-node-cap"); + } + return { ports: [], cdpPort: null, screen: null }; + }, + }); + await expect( + broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }), + ).rejects.toMatchObject({ name: "NodeRpcError", detail: "preview-lane-over-node-cap" }); + expect(cmds(h, "preview_close")).toHaveLength(0); + expect(await auditRows("node.preview_forward.open")).toHaveLength(0); + }); + + it("mode is raw exactly at the screen's rfbPort, by the live ports answer first", async () => { + const h = makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const raw = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + const http = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(broker().sessionFor(raw.ref)?.mode).toBe("raw"); + expect(broker().sessionFor(http.ref)?.mode).toBe("http"); + // The screen came from getPorts alone: no display memory existed this run. + expect(cmds(h, "preview_open")).toHaveLength(2); + }); + + it("plane-restart regression: after resetForTests the ports answer ALONE still lands raw", async () => { + // Memory-only discrimination would ship `http` here and re-handshake raw + // RFB over an http proxy lane (decision 6). + makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const first = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + expect(broker().sessionFor(first.ref)?.mode).toBe("raw"); + + // Fresh broker (fresh memory), SAME fake ports answer. + makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const second = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + expect(broker().sessionFor(second.ref)?.mode).toBe("raw"); + }); + + it("mac leg: a refused ports walk falls back to the onDisplayOpened memory", async () => { + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + throw new NodeRpcError("failed", "refused", NODE, "preview-ports-unsupported"); + } + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + return {}; + }, + }); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const raw = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + const http = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(broker().sessionFor(raw.ref)?.mode).toBe("raw"); + expect(broker().sessionFor(http.ref)?.mode).toBe("http"); + }); +}); + +describe("lane pools: the per-pane cap, the queue, the close", () => { + it("four lanes open, the fifth through twelfth queue, the thirteenth refuses; a close promotes a waiter", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + + const lanes: string[] = []; + for (let i = 0; i < 4; i += 1) { + const lane = await broker().openLaneForConnection(ref); + expect(lane.laneRef).toMatch(/^[0-9a-f]{32}$/); + lanes.push(lane.laneRef); + } + expect(lanes).toEqual([...new Set(lanes)]); + + // Eight queued waiters: pending, and they mint no lane while queued. + const settled: string[] = []; + const waiters = Array.from({ length: PREVIEW_LANE_QUEUE_MAX }, () => + broker() + .openLaneForConnection(ref) + .then((l) => { + settled.push(l.laneRef); + return l.laneRef; + }), + ); + await settle(); + expect(settled).toHaveLength(0); + expect(cmds(h, "preview_open")).toHaveLength(5); // probe + 4 lanes + + // The next in the door past the cap AND past the queue refuses named. + await expect(broker().openLaneForConnection(ref)).rejects.toBeInstanceOf(PreviewRefusal); + await expect(broker().openLaneForConnection(ref)).rejects.toMatchObject({ code: "preview-lane-over-cap" }); + + // Releasing one lane frees the node slot AND promotes the first waiter. + await broker().closeLaneForConnection(lanes[0], "viewer-left"); + await settle(); + expect(settled).toHaveLength(1); + const promoted = await waiters[0]; + expect(promoted).toMatch(/^[0-9a-f]{32}$/); + expect(promoted).not.toBe(lanes[0]); + + expect(cmds(h, "preview_open")).toHaveLength(6); + expect(cmds(h, "preview_close")).toContainEqual({ type: "preview_close", ref: lanes[0], reason: "viewer-left" }); + // Lane closes audit nothing; the pair belongs to the session. + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + // Seven waiters stay queued when this test ends, and the NEXT test's + // resetForTests shutdown rejects them. Marking those rejections handled + // here is what keeps the reset from surfacing as an unhandled error + // against a test that never touched the queue. + for (const w of waiters.slice(1)) w.catch(() => {}); + }); + + it("closeLaneForConnection frees the node slot immediately and ends subscribers with the reason", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + await broker().closeLaneForConnection(laneRef, "viewer-left"); + expect(cmds(h, "preview_close")).toContainEqual({ type: "preview_close", ref: laneRef, reason: "viewer-left" }); + expect(events).toEqual([{ closed: "viewer-left" }]); + // The lane is gone: a late byte send drops instead of resurrecting it. + h.linked.length = 0; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(h.linked).toHaveLength(0); + // And a completed one-shot response's close left the session open for + // its next connection, with no close audit. + expect(broker().sessionFor(ref)).not.toBeNull(); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + }); + + it("a closed pane's queued waiters are rejected, not left pending forever", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + for (let i = 0; i < 4; i += 1) await broker().openLaneForConnection(ref); + const waiter = broker().openLaneForConnection(ref); + await broker().closeForPane(PANE, "pane-gone"); + await expect(waiter).rejects.toBeInstanceOf(Error); + expect(cmds(h, "preview_open")).toHaveLength(5); + }); + + it("the lane cap is per PANE, counted across the pane's sessions", async () => { + const h = makeHarness(); + const a = await broker().openForward({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 8080 }); + const b = await broker().openForward({ viewerUserId: "u-b", paneId: PANE, nodeId: NODE, port: 8081 }); + await broker().openLaneForConnection(a.ref); + await broker().openLaneForConnection(a.ref); + const b1 = await broker().openLaneForConnection(b.ref); + await broker().openLaneForConnection(b.ref); + const settled: string[] = []; + void broker() + .openLaneForConnection(a.ref) + .then((l) => { + settled.push(l.laneRef); + }); + await settle(); + expect(settled).toHaveLength(0); + // A lane released under session B still promotes session A's waiter: + // the cap is the pane's, not the session's. + await broker().closeLaneForConnection(b1.laneRef, "viewer-left"); + await settle(); + expect(settled).toHaveLength(1); + void h; + }); + + it("sendLaneBytes rides the byte sender, not the command RPC; traffic keeps the idle clock off", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const got: Uint8Array[] = []; + broker().subscribeLane(laneRef, (e) => { + if ("bytes" in e) got.push(e.bytes); + }); + + h.clock.now += 60_000; + broker().sendLaneBytes(laneRef, new Uint8Array([0x48, 0x69])); + expect(h.linked).toHaveLength(1); + expect(h.linked[0]?.nodeId).toBe(NODE); + expect(h.linked[0]?.frame).toEqual({ type: "preview_data", ref: laneRef, dir: "in", blob: "SGk=" }); + // No command rode the RPC path for bytes: the split mirrors the relay's. + expect(h.sent.filter((c) => c.cmd.type === ("preview_data" as NodeCommandBody["type"]))).toHaveLength(0); + expect(got).toHaveLength(0); // an `in` send does not echo to subscribers + + // Keep traffic alive across one idle window: the sweep must skip it. + h.clock.now += PREVIEW_IDLE_MS; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(await broker().sweepIdle()).toBe(0); + expect(broker().sessionFor(ref)).not.toBeNull(); + + // And the OUT leg decodes to bytes for the lane's subscribers. + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "SGk=" }); + expect(Array.from(got[0] ?? [])).toEqual([0x48, 0x69]); + }); + + it("bytes over the frame cap: nothing rides the link, the lane dies over-cap, the session survives", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + broker().sendLaneBytes(laneRef, new Uint8Array(PREVIEW_FRAME_MAX_BYTES + 1)); + await settle(); + expect(h.linked).toHaveLength(0); + expect(events).toEqual([{ closed: "over-cap" }]); + expect(broker().sessionFor(ref)).not.toBeNull(); + // The ONE lane-level audit, named, carrying no ref and no byte content. + const rows = await metadataOf("node.preview_lane.over_cap"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, nodeId: NODE, port: 8080, kind: "forward", reason: "over-cap" }); + for (const row of await auditRows("node.preview_lane.over_cap")) { + expect(row.metadataJson).not.toContain(laneRef); + } + // A dead lane's later sends drop quietly. + h.linked.length = 0; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(h.linked).toHaveLength(0); + }); + + it("a node lane frame naming an unknown ref, the wrong direction, or another node is dropped", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const got: Uint8Array[] = []; + broker().subscribeLane(laneRef, (e) => { + if ("bytes" in e) got.push(e.bytes); + }); + // An `in` leg from the node is nonsense (the plane owns that direction). + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "in", blob: "AQ==" }); + // A frame naming another node's ref: dropped (the socket's node is the fence). + broker().onNodeLaneFrame("n-other", { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: GHOST_REF, dir: "out", blob: "AQ==" }); + expect(got).toHaveLength(0); + expect(h.linked).toHaveLength(0); + }); + + it("a node-reported lane close ends subscribers with its reason, audits nothing, sends nothing back", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + await broker().onNodeReportedClose(NODE, laneRef, "target-gone"); + await settle(); + expect(events).toEqual([{ closed: "target-gone" }]); + expect(broker().sessionFor(ref)).not.toBeNull(); + // Wire-only: the only preview_close on the wire is openForward's own probe + // close from the open (which names the session ref); nothing new is sent + // back for the lane the node already closed, and nothing is audited. + expect(cmds(h, "preview_close").filter((c) => c.ref !== ref)).toHaveLength(0); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + }); + + it("subscribeLane unsubscribes by the returned function; an unknown lane's subscribe is a noop", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + let count = 0; + const off = broker().subscribeLane(laneRef, () => { + count += 1; + }); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + expect(count).toBe(1); + off(); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + expect(count).toBe(1); + const late = broker().subscribeLane(GHOST_REF, () => { + count += 1; + }); + late(); + expect(count).toBe(1); + }); + + it("lane callbacks never cross duplexes: one session's two lanes pump independently", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const a = await broker().openLaneForConnection(ref); + const b = await broker().openLaneForConnection(ref); + const seen: string[] = []; + broker().subscribeLane(a.laneRef, () => seen.push("a")); + broker().subscribeLane(b.laneRef, () => seen.push("b")); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: b.laneRef, dir: "out", blob: "AQ==" }); + expect(seen).toEqual(["b"]); + }); +}); + +describe("watch bridges: one per pane, refcounted, idempotent", () => { + it("zero->one sends preview_open and audits once; joins ride no wire and no audit", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const opens = cmds(h, "preview_open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toMatchObject({ type: "preview_open", kind: "screencast", paneId: PANE, port: 9222 }); + expect(opens[0]?.type === "preview_open" ? opens[0].ref : "").toMatch(/^[0-9a-f]{32}$/); + expect((await auditRows("node.preview_watch.open")).length).toBe(1); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(1); + + await broker().startWatch({ viewerUserId: "u-b", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(cmds(h, "preview_open")).toHaveLength(1); // the ack's `subscribers: 1` is a lie the broker ignores + expect((await auditRows("node.preview_watch.open")).length).toBe(1); + expect(bridgeFor(PANE)?.viewers.get("u-b")).toBe(1); + + await broker().stopWatch("u-a", PANE); + expect(cmds(h, "preview_close")).toHaveLength(0); + await broker().stopWatch("u-b", PANE); + const closes = cmds(h, "preview_close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ reason: "viewer-left" }); + const watchCloses = await metadataOf("node.preview_watch.close"); + expect(watchCloses).toHaveLength(1); + expect(watchCloses[0]).toMatchObject({ + paneId: PANE, + nodeId: NODE, + port: 9222, + kind: "screencast", + reason: "viewer-left", + }); + expect(bridgeFor(PANE)).toBeNull(); + }); + + it("two POSTs from ONE user are count 2, and the first DELETE leaves the bridge open", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(bridgeFor(PANE)?.viewers.get("u-one")).toBe(2); + + await broker().stopWatch("u-one", PANE); + expect(cmds(h, "preview_close")).toHaveLength(0); + expect(bridgeFor(PANE)?.viewers.get("u-one")).toBe(1); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + + await broker().stopWatch("u-one", PANE); + expect(cmds(h, "preview_close")).toHaveLength(1); + }); + + it("stopWatch after the zero-close, and after a node-reported teardown, answer ok and send nothing", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + await broker().stopWatch("u-one", PANE); + const after = h.sent.length; + expect(await broker().stopWatch("u-one", PANE)).toEqual({ ok: true }); + expect(h.sent.length).toBe(after); + expect(await auditRows("node.preview_watch.close")).toHaveLength(1); // not 2 + + // The post-teardown twin: the node's close zeroes the counts, and a + // DELETE arriving after it must not send a second preview_close into a + // dead bridge. + await broker().startWatch({ viewerUserId: "u-two", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = bridgeFor(PANE); + expect(bridge).not.toBeNull(); + await broker().onNodeReportedClose(NODE, String(bridge?.ref), "target-gone"); + await settle(); + const sentNow = h.sent.length; + expect(await broker().stopWatch("u-two", PANE)).toEqual({ ok: true }); + expect(h.sent.length).toBe(sentNow); + expect((await auditRows("node.preview_watch.close")).length).toBe(2); // the report's row, not a new one + }); + + it("a POST naming another port is refused watch-port-conflict and touches no count", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + await expect( + broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9333 }), + ).rejects.toMatchObject({ code: "watch-port-conflict" }); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(1); + expect(bridgeFor(PANE)?.viewers.size).toBe(1); + expect(cmds(h, "preview_open")).toHaveLength(1); + await settle(); + expect(await auditRows("node.preview_watch.open")).toHaveLength(1); + }); + + it("the subscriber cap is PER BRIDGE: eight across three users fills it, the ninth refuses touching no count", async () => { + const h = makeHarness(); + const at = (u: string) => broker().startWatch({ viewerUserId: u, paneId: PANE, nodeId: NODE, port: 9222 }); + await at("u-a"); + await at("u-a"); + await at("u-a"); + await at("u-b"); + await at("u-b"); + await at("u-b"); + await at("u-c"); + await at("u-c"); + let sum = 0; + for (const n of bridgeFor(PANE)?.viewers.values() ?? []) sum += n; + expect(sum).toBe(PREVIEW_WATCH_SUBSCRIBERS_MAX); + + await expect(at("u-d")).rejects.toMatchObject({ code: "preview-watch-over-cap" }); + expect(bridgeFor(PANE)?.viewers.has("u-d")).toBe(false); + expect(cmds(h, "preview_open")).toHaveLength(1); + }); + + it("a node refusal on the bridge open propagates and records nothing", async () => { + const h = makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_open" && cmd.kind === "screencast") { + throw new NodeRpcError("failed", "refused", NODE, "preview-watch-over-node-cap"); + } + return {}; + }, + }); + await expect( + broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }), + ).rejects.toMatchObject({ detail: "preview-watch-over-node-cap" }); + expect(bridgeFor(PANE)).toBeNull(); + expect(await auditRows("node.preview_watch.open")).toHaveLength(0); + expect(cmds(h, "preview_close")).toHaveLength(0); + }); + + it("a node-reported BRIDGE close tears the plane's half down and the next startWatch re-opens", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = bridgeFor(PANE); + const socket = fakeSocket(); + expect(broker().attachStream(PANE, "k-a", "u-a", socket)).toEqual({ ok: true }); + + await broker().onNodeReportedClose(NODE, String(bridge?.ref), "target-gone"); + await settle(); + expect(bridgeFor(PANE)).toBeNull(); + const rows = await metadataOf("node.preview_watch.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, kind: "screencast", reason: "target-gone" }); + // The plane sends no preview_close for a bridge the node already killed. + expect(cmds(h, "preview_close")).toHaveLength(0); + + // The next startWatch hits the zero->one edge and re-sends preview_open, + // because "a matching port just joins" may only ever join a LIVE bridge. + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(cmds(h, "preview_open")).toHaveLength(2); + const fresh = bridgeFor(PANE); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(fresh?.ref), 1)); + expect(h.delivered).toHaveLength(0); // the old socket stays detached; re-watch to see frames + broker().attachStream(PANE, "k-b", "u-a", fakeSocket()); + broker().onStreamEvent(NODE, streamEvent(String(fresh?.ref), 2)); + expect(h.delivered).toHaveLength(1); + }); +}); + +describe("stream sockets: the attach cap, the keying, the byte debt", () => { + it("eight attach; the ninth is refused at attach with a viewer_drop audit while eight keep streaming", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const sockets = Array.from({ length: PREVIEW_WATCH_SUBSCRIBERS_MAX }, () => fakeSocket()); + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker().attachStream(PANE, `k-${i}`, "u-holder", sockets[i])).toEqual({ ok: true }); + } + const ninth = fakeSocket(); + expect(broker().attachStream(PANE, "k-8", "u-holder", ninth)).toEqual({ + ok: false, + code: "preview-stream-over-cap", + }); + await settle(); + const drops = await metadataOf("node.preview_watch.viewer_drop"); + expect(drops).toHaveLength(1); + expect(drops[0]).toMatchObject({ paneId: PANE, reason: "over-cap" }); + expect(drops[0]).not.toHaveProperty("ref"); + // Nothing closed: the bridge still streams to the first eight. + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + expect(h.delivered).toHaveLength(PREVIEW_WATCH_SUBSCRIBERS_MAX); + expect(h.delivered.some((d) => d.socket === ninth)).toBe(false); + }); + + it("attach is key-based, never identity: two sockets under different keys fan out; detach stops frames", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const s1 = fakeSocket(); + const s2 = fakeSocket(); + // Two tabs, ONE holder: decision 7's two-tab case on the socket half. + expect(broker().attachStream(PANE, "conn-1", "u-one", s1)).toEqual({ ok: true }); + expect(broker().attachStream(PANE, "conn-2", "u-one", s2)).toEqual({ ok: true }); + + broker().onStreamEvent(NODE, streamEvent(bridge, 7)); + expect(h.delivered).toHaveLength(2); + expect(h.delivered[0]?.frame).toEqual({ type: "preview-stream", id: PANE, seq: 7, w: 100, h: 50, blob: "blob-7" }); + + // Elysia hands the NEXT callback a fresh wrapper; the caller passes the + // SAME per-connection id. An identity-keyed map finds nothing here. + broker().detachStream(PANE, "conn-1"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 8)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(s2); + + // detachSocketsFor walks every pane by key alone (the socket's `close` + // path hands the broker nothing but the id). + broker().detachSocketsFor("conn-2"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 9)); + expect(h.delivered).toHaveLength(0); + // And the detached keys freed their cap slots: eight can attach again. + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker().attachStream(PANE, `re-${i}`, "u-one", fakeSocket())).toEqual({ ok: true }); + } + }); + + it("byte debt over the max detaches THAT socket with a viewer_drop audit; the bridge and siblings keep running", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const slow = fakeSocket({ debt: () => PREVIEW_STREAM_DEBT_MAX + 1 }); + // The sibling WITHOUT the method at all: the `?? 0` read keeps its + // frames flowing (the property spelling is the trap the platform lacks). + const sibling = fakeSocket({ withDebtMethod: false }); + broker().attachStream(PANE, "k-slow", "u-a", slow); + broker().attachStream(PANE, "k-fast", "u-a", sibling); + + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + await settle(); + const drops = await metadataOf("node.preview_watch.viewer_drop"); + expect(drops).toHaveLength(1); + expect(drops[0]).toMatchObject({ paneId: PANE, reason: "slow-link" }); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 2)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(sibling); + // The lane and bridge untouched: the session still stands, and the drop + // closes nothing on the wire (spec §9 drops slow VIEWERS, never the bridge). + expect(bridgeFor(PANE)).not.toBeNull(); + expect(cmds(h, "preview_close")).toHaveLength(0); + }); + + it("an unconfigured broker never fans out: send never reaches a socket by itself", async () => { + makeHarness({ sendToSockets: null }); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const socket = fakeSocket(); + expect(broker().attachStream(PANE, "k-1", "u-a", socket)).toEqual({ ok: true }); + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + await settle(); + // The broker imports nothing from ws/ and never touches socket.send. + expect(socket.frames).toHaveLength(0); + }); + + it("configurePreviewStreamSender fills the fan-out slot for a broker built without a fake sender", async () => { + const seen: unknown[] = []; + // Boot order, as Task 11 will call it: the slot is filled first, and the + // broker built after it inherits the sender. + configurePreviewStreamSender((_socket, frame) => { + seen.push(frame); + }); + resetForTests({ + send: async (_n, cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer() + : cmd.type === "preview_open" + ? { ref: cmd.ref, subscribers: 1 } + : {}, + }); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + broker().attachStream(PANE, "k-1", "u-a", fakeSocket()); + broker().onStreamEvent(NODE, streamEvent(bridge, 3)); + expect(seen).toHaveLength(1); + }); + + it("an event naming an unknown ref, or another node's bridge, fans out to nobody", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + broker().attachStream(PANE, "k-1", "u-a", fakeSocket()); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(GHOST_REF, 1)); + broker().onStreamEvent("n-other", streamEvent(String(bridgeFor(PANE)?.ref), 2)); + expect(h.delivered).toHaveLength(0); + }); +}); + +describe("idle sweep: forward only", () => { + it("closes the idle forward session with `idle` and leaves a watched bridge with zero frames untouched", async () => { + const h = makeHarness(); + const fwd = await broker().openForward({ viewerUserId: "u-open", paneId: "p-fwd", nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-watch", paneId: PANE, nodeId: NODE, port: 9222 }); + + h.clock.now += PREVIEW_IDLE_MS; + const closed = await broker().sweepIdle(); + await settle(); + expect(closed).toBe(1); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor(PANE)).not.toBeNull(); // watched bridge: zero frames, zero closes + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: "p-fwd", reason: "idle" }); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + // A wire close rode for the lane with the named reason (the session ref + // was the probe, already closed on the node at open). + expect(cmds(h, "preview_close").filter((c) => c.reason === "idle")).toHaveLength(1); + }); +}); + +describe("closeForPane and the node/user sweeps", () => { + it("closeForPane(pane-gone) closes forward sessions, the bridge, and the display, auditing every pair", async () => { + const h = makeHarness(); + const fwd = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-watch", paneId: PANE, nodeId: NODE, port: 9222 }); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const other = await broker().openForward({ viewerUserId: "u-open", paneId: "p-other", nodeId: NODE, port: 8081 }); + + await broker().closeForPane(PANE, "pane-gone"); + await settle(); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor(PANE)).toBeNull(); + expect(broker().sessionFor(other.ref)).not.toBeNull(); // no bleed + + expect(await metadataOf("node.preview_forward.close")).toEqual([ + { paneId: PANE, nodeId: NODE, port: 8080, kind: "forward", reason: "pane-gone" }, + ]); + expect((await metadataOf("node.preview_watch.close"))[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + expect((await metadataOf("node.preview_display.close"))[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + // Lane and bridge rode the wire with the named reason. The forward + // SESSION adds no frame: its ref was the probe, already closed at open, + // so the node holds live state only for the lane. + expect(cmds(h, "preview_close").filter((c) => c.reason === "pane-gone")).toHaveLength(2); + }); + + it("closeAllForNode sweeps the node's sessions and display with node-dropped", async () => { + const h = makeHarness(); + const mine = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(mine.ref); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const otherNode = await broker().openForward({ + viewerUserId: "u-open", + paneId: "p-n2", + nodeId: "n-different", + port: 8080, + }); + + await broker().closeAllForNode(NODE, "node-dropped"); + await settle(); + expect(broker().sessionFor(mine.ref)).toBeNull(); + expect(broker().sessionFor(otherNode.ref)).not.toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, nodeId: NODE, reason: "node-dropped" }); + expect((await metadataOf("node.preview_display.close"))[0]).toMatchObject({ paneId: PANE, reason: "node-dropped" }); + // The lane frame (the session ref was the probe, closed at open). + expect(cmds(h, "preview_close").filter((c) => c.reason === "node-dropped")).toHaveLength(1); + }); + + it("closeAllForUser sweeps the user's forwards, watch counts, and held streams with revoked", async () => { + const h = makeHarness(); + const theirs = await broker().openForward({ viewerUserId: "u-victim", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(theirs.ref); + const mine = await broker().openForward({ + viewerUserId: "u-bystander", + paneId: "p-other", + nodeId: NODE, + port: 8080, + }); + // Their watch count on a bridge someone else started: it decrements out, + // and the bridge lives on under its remaining watcher. + await broker().startWatch({ viewerUserId: "u-bystander", paneId: "p-shared", nodeId: NODE, port: 9222 }); + await broker().startWatch({ viewerUserId: "u-victim", paneId: "p-shared", nodeId: NODE, port: 9222 }); + // Their stream socket on a bridge they did not create. + await broker().startWatch({ viewerUserId: "u-holder", paneId: "p-stream", nodeId: NODE, port: 9222 }); + broker().attachStream("p-stream", "k-v", "u-victim", fakeSocket()); + + await broker().closeAllForUser("u-victim", "revoked"); + await settle(); + expect(broker().sessionFor(theirs.ref)).toBeNull(); + expect(broker().sessionFor(mine.ref)).not.toBeNull(); + const shared = bridgeFor("p-shared"); + expect(shared).not.toBeNull(); + expect(shared?.viewers.has("u-victim")).toBe(false); + expect(shared?.viewers.get("u-bystander")).toBe(1); + // The victim's stream socket is detached, never closed: frames stop. + const streamBridge = bridgeFor("p-stream"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(streamBridge?.ref), 1)); + expect(h.delivered).toHaveLength(0); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, reason: "revoked" }); + void h; + }); +}); + +describe("getPorts: the 5 second cache and the screenLost marker", () => { + it("caches for the window per pane, then re-RPCs", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + return portsAnswer(); + } + return {}; + }, + }); + await broker().getPorts(NODE, PANE); + await broker().getPorts(NODE, PANE); + expect(rpcs).toBe(1); + // Per-pane key: a different pane is a different cache slot. + await broker().getPorts(NODE, "p-other"); + expect(rpcs).toBe(2); + }); + + it("the cached answer carries screenLost through, marker and all", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + return { ...portsAnswer(), screenLost: "x11vnc" as const }; + } + return {}; + }, + }); + const first = await broker().getPorts(NODE, PANE); + expect(first.screenLost).toBe("x11vnc"); + const second = await broker().getPorts(NODE, PANE); + expect(second.screenLost).toBe("x11vnc"); + expect(rpcs).toBe(1); + }); + + it("no screenLost key at all when the RPC answer carried none (absent, never null)", async () => { + makeHarness({ + ack: (cmd) => (cmd.type === "preview_ports_get" ? portsAnswer() : {}), + }); + const answer = await broker().getPorts(NODE, PANE); + expect("screenLost" in answer).toBe(false); + const cached = await broker().getPorts(NODE, PANE); + expect("screenLost" in cached).toBe(false); + }); + + it("a node refusal rejects named and caches NOTHING; a malformed answer rejects", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + if (rpcs === 1) throw new NodeRpcError("offline", "down", NODE); + return { not: "a ports answer" }; + } + return {}; + }, + }); + await expect(broker().getPorts(NODE, PANE)).rejects.toBeInstanceOf(NodeRpcError); + await expect(broker().getPorts(NODE, PANE)).rejects.toBeInstanceOf(Error); + expect(rpcs).toBe(2); // neither failure left a cache entry + }); +}); + +describe("display memory: onDisplayOpened / onDisplayClosed", () => { + it("audits open with {display, rfbPort} and close with the reason; double-close is idempotent", async () => { + makeHarness(); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const opens = await metadataOf("node.preview_display.open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toEqual({ paneId: PANE, nodeId: NODE, display: ":7", rfbPort: 5900 }); + + await broker().onDisplayClosed(PANE, "pane-gone"); + const closes = await metadataOf("node.preview_display.close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + + await broker().onDisplayClosed(PANE, "idle"); + expect(await metadataOf("node.preview_display.close")).toHaveLength(1); + // And a close for a pane with no display record audits nothing either. + await broker().onDisplayClosed("p-never-opened", "pane-gone"); + expect(await metadataOf("node.preview_display.close")).toHaveLength(1); + }); +}); + +describe("closeUnauthorizedForPane: the recompute, one case per transition", () => { + function accessMap(map: Record) { + return async (userId: string, paneId: string): Promise => map[`${userId}@${paneId}`] ?? "none"; + } + + it("an edit->view DOWNGRADE closes the grantee's forward session (lanes in, revoked) and keeps their watch streaming", async () => { + const h = makeHarness({ accessFor: accessMap({ "u-owner@p1": "owner", "u-grantee@p1": "view" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-grantee", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-grantee", paneId: "p1", nodeId: NODE, port: 9222 }); + broker().attachStream("p1", "k-g", "u-grantee", fakeSocket()); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + + expect(broker().sessionFor(fwd.ref)).toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: "p1", reason: "revoked" }); + // The view tier they still hold: watch count, bridge, and socket untouched. + const bridge = bridgeFor("p1"); + expect(bridge).not.toBeNull(); + expect(bridge?.viewers.get("u-grantee")).toBe(1); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(bridge?.ref), 1)); + expect(h.delivered).toHaveLength(1); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + expect(cmds(h, "preview_close").filter((c) => c.reason === "revoked")).toHaveLength(1); // the lane + }); + + it("a full removal sweeps BOTH tiers, empties the bridge to zero with revoked, and never closes the socket", async () => { + const h = makeHarness({ accessFor: accessMap({ "u-owner@p1": "owner", "u-stranger@p1": "none" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-stranger", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().startWatch({ viewerUserId: "u-stranger", paneId: "p1", nodeId: NODE, port: 9222 }); + const socket = fakeSocket(); + broker().attachStream("p1", "k-s", "u-stranger", socket); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor("p1")).toBeNull(); + const closes = await metadataOf("node.preview_watch.close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ paneId: "p1", reason: "revoked" }); // not viewer-left + + // A NEW owner-run bridge proves the revoked holder's socket is gone from + // the pane's attach set while the socket object itself was never closed + // (the /ws/live feed outlives a single lost grant). + await broker().startWatch({ viewerUserId: "u-owner", paneId: "p1", nodeId: NODE, port: 9222 }); + const survivor = fakeSocket(); + broker().attachStream("p1", "k-o", "u-owner", survivor); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(bridgeFor("p1")?.ref), 1)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(survivor); + }); + + it("the Everyone row's edit->view write closes every non-owner forward session; the owner's and a still-edit grantee's sit untouched", async () => { + makeHarness({ + accessFor: accessMap({ + "u-owner@p1": "owner", + "u-downgraded@p1": "view", + "u-still-edit@p1": "edit", + }), + }); + const owner = await broker().openForward({ viewerUserId: "u-owner", paneId: "p1", nodeId: NODE, port: 8080 }); + const downgraded = await broker().openForward({ + viewerUserId: "u-downgraded", + paneId: "p1", + nodeId: NODE, + port: 8081, + }); + const stillEdit = await broker().openForward({ + viewerUserId: "u-still-edit", + paneId: "p1", + nodeId: NODE, + port: 8082, + }); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(owner.ref)).not.toBeNull(); + expect(broker().sessionFor(stillEdit.ref)).not.toBeNull(); + expect(broker().sessionFor(downgraded.ref)).toBeNull(); + expect(await metadataOf("node.preview_forward.close")).toHaveLength(1); + }); + + it("a view->edit UPGRADE closes nothing (the recompute fires on every shares write, even widening ones)", async () => { + const h = makeHarness({ accessFor: accessMap({ "u-up@p1": "edit" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-up", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().startWatch({ viewerUserId: "u-up", paneId: "p1", nodeId: NODE, port: 9222 }); + broker().attachStream("p1", "k-u", "u-up", fakeSocket()); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(fwd.ref)).not.toBeNull(); + expect(bridgeFor("p1")).not.toBeNull(); + // Nothing beyond the open's probe close: the sweep closes nothing on a + // widening write. + expect(cmds(h, "preview_close").filter((c) => c.ref !== fwd.ref)).toHaveLength(0); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + }); + + it("no transition reaches a different pane's sessions", async () => { + const h = makeHarness({ accessFor: async () => "none" }); + const other = await broker().openForward({ + viewerUserId: "u-gone", + paneId: "p-elsewhere", + nodeId: NODE, + port: 8080, + }); + await broker().openForward({ viewerUserId: "u-gone", paneId: "p1", nodeId: NODE, port: 8080 }); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(other.ref)).not.toBeNull(); + expect(h.accessCalls.every((c) => c.paneId === "p1")).toBe(true); + }); +}); + +describe("the shares write performs the cut (real SubshellsService.setShares)", () => { + it("setShares fires closeUnauthorizedForPane: the downgraded grantee's forward session closes revoked", async () => { + const ctx = getRequestlessContext(); + const service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); + const paneId = crypto.randomUUID(); + const owner = "u-shares-owner"; + const grantee = "u-shares-grantee"; + // setShares validates every grantee against the users table for real (the + // access seam fakes only the revocation recompute), so both principals + // must exist as rows. + for (const id of [owner, grantee]) { + await sql`INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) + VALUES (${id}, ${id}, ${`${id}@example.test`}, 1, datetime('now'), datetime('now'))`.execute(db); + } + await ctx.repos.subshells.create({ + id: paneId, + userId: owner, + harnessId: "terminal", + name: "preview shares row", + workingDir: "/tmp", + tmuxSocket: `subshell-${paneId.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: "n-preview", + ssh: null, + }); + // The fake answers the POST-WRITE access (the row is already replaced): + // the grantee resolves to `view`, under the forward tier they hold. + const h = makeHarness({ + accessFor: async (userId) => (userId === owner ? "owner" : userId === grantee ? "view" : "none"), + }); + const fwd = await broker().openForward({ viewerUserId: grantee, paneId, nodeId: "n-preview", port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + + const entries: ShareEntry[] = [{ granteeUserId: grantee, permission: "view" }]; + await service.setShares(owner, paneId, entries, "cookie"); + await settle(); + + expect(broker().sessionFor(fwd.ref)).toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId, reason: "revoked" }); + expect(h.sent.some((c) => c.cmd.type === "preview_close" && c.cmd.reason === "revoked")).toBe(true); + await ctx.repos.subshells.delete(paneId).catch(() => {}); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index 4c0bff0d0..adf511142 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -6,6 +6,7 @@ import { NODE_CLOSE_UPDATE_REQUIRED, NODE_MAX_FRAME_BYTES, NODE_PROTOCOL_VERSION, + type NodeCommandBody, type NodeEvent, type RelayFrame, SSH_RELAY_FRAME_MAX_BYTES, @@ -22,6 +23,7 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { getPreviewBroker, resetForTests as resetPreviewBrokerForTests } from "@/services/preview-broker.js"; import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; @@ -51,7 +53,15 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; -import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { + previewClosesSeenFor, + previewFramesSeenFor, + previewStreamEventsSeenFor, + relayFramesSeenFor, + relayOverCapsSeenFor, + resetPreviewFramesForTests, + resetRelayFramesForTests, +} from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -2280,3 +2290,102 @@ describe("handleNodeMessage + handleNodeClose → the update tracker", () => { expect(readView().nodes.n1?.phase).toBe("working"); // the real one was never told }); }); + +/* Preview intake and the link-drop cut (pane-preview Task 8). The event + switch has NO default branch, so these rows are the only evidence the + three preview events reach the broker at all: a broker test that calls + the verbs directly cannot see a missing `case`, and a seam test that + calls relay-frames directly cannot see a switch that never routes. */ +describe("preview intake through the event switch", () => { + const LANE_REF = "ab".repeat(16); + + beforeEach(() => { + resetPreviewFramesForTests(); + }); + + it("preview_data reaches the lane-pump seam, keyed by the socket's nodeId", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_data", ref: LANE_REF, dir: "out", blob: "QUJD" }), + ); + expect(previewFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: LANE_REF, lastDir: "out" }); + // The summary records counts, never byte content. + expect(JSON.stringify(previewFramesSeenFor("n1"))).not.toContain("QUJD"); + }); + + it("preview_stream reaches the fan-out seam", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_stream", ref: LANE_REF, seq: 3, w: 80, h: 24, ts: 1, blob: "QUJD" }), + ); + expect(previewStreamEventsSeenFor("n1")).toEqual({ events: 1, lastRef: LANE_REF, lastSeq: 3 }); + }); + + it("preview_closed reaches the close-report seam with its reason", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_closed", ref: LANE_REF, reason: "target-gone" }), + ); + expect(previewClosesSeenFor("n1")).toEqual({ reports: 1, lastRef: LANE_REF, lastReason: "target-gone" }); + }); + + it("an unknown preview reason is refused by the grammar and reaches no seam", async () => { + // The reason set is closed (plan decision 3); a half-sent refusal is + // dropped whole, like every other grammar refusal this file pins. + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_closed", ref: LANE_REF, reason: "someone-liked-it" }), + ); + expect(previewClosesSeenFor("n1")).toBeUndefined(); + }); + + it("handleNodeClose cuts the node's preview sessions with node-dropped", async () => { + // The relay's `a-dropped` witness already lives in this hook; the broker + // needs the same one, or a link flap leaves sessions and lanes + // plane-side against refs the node has already forgotten. + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: "u-prev", + paneId: "p-prev", + nodeId: "n1", + port: 3000, + }); + const lane = await getPreviewBroker().openLaneForConnection(ref); + + const h = makeHarness(); + const ws = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, ws); + await handleNodeMessage(h.deps, ws, readyFrame()); + expect(getLive("n1")).toBeDefined(); + + await handleNodeClose(h.deps, ws); + // The hook is fire-and-forget (a link-drop must not block the close + // path), so let its sweep land before reading. + await new Promise((r) => setTimeout(r, 20)); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect( + sent.some((c) => c.type === "preview_close" && c.ref === lane.laneRef && c.reason === "node-dropped"), + ).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); +}); diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 197dfbfa2..8ef1e2c53 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -31,6 +31,7 @@ import { import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; import { bootstrapSshIdentityOnReady } from "@/services/nodes/ssh-identity.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -48,7 +49,14 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; +import { + onNodeSocketClosed, + onPreviewClosedReport, + onPreviewFrame, + onPreviewStreamEvent, + onRelayFrame, + onRelayFrameOverCap, +} from "./relay-frames.js"; import { nodeRouteTickets } from "./route-tickets.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; @@ -1225,6 +1233,31 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: onRelayFrame(nodeId, event); return; } + case "preview_data": { + // The preview lane leg (pane-preview spec §4). The same upstream facts + // the relay arm relies on hold here: the grammar validated shape and + // the PREVIEW_FRAME_MAX_BYTES cap (an over-cap blob answers + // `parsePreviewFrame` with null and never reaches this arm), the link + // is encrypted, the held/supersede guards ran. The blob stays opaque + // through here too: relay-frames' seam hands the frame to the broker's + // pump unopened; this is the routing seam, not the broker. + onPreviewFrame(nodeId, event); + return; + } + case "preview_stream": { + // One screencast frame; the broker fans it out to the pane's attached + // /ws/live sockets (spec §6) and drops slow viewers by byte debt + // (§9). Nothing about this frame reaches a viewer verbatim. + onPreviewStreamEvent(nodeId, event); + return; + } + case "preview_closed": { + // The node volunteering a death it observed (plan decision 3): a lane, + // or a bridge whose plane-side half must go down WITH it so the next + // watch re-opens a live one. The reason set is closed by the grammar. + onPreviewClosedReport(nodeId, event.ref, event.reason); + return; + } } } @@ -1301,6 +1334,19 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi // the node is NOT gone. Fire-and-forget by contract (relay-frames owns // the swallow). onNodeSocketClosed(nodeId); + // The preview broker's twin of that witness (pane-preview Task 8): with + // the link gone, every session, lane, bridge, and display record riding + // this node is plane-side state against refs the node has forgotten, so + // the sweep closes them named `node-dropped` (audited, and wire-only for + // the refs that still exist: an offline node takes its own closes with + // it). Fire-and-forget like the relay's: a link drop must not block the + // close path, and an unhooked broker would leave stale lanes parked + // until the 15-minute idle sweep. The superseded branch below calls + // nothing, for the same reason the relay's witness does not: a + // replacement socket means the node is NOT gone. + void getPreviewBroker() + .closeAllForNode(nodeId, "node-dropped") + .catch((err: unknown) => logger.warn(`preview node-drop cut for ${nodeId} threw: ${String(err)}`)); logger.debug(`node ws: ${nodeId} disconnected → offline`); return; } diff --git a/apps/server/api/src/services/nodes/preview-frames.ts b/apps/server/api/src/services/nodes/preview-frames.ts new file mode 100644 index 000000000..df512297b --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-frames.ts @@ -0,0 +1,50 @@ +import { NODE_MAX_FRAME_BYTES, type PreviewDataFrame } from "@internal/subshell-protocol"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { binaryPayload } from "@/services/nodes/node-rpc.js"; + +/** + * The plane-to-node leg of the preview lane (pane-preview spec §4, Task 8). + * A `preview_data` frame is a LINK frame, not a command: lane bytes never + * ride `sendCommand`, exactly as the relay split its own bytes from its + * commands. This file is the sanctioned twin of `sendRelayFrameOverNodeSocket` + * (ssh-relay.service.ts:651), kept separate rather than generalised because + * the relay sender is load-bearing for SSH and one clone is a smaller risk + * than one shared function with a union parameter. + */ + +/** Why one lane frame did not reach the node's socket. */ +export class PreviewSendError extends Error { + constructor( + readonly nodeId: string, + reason: string, + ) { + super(`preview frame to node "${nodeId}" not delivered: ${reason}`); + this.name = "PreviewSendError"; + } +} + +/** + * Write one preview lane frame to `nodeId`'s CURRENT live socket, sealed + * through its established link (any node that carries preview frames speaks + * protocol 23, whose sockets are handshake-classified). Throws + * {@link PreviewSendError} on every non-delivery: no live connection, a + * closing/superseded record, an unestablished link, a frame past the wire + * cap, or a failing write. The broker reads the throw as "this lane's node + * is gone" (the link-drop sweep then reaps the node's sessions named); a + * silent return would strand lane subscribers on a dead pipe. + */ +export function sendPreviewFrameOverNodeSocket(nodeId: string, frame: PreviewDataFrame): void { + const conn = getLive(nodeId); + if (!conn) throw new PreviewSendError(nodeId, "node has no live connection"); + if (conn.closing) throw new PreviewSendError(nodeId, "connection is closing (superseded or revoked)"); + if (!conn.link) throw new PreviewSendError(nodeId, "encrypted link is not established"); + const payload = JSON.stringify(frame); + if (Buffer.byteLength(payload) > NODE_MAX_FRAME_BYTES) { + throw new PreviewSendError(nodeId, `frame exceeds ${NODE_MAX_FRAME_BYTES} bytes`); + } + try { + conn.ws.send(binaryPayload(conn.link.sealFrame(payload))); + } catch (err) { + throw new PreviewSendError(nodeId, `socket send failed: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 776d3c8ec..928151da7 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -1,4 +1,5 @@ -import type { RelayFrame } from "@internal/subshell-protocol"; +import type { NodeEvent, PreviewCloseReason, PreviewDataFrame, RelayFrame } from "@internal/subshell-protocol"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { getRelayBroker } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -152,4 +153,132 @@ export function relayOverCapsSeenFor(nodeId: string): RelayOverCapRecord | undef export function resetRelayFramesForTests(): void { routedByNode.clear(); overCapByNode.clear(); + resetPreviewFramesForTests(); +} + +/* ------------------------------------------------------------------ */ +/* the preview intake (pane-preview spec §4, Task 8) */ +/* ------------------------------------------------------------------ */ + +/** + * The same door for the preview surface (protocol 23): `preview_data`, + * `preview_stream`, and `preview_closed` arrive on the identical + * authenticated, grammar-validated path the relay frames ride, and the + * preview broker owns everything past here (lane pumping, screencast + * fan-out, the reported-close teardown). Fire-and-forget for the same reason + * the relay legs are: a broker throw must not cost the socket the frames + * behind it. These records are also what the handler's dispatch rows assert, + * because the event switch has no default branch and only these rows can see + * a missing `case`. + */ + +/** What the seam records for one node's lane frames: counters and the lane ref, never a blob. */ +export interface PreviewLaneRouteRecord { + /** How many `preview_data` frames have been routed from this node. */ + frames: number; + /** The lane ref of the most recent frame (an opaque id). */ + lastRef: string; + /** The direction of the most recent frame (only `out` legs pump, but the record keeps either). */ + lastDir: PreviewDataFrame["dir"]; +} + +/** What the seam records for one node's screencast events. */ +export interface PreviewStreamRouteRecord { + /** How many `preview_stream` events have been routed from this node. */ + events: number; + /** The bridge ref of the most recent event. */ + lastRef: string; + /** The bridge counter of the most recent event (gap reporting is the broker's problem, not this seam's). */ + lastSeq: number; +} + +/** What the seam records for one node's reported closes. */ +export interface PreviewCloseRouteRecord { + /** How many `preview_closed` reports have been routed from this node. */ + reports: number; + /** The ref of the most recent report. */ + lastRef: string; + /** The named reason of the most recent report. */ + lastReason: string; +} + +const laneFramesByNode = new Map(); +const streamEventsByNode = new Map(); +const closeReportsByNode = new Map(); + +/** Route one lane frame to the broker's pump (the `out` leg to its subscribers). */ +export function onPreviewFrame(nodeId: string, frame: PreviewDataFrame): void { + const record = laneFramesByNode.get(nodeId); + if (record) { + record.frames += 1; + record.lastRef = frame.ref; + record.lastDir = frame.dir; + } else { + laneFramesByNode.set(nodeId, { frames: 1, lastRef: frame.ref, lastDir: frame.dir }); + } + try { + getPreviewBroker().onNodeLaneFrame(nodeId, frame); + } catch (err: unknown) { + logger.withError(err).warn(`preview lane routing for node ${nodeId} threw (contained): ${String(err)}`); + } +} + +/** Route one screencast frame to the broker's fan-out. */ +export function onPreviewStreamEvent(nodeId: string, ev: Extract): void { + const record = streamEventsByNode.get(nodeId); + if (record) { + record.events += 1; + record.lastRef = ev.ref; + record.lastSeq = ev.seq; + } else { + streamEventsByNode.set(nodeId, { events: 1, lastRef: ev.ref, lastSeq: ev.seq }); + } + try { + getPreviewBroker().onStreamEvent(nodeId, ev); + } catch (err: unknown) { + logger.withError(err).warn(`preview stream fan-out for node ${nodeId} threw (contained): ${String(err)}`); + } +} + +/** Route one node-reported close (a lane, or the plane's half of a bridge with it). */ +export function onPreviewClosedReport(nodeId: string, ref: string, reason: PreviewCloseReason): void { + const record = closeReportsByNode.get(nodeId); + if (record) { + record.reports += 1; + record.lastRef = ref; + record.lastReason = reason; + } else { + closeReportsByNode.set(nodeId, { reports: 1, lastRef: ref, lastReason: reason }); + } + void getPreviewBroker() + .onNodeReportedClose(nodeId, ref, reason) + .catch((err: unknown) => + logger.withError(err).warn(`preview reported-close cut for ${nodeId} threw: ${String(err)}`), + ); +} + +/** What the seam recorded for one node's lane frames (no blob slot; mirrors {@link relayFramesSeenFor}). + * @internal test-only */ +export function previewFramesSeenFor(nodeId: string): PreviewLaneRouteRecord | undefined { + return laneFramesByNode.get(nodeId); +} + +/** What the seam recorded for one node's screencast events. + * @internal test-only */ +export function previewStreamEventsSeenFor(nodeId: string): PreviewStreamRouteRecord | undefined { + return streamEventsByNode.get(nodeId); +} + +/** What the seam recorded for one node's reported closes. + * @internal test-only */ +export function previewClosesSeenFor(nodeId: string): PreviewCloseRouteRecord | undefined { + return closeReportsByNode.get(nodeId); +} + +/** Drop every preview seam record. + * @internal test-only */ +export function resetPreviewFramesForTests(): void { + laneFramesByNode.clear(); + streamEventsByNode.clear(); + closeReportsByNode.clear(); } diff --git a/apps/server/api/src/services/preview-broker.ts b/apps/server/api/src/services/preview-broker.ts new file mode 100644 index 000000000..4af2644c1 --- /dev/null +++ b/apps/server/api/src/services/preview-broker.ts @@ -0,0 +1,1044 @@ +import { + type NodeCommandBody, + type NodeEvent, + type NodePreviewPortsResult, + type NodePreviewScreenWire, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_QUEUE_MAX, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewCloseReason, + type PreviewDataFrame, + type PreviewStreamFrame, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, + parseNodePreviewPortsResult, +} from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { sendPreviewFrameOverNodeSocket } from "@/services/nodes/preview-frames.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The plane-side preview broker (pane-preview spec 2026-10-21 §5-§7). + * + * It owns everything the link carries but the link itself: forward sessions + * and their byte lanes (§4), the one screencast bridge per pane with a + * refcount of REST watchers (§6), the pane-keyed set of /ws/live sockets + * entitled to frames, the launch-time display memory (§9), and the 5-second + * ports cache. The shape is ssh-relay.service.ts's: a module singleton built + * lazily over injectable seams, closes that recompute authorization rather + * than diffing events, and an audit pair per SESSION-level close. + * + * What it is NOT: a gate. Routes and the live feed authorize (Task 9's REST + * surface, Task 11's ws attach); the broker trusts the caller for `who` and + * enforces only caps, refcounts, and revocation sweeps. And it imports + * nothing from the `ws/` layer: the fan-out sender is INJECTED + * (`configurePreviewStreamSender`, Task 11's wiring), because the broker must + * stay testable without a socket and the /ws/live crossing belongs in + * live-ws.ts. + * + * Refs are the one secret here: a session or lane ref IS the `/pv` path + * credential (spec §6), minted from the CSPRNG and appearing nowhere else. + * Unlike the relay's routing refs, it never rides an audit row, and every + * `node.preview_*` metadata block carries pane/node/port/kind/reason instead. + */ + +/* ------------------------------------------------------------------ */ +/* refusals */ +/* ------------------------------------------------------------------ */ + +/** Viewer-level refusals the broker itself raises (node refusals are NodeRpcError). */ +export type PreviewRefusalCode = "preview-lane-over-cap" | "preview-watch-over-cap" | "watch-port-conflict"; + +/** + * A refusal raised by the broker's own caps, carrying the wire-stable code + * Task 9 maps to HTTP (409 for `watch-port-conflict`, cap refusals to their + * spec-named strings). Distinct from the bare word `over-cap`, which names + * CLOSES (the sender's death), never refusals (spec §6's naming rule). + */ +export class PreviewRefusal extends Error { + constructor( + readonly code: PreviewRefusalCode, + message: string, + ) { + super(message); + this.name = "PreviewRefusal"; + } +} + +/* ------------------------------------------------------------------ */ +/* the socket seam (exported: Task 11 builds the real one) */ +/* ------------------------------------------------------------------ */ + +/** + * What a stream recipient must offer. NOT the `ElysiaWS` wrapper: measured on + * this repo's Elysia 1.4.29/Bun 1.4.2, the per-callback wrapper carries + * neither a `bufferedAmount` property nor a `getBufferedAmount` method, while + * the raw Bun socket carries the METHOD. Task 11 stores a two-member adapter + * over `ws` + `ws.raw` (the `?? 0` read at remote-subshell-ws.ts:79 is that + * precedent). An absent method reads as zero debt, which keeps frames + * flowing for any other sendable object. + */ +export interface PreviewStreamSocket { + send(data: string): unknown; + getBufferedAmount?: () => number; +} + +/** + * The fan-out sender: it OWNS serialization (JSON.stringify is live-ws's + * house, the broker stays frame-shaped), and it receives the frame built for + * exactly one socket. Installed once at boot by Task 11 + * (`configurePreviewStreamSender`); an unconfigured broker never fans out. + */ +export type PreviewStreamSender = (socket: PreviewStreamSocket, frame: PreviewStreamFrame) => void; + +/* ------------------------------------------------------------------ */ +/* state shapes */ +/* ------------------------------------------------------------------ */ + +/** One lane event: decoded bytes out, or the named death of the lane. */ +export type PreviewLaneEvent = { bytes: Uint8Array } | { closed: PreviewCloseReason }; + +/** The per-subscriber callback shape (decision 2: keyed by LANE, not session). */ +export type PreviewLaneCallback = (event: PreviewLaneEvent) => void; + +/** + * One duplex lane under a forward session. An iframe, an HMR socket, and + * asset requests all hold lanes under ONE session, so subscriber sets live + * here, not on the session: a session-keyed callback would pump one + * connection's bytes into every duplex. + */ +export interface PreviewLane { + /** The lane's 32-hex ref (the node-side lane id and the wire's lane key). */ + ref: string; + /** The session this lane rides under. */ + sessionRef: string; + /** Live subscribers; each connection adds and removes its own. */ + subscribers: Set; +} + +/** + * A live preview session: a forward lane-pool owner (`kind: "forward"`) or + * the pane's one screencast bridge (`kind: "screencast"`). + */ +export interface PreviewSession { + /** The 32-hex session ref (for forward, the PROBE ref: minted from the CSPRNG, this is the credential). */ + ref: string; + /** Which preview surface this session fronts. */ + kind: "forward" | "screencast"; + /** + * Discriminator for the proxy (decision 6): `raw` when the port is the + * pane's registered screen `rfbPort` (bytes are RFB, no HTTP parsing), + * `http` otherwise. Screencast bridges set `http` (they have no leg). + */ + mode: "http" | "raw"; + /** The subshell whose pane this previews. */ + paneId: string; + /** The node the lane/bridge was opened against. */ + nodeId: string; + /** The target port (RFB port for raw, service port otherwise, CDP port for a bridge). */ + port: number; + /** Who opened it (the forward tier holder that revocation re-asks). */ + createdBy: string; + /** Epoch ms of the open (audit-time context; the sweeps use lastActivity). */ + createdAt: number; + /** Lane ref -> lane. Forward sessions grow and shrink these. */ + lanes: Map; + /** + * REST watcher -> SUBSCRIPTION count, not a set: two tabs of one account + * are two watchers, and a Set would let one tab's DELETE close the bridge + * under its sibling's canvas. + */ + viewers: Map; + /** Epoch ms of the last lane traffic (the idle sweep reads this; bridges ride refcount/lifetime, never traffic silence). */ + lastActivity: number; + /** + * Anchor for spec §6's per-session cookie jar. It lives HERE so it dies on + * every session teardown path (this broker has all of them); its CONTENTS + * are read and written only by Task 10's pv-http/pv-ws, which the broker + * never calls into, and no wire type carries them. + */ + cookieJar: Map; +} + +/** The verbs the routes, the ws feed, and the sweeps call (Tasks 9-11). */ +export interface PreviewBroker { + openForward(input: { viewerUserId: string; paneId: string; nodeId: string; port: number }): Promise<{ ref: string }>; + openLaneForConnection(sessionRef: string): Promise<{ laneRef: string }>; + subscribeLane(laneRef: string, cb: PreviewLaneCallback): () => void; + sendLaneBytes(laneRef: string, bytes: Uint8Array): void; + closeLaneForConnection(laneRef: string, reason: PreviewCloseReason): Promise; + startWatch(input: { viewerUserId: string; paneId: string; nodeId: string; port: number }): Promise<{ ok: true }>; + stopWatch(viewerUserId: string, paneId: string): Promise<{ ok: true }>; + attachStream( + paneId: string, + socketKey: string, + holderUserId: string, + socket: PreviewStreamSocket, + ): { ok: true } | { ok: false; code: "preview-stream-over-cap" }; + detachStream(paneId: string, socketKey: string): void; + detachSocketsFor(socketKey: string): void; + sessionFor(ref: string): PreviewSession | null; + /** The pane's live screencast bridge, if any (tests and the feed read it by pane). */ + sessionForPane(paneId: string): PreviewSession | null; + getPorts(nodeId: string, paneId: string): Promise; + onDisplayOpened(nodeId: string, paneId: string, screen: NodePreviewScreenWire): void; + onDisplayClosed(paneId: string, reason: PreviewCloseReason): Promise; + closeForPane(paneId: string, reason: PreviewCloseReason): Promise; + closeAllForNode(nodeId: string, reason: PreviewCloseReason): Promise; + /** Sweeps the user's forwards, watch counts, and held streams; answers how many SESSIONS it closed. */ + closeAllForUser(userId: string, reason: PreviewCloseReason): Promise; + closeUnauthorizedForPane(paneId: string): Promise; + onStreamEvent(nodeId: string, ev: Extract): void; + onNodeLaneFrame(nodeId: string, frame: PreviewDataFrame): void; + onNodeReportedClose(nodeId: string, ref: string, reason: PreviewCloseReason): Promise; + /** One idle pass over FORWARD sessions (the periodic timer runs it; tests drive it with a fake clock). */ + sweepIdle(): Promise; + /** Stops the sweep timer and clears all state (test isolation). */ + shutdown(): void; +} + +/* ------------------------------------------------------------------ */ +/* seams and the singleton */ +/* ------------------------------------------------------------------ */ + +/** The `getPorts` cache window (spec §6: 5 s, keyed by pane). */ +export const PREVIEW_PORTS_CACHE_MS = 5_000; + +/** How often the periodic idle sweep runs; tests call `sweepIdle()` directly. */ +const PREVIEW_SWEEP_INTERVAL_MS = 60_000; + +interface PreviewBrokerDeps { + nowMs: () => number; + /** The signed COMMAND path (open/close/ports), exactly sendCommand's contract. */ + send: (nodeId: string, cmd: NodeCommandBody) => Promise; + /** The byte-frame path (lane `in` legs), NOT the command path (preview-frames.ts). */ + linkSend: (nodeId: string, frame: PreviewDataFrame) => void; + /** The shares resolver the revocation recompute asks per holder (production: the real one). */ + accessFor: (userId: string, paneId: string) => Promise; + audit: (event: AuditEventInput) => Promise; + /** The fan-out sender; undefined means "not wired" and frames are never sent. */ + fanOut: PreviewStreamSender | undefined; + log: (line: string) => void; +} + +function productionDeps(): PreviewBrokerDeps { + return { + nowMs: () => Date.now(), + send: (nodeId, cmd) => sendCommand(nodeId, cmd), + linkSend: (nodeId, frame) => sendPreviewFrameOverNodeSocket(nodeId, frame), + // The repos are built directly (not through getRequestlessContext) so + // this module never sits in the context <-> services import circle. + accessFor: async (userId, paneId) => { + const { access } = await loadSubshellAccess( + { subshells: new SubshellsRepository(db), shares: new SubshellSharesRepository(db) }, + userId, + paneId, + ); + return access; + }, + audit, + fanOut: productionStreamSender, + log: (line) => getLogger().debug(line), + }; +} + +/** + * The production fan-out slot. One-shot by contract: Task 11's live-ws.ts + * calls `configurePreviewStreamSender` once at boot (the singleton builds + * lazily, so there is no construction site to inject at; a setter is the + * seam that makes). `resetForTests` overrides it for the fake only. + */ +let productionStreamSender: PreviewStreamSender | undefined; + +/** Installs the production fan-out sender (first call wins; tests use resetForTests). */ +export function configurePreviewStreamSender(send: PreviewStreamSender): void { + productionStreamSender ??= send; +} + +let instance: PreviewBroker | undefined; + +/** Returns the preview broker, building it lazily over the production seams. */ +export function getPreviewBroker(): PreviewBroker { + instance ??= buildPreviewBroker(productionDeps()); + return instance; +} + +/** + * Test seam: rebuilds the singleton over named seams (fake clock for the + * idle sweep, fake command RPC, fake byte-frame path, fake access answers, + * injected fan-out). With no argument it rebuilds over production seams. The + * AUDIT seam stays real on purpose: the audit rows are half the contract, so + * the fake writes them to the suite's migrated DB (Task 8's tests read them + * back). + */ +export function resetForTests(seams?: { + now?: () => number; + send?: (nodeId: string, cmd: NodeCommandBody) => Promise; + linkSend?: (nodeId: string, frame: PreviewDataFrame) => void; + accessFor?: (userId: string, paneId: string) => Promise; + sendToSockets?: PreviewStreamSender; +}): void { + instance?.shutdown(); + instance = undefined; + const prod = productionDeps(); + const deps: PreviewBrokerDeps = { + ...prod, + ...(seams?.now ? { nowMs: seams.now } : {}), + ...(seams?.send ? { send: seams.send } : {}), + ...(seams?.linkSend ? { linkSend: seams.linkSend } : {}), + ...(seams?.accessFor ? { accessFor: seams.accessFor } : {}), + // An absent `sendToSockets` inherits the production slot (unset in tests + // until something configures it): that is what "an unconfigured broker + // never fans out" means here. + fanOut: seams && "sendToSockets" in seams ? seams.sendToSockets : prod.fanOut, + }; + instance = buildPreviewBroker(deps); +} + +/** Drops the memoized instance entirely (builds nothing; pairs with resetForTests in suites that need a true blank). */ +export function shutdownPreviewBrokerForTests(): void { + instance?.shutdown(); + instance = undefined; +} + +/* ------------------------------------------------------------------ */ +/* the mint */ +/* ------------------------------------------------------------------ */ + +/** + * The session/lane ref: 128 bits from the CSPRNG, hex. This mint is the + * credential (spec §10: "secret unguessability" is a property of HERE, not + * of the format regex; a `Math.random()` hex still matches PREVIEW_REF_RE). + */ +function mintRef(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("hex"); +} + +/* ------------------------------------------------------------------ */ +/* the broker */ +/* ------------------------------------------------------------------ */ + +interface LaneRecord { + lane: PreviewLane; + session: PreviewSession; +} + +interface StreamEntry { + socket: PreviewStreamSocket; + holderUserId: string; +} + +interface LaneWaiter { + sessionRef: string; + resolve: (answer: { laneRef: string }) => void; + reject: (err: Error) => void; +} + +function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { + const sessionsByRef = new Map(); + /** paneId -> the one screencast bridge's ref (spec §6: the watch is pane-keyed). */ + const bridgeRefByPane = new Map(); + /** laneRef -> {lane, session}; the wire's lane key for both directions. */ + const lanesByRef = new Map(); + /** paneId -> socketKey -> entry. Keyed by the PER-CONNECTION id, NEVER socket identity (live-ws.ts's rule). */ + const streamsByPane = new Map>(); + /** paneId -> the display the node reported at launch (the mac-leg fallback for `mode`). */ + const displaysByPane = new Map(); + /** paneId -> cached `getPorts` answer (5 s window, spec §6). */ + const portsCache = new Map(); + /** paneId -> queued lane requests beyond the per-pane cap (decision 2). */ + const laneWaitersByPane = new Map(); + + const sweepTimer = setInterval(() => { + void broker.sweepIdle().catch((err: unknown) => deps.log(`preview idle sweep failed: ${String(err)}`)); + }, PREVIEW_SWEEP_INTERVAL_MS); + sweepTimer.unref?.(); + + /** + * Writes one `node.preview_*` row. + * + * The metadata deliberately OMITS the ref, diverging from the relay's + * audit block (ssh-relay.service.ts:583 includes it because "a routing ref + * is not a credential"): a preview session/lane ref IS the credential, + * the `/pv` path secret, so it belongs in no row an operator can read + * (spec §6). Byte content stays out the same way; only ids, port, kind, + * reason, and counts ride. + */ + async function auditPreview(action: string, nodeId: string | null, meta: Record): Promise { + await deps.audit({ + actorUserId: null, + action, + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify(meta), + }); + } + + function laneCountForPane(paneId: string): number { + let n = 0; + for (const rec of lanesByRef.values()) if (rec.session.paneId === paneId) n += 1; + return n; + } + + async function sendPreviewClose(nodeId: string, ref: string, reason: PreviewCloseReason): Promise { + try { + const data = await deps.send(nodeId, { type: "preview_close", ref, reason }); + parseNodePreviewCloseResult(data); // shape-checked; the ack's `closed` is advisory (the node forgot the ref either way) + } catch (err) { + // The close is a courtesy against a node that may already be gone (the + // link-drop sweep reaps the rest). Loud in the debug log, never thrown + // back into a sweep. + deps.log(`preview_close ${reason} for ref on node ${nodeId} failed: ${String(err)}`); + } + } + + /** Removes the lane entry, notifies its subscribers, optionally tells the node, then frees a queue slot. */ + async function removeLane(rec: LaneRecord, reason: PreviewCloseReason, sendWire: boolean): Promise { + lanesByRef.delete(rec.lane.ref); + rec.session.lanes.delete(rec.lane.ref); + for (const cb of [...rec.lane.subscribers]) { + rec.lane.subscribers.delete(cb); + try { + cb({ closed: reason }); + } catch (err) { + deps.log(`preview lane subscriber threw on close: ${String(err)}`); + } + } + if (sendWire) await sendPreviewClose(rec.session.nodeId, rec.lane.ref, reason); + await dequeueWaiter(rec.session.paneId); + } + + /** A freed lane slot goes to the pane's first still-living waiter. */ + async function dequeueWaiter(paneId: string): Promise { + const queue = laneWaitersByPane.get(paneId); + if (!queue || queue.length === 0) return; + if (laneCountForPane(paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) return; + const waiter = queue.shift(); + if (!waiter) return; + const session = sessionsByRef.get(waiter.sessionRef); + if (!session) { + // The session died while queued (the common case: its last connection + // was the one that just closed). Reject rather than mint against a ref + // nothing owns; later waiters get their turn on the next release. + waiter.reject(new Error(`preview session ${waiter.sessionRef.slice(0, 8)}… closed while its lane was queued`)); + await dequeueWaiter(paneId); + return; + } + try { + waiter.resolve(await mintLane(session)); + } catch (err) { + waiter.reject(err instanceof Error ? err : new Error(String(err))); + } + } + + /** Mints a fresh lane ref, opens it on the node (a refusal propagates and records nothing), and registers it. */ + async function mintLane(session: PreviewSession): Promise<{ laneRef: string }> { + const laneRef = mintRef(); + const data = await deps.send(session.nodeId, { + type: "preview_open", + ref: laneRef, + kind: "forward", + paneId: session.paneId, + port: session.port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + if (parseNodePreviewOpenResult(data) === null) { + throw new Error("preview_open answered a shape the grammar refuses"); + } + const lane: PreviewLane = { ref: laneRef, sessionRef: session.ref, subscribers: new Set() }; + session.lanes.set(laneRef, lane); + lanesByRef.set(laneRef, { lane, session }); + session.lastActivity = deps.nowMs(); + return { laneRef }; + } + + function detachStreamEntries(paneId: string): void { + // Drops the pane's stream set WITHOUT touching the sockets: the /ws/live + // socket carries the whole feed and outlives a lost grant or a gone pane + // (Task 11's matrix: "stops frames to that socket while the socket lives"). + streamsByPane.delete(paneId); + } + + /** Tears down a forward session whole: lanes off the wire, waiters rejected, the pair audited. */ + async function closeForwardSession(session: PreviewSession, reason: PreviewCloseReason): Promise { + if (!sessionsByRef.has(session.ref)) return; + sessionsByRef.delete(session.ref); + // The session ref was the probe, closed on the node at open: the only + // live node-side state under a forward session is its lanes, so each + // lane's close is the wire work (and lane closes audit nothing; the + // SESSION pair is the one row). + const laneCloses = [...session.lanes.values()].map((lane) => + removeLane({ lane, session }, reason, true).catch((err: unknown) => + deps.log(`preview lane close during sweep threw: ${String(err)}`), + ), + ); + await Promise.all(laneCloses); + rejectWaitersFor(session.paneId, (w) => w.sessionRef === session.ref, new Error("preview session closed")); + await auditPreview("node.preview_forward.close", session.nodeId, { + paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "forward", + reason, + }); + } + + /** + * Tears down the pane's bridge: counts gone, sockets detached (never + * closed), optionally told the node, the pair audited once per bridge + * (not per watcher - that is the row's whole design). + */ + async function closeWatchBridge( + session: PreviewSession, + reason: PreviewCloseReason, + sendWire: boolean, + ): Promise { + if (bridgeRefByPane.get(session.paneId) !== session.ref) return; + bridgeRefByPane.delete(session.paneId); + sessionsByRef.delete(session.ref); + session.viewers.clear(); + detachStreamEntries(session.paneId); + if (sendWire) await sendPreviewClose(session.nodeId, session.ref, reason); + await auditPreview("node.preview_watch.close", session.nodeId, { + paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "screencast", + reason, + }); + } + + function rejectWaitersFor(paneId: string, which: (w: LaneWaiter) => boolean, err: Error): void { + const queue = laneWaitersByPane.get(paneId); + if (!queue || queue.length === 0) return; + const kept: LaneWaiter[] = []; + for (const w of queue) { + if (which(w)) w.reject(err); + else kept.push(w); + } + if (kept.length === 0) laneWaitersByPane.delete(paneId); + else laneWaitersByPane.set(paneId, kept); + } + + /** The live screen record for `mode`: getPorts answer FIRST, launch memory second (decision 6). */ + async function resolveMode(nodeId: string, paneId: string, port: number): Promise<"http" | "raw"> { + try { + const answer = await broker.getPorts(nodeId, paneId); + if (answer.screen && answer.screen.rfbPort === port) return "raw"; + // The screen exists but is not this port: `http`, decided (the memory + // cannot disagree upward, and on the mac leg there is no memory). + if (answer.screen) return "http"; + } catch (err) { + deps.log( + `preview getPorts refused during open (${paneId.slice(0, 8)}…): ${String(err)}; falling back to display memory`, + ); + } + const memory = displaysByPane.get(paneId); + if (memory && memory.screen.rfbPort === port) return "raw"; + return "http"; + } + + function dropPortsCache(paneIds: Iterable): void { + for (const p of paneIds) portsCache.delete(p); + } + + const broker: PreviewBroker = { + async openForward({ viewerUserId, paneId, nodeId, port }) { + const ref = mintRef(); + const mode = await resolveMode(nodeId, paneId, port); + // The PROBE open (spec §5): validates the node will serve this pane at + // all, and the refusal path records nothing and opens nothing. Every + // preview_open rides the lifetime cap: the absolute ceiling is + // belt-and-braces while refcount/idle/revoke are the real lifecycle, + // and the value is named here so two executors cannot pick two. + const data = await deps.send(nodeId, { + type: "preview_open", + ref, + kind: "forward", + paneId, + port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + if (parseNodePreviewOpenResult(data) === null) { + throw new Error("preview_open answered a shape the grammar refuses"); + } + await sendPreviewClose(nodeId, ref, "viewer-left"); + const session: PreviewSession = { + ref, + kind: "forward", + mode, + paneId, + nodeId, + port, + createdBy: viewerUserId, + createdAt: deps.nowMs(), + lanes: new Map(), + viewers: new Map(), + lastActivity: deps.nowMs(), + cookieJar: new Map(), + }; + sessionsByRef.set(ref, session); + await auditPreview("node.preview_forward.open", nodeId, { paneId, nodeId, port, kind: "forward" }); + return { ref }; + }, + + async openLaneForConnection(sessionRef) { + const session = sessionsByRef.get(sessionRef); + if (!session) throw new Error(`no live preview session ${sessionRef.slice(0, 8)}… for a new lane`); + if (session.kind !== "forward") throw new Error("preview lanes belong to forward sessions only"); + if (laneCountForPane(session.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) { + const queue = laneWaitersByPane.get(session.paneId) ?? []; + if (queue.length >= PREVIEW_LANE_QUEUE_MAX) { + throw new PreviewRefusal( + "preview-lane-over-cap", + `this pane already carries ${PREVIEW_FORWARD_SESSIONS_PER_PANE} preview lanes with ${PREVIEW_LANE_QUEUE_MAX} waiting; try again when one frees`, + ); + } + return new Promise<{ laneRef: string }>((resolve, reject) => { + queue.push({ sessionRef, resolve, reject }); + laneWaitersByPane.set(session.paneId, queue); + }); + } + return mintLane(session); + }, + + subscribeLane(laneRef, cb) { + const rec = lanesByRef.get(laneRef); + if (!rec) return () => {}; + rec.lane.subscribers.add(cb); + return () => { + rec.lane.subscribers.delete(cb); + }; + }, + + sendLaneBytes(laneRef, bytes) { + const rec = lanesByRef.get(laneRef); + if (!rec) return; // the lane already died (the common race at teardown) + if (bytes.byteLength > PREVIEW_FRAME_MAX_BYTES) { + // The plane-side twin of the node's pump cap (spec §6): BOTH proxy + // consumers funnel through this choke point, so an oversized HTTP + // body chunk and an oversized browser WS message get the same named + // death Task 1 pins from the receiver side. Nothing rides the link; + // the LANE dies (the session survives it), and this is the ONE + // lane-level audit row. + void removeLane(rec, "over-cap", true) + .catch((err: unknown) => deps.log(`preview over-cap lane close threw: ${String(err)}`)) + .then(() => + auditPreview("node.preview_lane.over_cap", rec.session.nodeId, { + paneId: rec.session.paneId, + nodeId: rec.session.nodeId, + port: rec.session.port, + kind: "forward", + reason: "over-cap", + }), + ) + .catch((err: unknown) => deps.log(`preview over-cap audit threw: ${String(err)}`)); + return; + } + try { + deps.linkSend(rec.session.nodeId, { + type: "preview_data", + ref: laneRef, + dir: "in", + blob: Buffer.from(bytes).toString("base64"), + }); + rec.session.lastActivity = deps.nowMs(); + } catch (err) { + // A dead link is not the sender's problem to resolve: the link-drop + // sweep reaps this node's sessions named `node-dropped` (or the close + // hook already did). Log, drop, and let the sweep own it. + deps.log(`preview lane write dropped (link send threw): ${String(err)}`); + } + }, + + async closeLaneForConnection(laneRef, reason) { + const rec = lanesByRef.get(laneRef); + if (!rec) return; // already gone (the one-shot response won the race to the idle sweep) + await removeLane(rec, reason, true); + }, + + async startWatch({ viewerUserId, paneId, nodeId, port }) { + const bridgeRef = bridgeRefByPane.get(paneId); + if (bridgeRef) { + const bridge = sessionsByRef.get(bridgeRef); + if (bridge) { + if (bridge.port !== port) { + // Two bridges on one pane would interleave on a channel with no + // discriminator, and the pane-keyed stopWatch could not name the + // right one. A matching port just joins. + throw new PreviewRefusal( + "watch-port-conflict", + `this pane's screencast bridge is already watching port ${bridge.port}`, + ); + } + const sum = [...bridge.viewers.values()].reduce((a, b) => a + b, 0); + if (sum + 1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + // PER BRIDGE (decision 7): a per-viewer cap would let one + // account's 40 tabs sail past the limit the spec named. + throw new PreviewRefusal( + "preview-watch-over-cap", + `this pane's screencast bridge already has ${PREVIEW_WATCH_SUBSCRIBERS_MAX} subscribers`, + ); + } + bridge.viewers.set(viewerUserId, (bridge.viewers.get(viewerUserId) ?? 0) + 1); + bridge.lastActivity = deps.nowMs(); + return { ok: true }; + } + } + if (1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + throw new PreviewRefusal("preview-watch-over-cap", "screencast subscribers cap below one"); + } + const ref = mintRef(); + const data = await deps.send(nodeId, { + type: "preview_open", + ref, + kind: "screencast", + paneId, + port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + const open = parseNodePreviewOpenResult(data); + if (open === null) throw new Error("preview_open answered a shape the grammar refuses"); + // The ack's `subscribers` count is IGNORED: the bridge counts itself + // (this broker owns the refcount; the node's number is advisory at best). + const session: PreviewSession = { + ref, + kind: "screencast", + mode: "http", // bridges have no leg; the member exists for the proxy's discriminator + paneId, + nodeId, + port, + createdBy: viewerUserId, + createdAt: deps.nowMs(), + lanes: new Map(), + viewers: new Map([[viewerUserId, 1]]), + lastActivity: deps.nowMs(), + cookieJar: new Map(), + }; + sessionsByRef.set(ref, session); + bridgeRefByPane.set(paneId, ref); + await auditPreview("node.preview_watch.open", nodeId, { paneId, nodeId, port, kind: "screencast" }); + return { ok: true }; + }, + + async stopWatch(viewerUserId, paneId) { + const bridgeRef = bridgeRefByPane.get(paneId); + const bridge = bridgeRef ? sessionsByRef.get(bridgeRef) : undefined; + if (!bridge) return { ok: true }; // REST DELETE is idempotent, and stays so after a node-reported teardown + const count = bridge.viewers.get(viewerUserId) ?? 0; + if (count <= 1) bridge.viewers.delete(viewerUserId); + else bridge.viewers.set(viewerUserId, count - 1); + // DECREMENT, never delete-key: one DELETE with two tabs open leaves + // count 1 and the bridge alive. + if (bridge.viewers.size === 0) await closeWatchBridge(bridge, "viewer-left", true); + return { ok: true }; + }, + + attachStream(paneId, socketKey, holderUserId, socket) { + // The key is a PER-CONNECTION id the caller resolves from ws.data; + // Elysia builds a fresh wrapper per callback, so socket identity is not + // a style preference but dead code (live-ws.ts:36-42). The socket stays + // the VALUE (its `send` reaches the shared raw). + const map = streamsByPane.get(paneId) ?? new Map(); + if (!map.has(socketKey) && map.size >= PREVIEW_WATCH_SUBSCRIBERS_MAX) { + // Refused AT ATTACH, answering a value, never throwing: the /ws/live + // switch swallows it, the socket stays open, the ninth tab's + // experience is the dark canvas, and this row is the operator's. + void auditPreview("node.preview_watch.viewer_drop", null, { paneId, reason: "over-cap" }).catch( + (err: unknown) => deps.log(`preview attach-cap audit threw: ${String(err)}`), + ); + return { ok: false, code: "preview-stream-over-cap" }; + } + map.set(socketKey, { socket, holderUserId }); + streamsByPane.set(paneId, map); + return { ok: true }; + }, + + detachStream(paneId, socketKey) { + const map = streamsByPane.get(paneId); + if (!map) return; + map.delete(socketKey); + if (map.size === 0) streamsByPane.delete(paneId); + }, + + detachSocketsFor(socketKey) { + // The socket's `close` handler knows only the id (the wrapper it hands + // Task 11 is not the wrapper it came in as), so the walk is by key. + for (const [paneId, map] of [...streamsByPane]) { + map.delete(socketKey); + if (map.size === 0) streamsByPane.delete(paneId); + } + }, + + sessionFor(ref) { + return sessionsByRef.get(ref) ?? null; + }, + + sessionForPane(paneId) { + const ref = bridgeRefByPane.get(paneId); + return ref ? (sessionsByRef.get(ref) ?? null) : null; + }, + + async getPorts(nodeId, paneId) { + const hit = portsCache.get(paneId); + if (hit && deps.nowMs() - hit.at < PREVIEW_PORTS_CACHE_MS) return hit.answer; + // A node refusal (preview-ports-unsupported, offline, ...) REJECTS with + // the named error rather than fabricating an empty answer; Task 9's + // info route is what turns the rejection into `{ nodeEnabled, reason }`. + const raw = await deps.send(nodeId, { type: "preview_ports_get", paneId }); + const answer = parseNodePreviewPortsResult(raw); + if (answer === null) throw new Error("preview_ports_get answered a shape the grammar refuses"); + portsCache.set(paneId, { at: deps.nowMs(), answer }); + return answer; + }, + + onDisplayOpened(nodeId, paneId, screen) { + displaysByPane.set(paneId, { nodeId, screen }); + void auditPreview("node.preview_display.open", nodeId, { paneId, nodeId, ...screen }).catch((err: unknown) => + deps.log(`preview display-open audit threw: ${String(err)}`), + ); + }, + + async onDisplayClosed(paneId, reason) { + const entry = displaysByPane.get(paneId); + if (!entry) return; // double-close is idempotent; a pane that never displayed audits nothing + displaysByPane.delete(paneId); + await auditPreview("node.preview_display.close", entry.nodeId, { + paneId, + nodeId: entry.nodeId, + ...entry.screen, + reason, + }); + }, + + async closeForPane(paneId, reason) { + for (const session of [...sessionsByRef.values()]) { + if (session.paneId !== paneId) continue; + if (session.kind === "forward") await closeForwardSession(session, reason); + else await closeWatchBridge(session, reason, true); + } + detachStreamEntries(paneId); + await broker.onDisplayClosed(paneId, reason); + dropPortsCache([paneId]); + rejectWaitersFor(paneId, () => true, new Error(`preview pane ${paneId.slice(0, 8)}… closed`)); + }, + + async closeAllForNode(nodeId, reason) { + const panesTouched = new Set(); + for (const session of [...sessionsByRef.values()]) { + if (session.nodeId !== nodeId) continue; + panesTouched.add(session.paneId); + if (session.kind === "forward") await closeForwardSession(session, reason); + else await closeWatchBridge(session, reason, true); + } + for (const [paneId, display] of [...displaysByPane]) { + if (display.nodeId !== nodeId) continue; + await broker.onDisplayClosed(paneId, reason); + } + // Forward panes with attached sockets but no bridge still lose their + // sockets: nothing on this node can ever feed them again. + for (const paneId of panesTouched) detachStreamEntries(paneId); + dropPortsCache(panesTouched); + }, + + async closeAllForUser(userId, reason) { + let closed = 0; + for (const session of [...sessionsByRef.values()]) { + if (session.kind === "forward") { + if (session.createdBy === userId) { + await closeForwardSession(session, reason); + closed += 1; + } + continue; + } + if (!session.viewers.has(userId)) continue; + session.viewers.delete(userId); + if (session.viewers.size === 0) { + await closeWatchBridge(session, reason, true); + closed += 1; + } + } + for (const [paneId, map] of [...streamsByPane]) { + for (const [socketKey, entry] of [...map]) { + if (entry.holderUserId === userId) map.delete(socketKey); + } + if (map.size === 0) streamsByPane.delete(paneId); + } + return closed; + }, + + async closeUnauthorizedForPane(paneId) { + // THE shares cut. Riding the relay's recompute posture (spec §6 names + // it): re-ask authorization per holder from THIS broker's own state, + // never diff the event that triggered the recompute. One verb covers + // removal, the edit->view downgrade, and the Everyone row's level + // change, because `loadSubshellAccess` already folds the NULL-grantee + // row into what each holder resolves to - a per-grantee diff keyed on + // named users would miss the row that reaches everybody. + const asked = new Map(); + const access = async (userId: string): Promise => { + const cached = asked.get(userId); + if (cached) return cached; + const a = await deps.accessFor(userId, paneId); + asked.set(userId, a); + return a; + }; + for (const session of [...sessionsByRef.values()]) { + if (session.paneId !== paneId) continue; + if (session.kind === "forward") { + // Below EDIT closes the whole session (lanes included) named + // `revoked`: an edit->view grantee loses ONLY the edit tier. + if (!accessAtLeast(await access(session.createdBy), "edit")) { + await closeForwardSession(session, "revoked"); + } + continue; + } + // Watchers and stream holders below VIEW drop; a bridge emptied by + // revocation closes `revoked`, not `viewer-left` (the audit names + // what actually ended it). + for (const viewer of [...session.viewers.keys()]) { + if (!accessAtLeast(await access(viewer), "view")) session.viewers.delete(viewer); + } + if (session.viewers.size === 0) { + await closeWatchBridge(session, "revoked", true); + continue; + } + const map = streamsByPane.get(paneId); + if (!map) continue; + for (const [socketKey, entry] of [...map]) { + if (!accessAtLeast(await access(entry.holderUserId), "view")) map.delete(socketKey); + } + if (map.size === 0) streamsByPane.delete(paneId); + } + }, + + onStreamEvent(nodeId, ev) { + const session = sessionsByRef.get(ev.ref); + if (session?.kind !== "screencast" || session.nodeId !== nodeId) return; // foreign node's bridge, or a lane ref, or gone + const map = streamsByPane.get(session.paneId); + if (!map || map.size === 0) return; + const send = deps.fanOut; + if (!send) return; // never wired: an unconfigured broker never fans out + // The frame is pane-keyed and carries NO ref: the ref never crosses to + // the browser (spec §6; §8's frame type from the protocol package). + const frame: PreviewStreamFrame = { + type: "preview-stream", + id: session.paneId, + seq: ev.seq, + w: ev.w, + h: ev.h, + blob: ev.blob, + }; + for (const [socketKey, entry] of [...map]) { + // The METHOD (never a property) over the raw socket, read through the + // adapter's `?? 0`: spec §9 drops slow VIEWERS, never the bridge. + const debt = entry.socket.getBufferedAmount?.() ?? 0; + if (debt > PREVIEW_STREAM_DEBT_MAX) { + map.delete(socketKey); + void auditPreview("node.preview_watch.viewer_drop", null, { + paneId: session.paneId, + reason: "slow-link", + }).catch((err: unknown) => deps.log(`preview slow-link drop audit threw: ${String(err)}`)); + continue; + } + try { + send(entry.socket, frame); + } catch (err) { + deps.log(`preview fan-out threw for one socket: ${String(err)}`); + } + } + if (map.size === 0) streamsByPane.delete(session.paneId); + }, + + onNodeLaneFrame(nodeId, frame) { + const rec = lanesByRef.get(frame.ref); + if (!rec || rec.session.nodeId !== nodeId) return; // the socket's node is the fence + if (frame.dir !== "out") return; // the plane owns the `in` leg; a node pushing one is nonsense + rec.session.lastActivity = deps.nowMs(); + const bytes = new Uint8Array(Buffer.from(frame.blob, "base64")); + for (const cb of rec.lane.subscribers) { + try { + cb({ bytes }); + } catch (err) { + deps.log(`preview lane subscriber threw on bytes: ${String(err)}`); + } + } + }, + + async onNodeReportedClose(nodeId, ref, reason) { + // The node volunteering a death it observed (plan decision 3): a lane, + // a bridge, or a stray. The plane's own closes need no report, so + // whatever arrives here names state the node tore down machine-side. + const rec = lanesByRef.get(ref); + if (rec) { + if (rec.session.nodeId !== nodeId) return; + // A NODE-reported lane close stays WIRE-ONLY: spec §9's actions are + // the plane's own acts, and the reason word already reaches the + // viewer on the wire. No audit row, no second preview_close. + await removeLane(rec, reason, false); + return; + } + const session = sessionsByRef.get(ref); + if (!session) { + deps.log(`preview_closed for unknown ref ${ref.slice(0, 8)}… on node ${nodeId} (${reason})`); + return; + } + if (session.nodeId !== nodeId) return; + if (session.kind !== "screencast") { + // A forward SESSION ref was closed by us at probe time; a report + // naming one is stale. Its lanes, if any are still registered, were + // not named by the node (lane refs are distinct). + deps.log(`preview_closed naming a forward session ref on node ${nodeId} (${reason}); ignoring`); + return; + } + // The bridge's twin: tear the plane's half down WITH it, so the next + // startWatch hits the zero->one edge and re-sends preview_open ("a + // matching port just joins" may only ever join a LIVE bridge). + await closeWatchBridge(session, reason, false); + }, + + async sweepIdle() { + let closed = 0; + const now = deps.nowMs(); + for (const session of [...sessionsByRef.values()]) { + // FORWARD sessions only. The constant's own JSDoc names a forward + // lane; a screencast bridge's life is refcount, revocation, the + // node-reported close, and the node's lifetime event - never + // plane-side traffic silence (a watched static page produces zero + // frames by definition). + if (session.kind !== "forward") continue; + if (now - session.lastActivity < PREVIEW_IDLE_MS) continue; + await closeForwardSession(session, "idle"); + closed += 1; + } + return closed; + }, + + shutdown() { + clearInterval(sweepTimer); + sessionsByRef.clear(); + bridgeRefByPane.clear(); + lanesByRef.clear(); + streamsByPane.clear(); + displaysByPane.clear(); + portsCache.clear(); + for (const queue of laneWaitersByPane.values()) { + for (const w of queue) w.reject(new Error("preview broker reset")); + } + laneWaitersByPane.clear(); + }, + }; + + return broker; +} diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index edef12df1..983f65c55 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -40,6 +40,7 @@ import { } from "@/services/nodes/pane-exec.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; @@ -1243,6 +1244,14 @@ export class SubshellsService extends BaseService { // thing that can tell a revoked grantee (spec §4.2). Deriving which topics // that means stays in the publisher — the service carries domain facts. publishLive({ kind: "subshell.shares-changed", id, before: { ownerUserId: row.userId, shares: before } }); + // A preview session is broker state, not a socket: the shares-changed + // publish tells /ws/live viewers the row moved, but it cannot touch a + // forward lane's node connection or a watch count. Recomputed against + // the POST-write grant set, every lane below `edit` and every viewer + // below `view` on THIS pane dies now (pane-preview spec §7; the relay + // twin is `closeUnauthorizedForNode` in the set-node-shares route). + // Awaited so the answer does not race the cut it describes. + await getPreviewBroker().closeUnauthorizedForPane(id); return { shares: await this.#shareViews(id) }; } diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 665ecbf81..179fbe330 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -376,6 +376,7 @@ export { PREVIEW_WATCH_FPS_MIN, PREVIEW_WATCH_FRAMES_BUFFERED, PREVIEW_WATCH_JPEG_QUALITY, + PREVIEW_WATCH_SUBSCRIBERS_MAX, SSH_CONFIG_FILE_MAX_BYTES, SSH_EXEC_COMMAND_MAX_CHARS, SSH_EXEC_MAX_PRESET_FLAGS, From 474e32270615cbc2e2ee754ca1b27d9940c2b353 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 01:17:14 -0700 Subject: [PATCH 040/139] fix(server): reserve preview caps and bridges synchronously across the open await --- .../src/api/users/set-user-disabled.route.ts | 4 +- .../api/src/api/users/set-user-role.route.ts | 11 +- .../services/__tests__/preview-broker.test.ts | 155 +++++++++- .../server/api/src/services/preview-broker.ts | 264 ++++++++++++------ 4 files changed, 335 insertions(+), 99 deletions(-) diff --git a/apps/server/api/src/api/users/set-user-disabled.route.ts b/apps/server/api/src/api/users/set-user-disabled.route.ts index 5c9ef1d5e..2069c4cad 100644 --- a/apps/server/api/src/api/users/set-user-disabled.route.ts +++ b/apps/server/api/src/api/users/set-user-disabled.route.ts @@ -97,7 +97,9 @@ export const setUserDisabledRoute = new Elysia().use(requireAdmin).patch( .catch((err: unknown) => { // Degraded-and-honest, the node-sweep rule below: the disable // has committed, and a swept-so-far broker is strictly better - // than an unswept one. The count names what was reached. + // than an unswept one. The row reports ZERO on this path whatever + // was swept before the throw — the count under-claims rather than + // promising a number the catch cannot know. logger.withError(err).warn(`users: disable of ${target.id} committed, but its preview sweep failed`); return 0; }) diff --git a/apps/server/api/src/api/users/set-user-role.route.ts b/apps/server/api/src/api/users/set-user-role.route.ts index e0644fd16..d3501e044 100644 --- a/apps/server/api/src/api/users/set-user-role.route.ts +++ b/apps/server/api/src/api/users/set-user-role.route.ts @@ -7,6 +7,7 @@ import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { DEFAULT_USER_ROLE, USER_ROLES } from "@/db/types/user-role.js"; import { audit } from "@/services/audit.js"; import { getPreviewBroker } from "@/services/preview-broker.js"; +import { logger } from "@/utils/logger.js"; import { dropLiveSocketsFor } from "@/ws/live-registry.js"; const RoleSchema = t.Union( @@ -71,7 +72,15 @@ export const setUserRoleRoute = new Elysia().use(requireAdmin).patch( // target port until the 15-minute idle sweep. Awaited like the // `set-node-shares` relay twin: the answer must not race the cut it // describes. - await getPreviewBroker().closeAllForUser(target.id, "revoked"); + await getPreviewBroker() + .closeAllForUser(target.id, "revoked") + .catch((err: unknown) => { + // Degraded-and-honest, the disable route's rule for the same sweep: + // the role write is committed and the audit row below must still land + // — a throw here would 500 past a change that already happened. What + // was swept so far stays swept; nothing reports it from here. + logger.withError(err).warn(`users: role change of ${target.id} committed, but its preview sweep failed`); + }); await audit({ actorUserId: user.id, action: "user.role_change", diff --git a/apps/server/api/src/services/__tests__/preview-broker.test.ts b/apps/server/api/src/services/__tests__/preview-broker.test.ts index 3a8d08d9e..da35978fa 100644 --- a/apps/server/api/src/services/__tests__/preview-broker.test.ts +++ b/apps/server/api/src/services/__tests__/preview-broker.test.ts @@ -52,10 +52,14 @@ interface Harness { linked: { nodeId: string; frame: PreviewDataFrame }[]; delivered: { socket: unknown; frame: Parameters[1] }[]; accessCalls: { userId: string; paneId: string }[]; + /** Held acks: one entry per command whose ack is parked (what "in flight" counts). */ + held: { cmd: NodeCommandBody; release: () => void; refuse: (err: Error) => void }[]; } interface FakeSeams { ack?: (cmd: NodeCommandBody) => unknown; + /** Name the commands whose ack parks until the test releases/refuses it. */ + hold?: (cmd: NodeCommandBody) => boolean; accessFor?: (userId: string, paneId: string) => Promise; /** Pass `null` to build a broker with NO fan-out sender at all. */ sendToSockets?: PreviewStreamSender | null; @@ -70,7 +74,7 @@ interface FakeSeams { */ function makeHarness(over: FakeSeams = {}): Harness { const clock = { now: 1_780_000_000_000 }; - const h: Harness = { clock, sent: [], linked: [], delivered: [], accessCalls: [] }; + const h: Harness = { clock, sent: [], linked: [], delivered: [], accessCalls: [], held: [] }; const defaultSendToSockets: PreviewStreamSender = (socket, frame) => { h.delivered.push({ socket, frame }); }; @@ -78,17 +82,25 @@ function makeHarness(over: FakeSeams = {}): Harness { now: () => clock.now, send: async (nodeId: string, cmd: NodeCommandBody): Promise => { h.sent.push({ nodeId, cmd }); - if (over.ack) return over.ack(cmd); - switch (cmd.type) { - case "preview_open": - return cmd.kind === "forward" ? { ref: cmd.ref, pending: true } : { ref: cmd.ref, subscribers: 1 }; - case "preview_close": - return { ref: cmd.ref, closed: true }; - case "preview_ports_get": - return { ports: [], cdpPort: null, screen: null }; - default: - throw new Error(`preview broker test: unexpected command ${cmd.type}`); + const ackFor = (): unknown => { + if (over.ack) return over.ack(cmd); + switch (cmd.type) { + case "preview_open": + return cmd.kind === "forward" ? { ref: cmd.ref, pending: true } : { ref: cmd.ref, subscribers: 1 }; + case "preview_close": + return { ref: cmd.ref, closed: true }; + case "preview_ports_get": + return { ports: [], cdpPort: null, screen: null }; + default: + throw new Error(`preview broker test: unexpected command ${cmd.type}`); + } + }; + if (over.hold?.(cmd)) { + return new Promise((resolve, reject) => { + h.held.push({ cmd, release: () => resolve(ackFor()), refuse: (err) => reject(err) }); + }); } + return ackFor(); }, linkSend: (nodeId: string, frame: PreviewDataFrame): void => { h.linked.push({ nodeId, frame }); @@ -106,6 +118,15 @@ function makeHarness(over: FakeSeams = {}): Harness { return h; } +/** Parked-ack helpers for the concurrency discriminators (no non-null assertions). */ +function releaseHeld(h: Harness): void { + h.held.shift()?.release(); +} + +function refuseHeld(h: Harness, err: Error): void { + h.held.shift()?.refuse(err); +} + function cmds(h: Harness, type: T): Extract[] { return h.sent.filter((c) => c.cmd.type === type).map((c) => c.cmd) as Extract[]; } @@ -747,7 +768,7 @@ describe("stream sockets: the attach cap, the keying, the byte debt", () => { await settle(); const drops = await metadataOf("node.preview_watch.viewer_drop"); expect(drops).toHaveLength(1); - expect(drops[0]).toMatchObject({ paneId: PANE, reason: "slow-link" }); + expect(drops[0]).toMatchObject({ paneId: PANE, nodeId: NODE, port: 9222, kind: "screencast", reason: "slow-link" }); h.delivered.length = 0; broker().onStreamEvent(NODE, streamEvent(bridge, 2)); expect(h.delivered).toHaveLength(1); @@ -1168,3 +1189,113 @@ describe("the shares write performs the cut (real SubshellsService.setShares)", await ctx.repos.subshells.delete(paneId).catch(() => {}); }); }); + +describe("concurrency: synchronous reservations and the one-bridge-per-pane edge (review 2026-10-23)", () => { + it("pane at three lanes: two concurrent opens send the node ONE lane open; the second queues and a close promotes it", async () => { + let probeRef = ""; + let holdLanes = false; + const h = makeHarness({ + hold: (cmd) => holdLanes && cmd.type === "preview_open" && cmd.kind === "forward" && cmd.ref !== probeRef, + }); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + probeRef = ref; + const lanes: string[] = []; + for (let i = 0; i < 3; i += 1) { + const lane = await broker().openLaneForConnection(ref); + lanes.push(lane.laneRef); + } + holdLanes = true; + + const pA = broker().openLaneForConnection(ref); + const pB = broker().openLaneForConnection(ref); + await settle(); + // Exactly ONE open in flight (A's, held). B reserved its slot + // synchronously and queued without ever reaching the node: two opens in + // `held` would mean both calls passed the check before either registered. + expect(h.held).toHaveLength(1); + + releaseHeld(h); // A's ack lands; A owns the fourth slot + await expect(pA).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + expect(h.held).toHaveLength(0); // B is queued, not flying + + // Closing one lane frees the slot AND promotes B: its open is now the + // one in flight. The close itself awaits the promoted open (dequeue runs + // inside the release), so the release comes before awaiting the close. + const closing = broker().closeLaneForConnection(lanes[0], "viewer-left"); + await settle(); + expect(h.held).toHaveLength(1); + releaseHeld(h); + await closing; + await expect(pB).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + // Exactly four live lanes and an empty queue: six lane-shaped opens ever + // reached the node (probe + three + A + B), and a seventh caller cannot + // sneak one past: it queues without touching the wire. + const extra = broker().openLaneForConnection(ref); + extra.catch(() => {}); // the reset shutdown will reject it; that is this test's own teardown + await settle(); + expect(h.held).toHaveLength(0); + expect(cmds(h, "preview_open").filter((c) => c.kind === "forward")).toHaveLength(6); + }); + + it("two concurrent startWatch on a cold pane: ONE preview_open reaches the node, both callers resolve ok, ONE close follows two stops", async () => { + const h = makeHarness({ hold: (cmd) => cmd.type === "preview_open" && cmd.kind === "screencast" }); + const p1 = broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const p2 = broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + await settle(); + // One cold open per pane, ever: the second caller awaits the winner. Two + // held opens here is the orphan bridge (loser registered nowhere + // reachable, uncounted by sweepIdle) the review named. + expect(h.held).toHaveLength(1); + expect(cmds(h, "preview_open").filter((c) => c.kind === "screencast")).toHaveLength(1); + releaseHeld(h); + await expect(p1).resolves.toEqual({ ok: true }); + await expect(p2).resolves.toEqual({ ok: true }); + const bridge = String(bridgeFor(PANE)?.ref); + expect(bridge).toMatch(/^[0-9a-f]{32}$/); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(2); // the joiner incremented the SAME bridge + expect(await metadataOf("node.preview_watch.open")).toHaveLength(1); // not one row per caller + + await broker().stopWatch("u-a", PANE); + await broker().stopWatch("u-a", PANE); + const closes = cmds(h, "preview_close").filter((c) => c.ref === bridge); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ reason: "viewer-left" }); + }); + + it("a refused lane open releases its reservation, and the freed slot still promotes the queued waiter", async () => { + let probeRef = ""; + let holdLanes = false; + const h = makeHarness({ + hold: (cmd) => holdLanes && cmd.type === "preview_open" && cmd.kind === "forward" && cmd.ref !== probeRef, + }); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + probeRef = ref; + for (let i = 0; i < 3; i += 1) await broker().openLaneForConnection(ref); + holdLanes = true; + + const pA = broker().openLaneForConnection(ref); + const pB = broker().openLaneForConnection(ref); + await settle(); + expect(h.held).toHaveLength(1); // B is queued behind A's reservation + + // A's node-side refusal must release the reservation through the lane + // close path: the slot frees and B is promoted at once. A's rejection + // waits on B's open behind it (the release chain runs the promotion), so + // both held acks are answered before awaiting either caller. + refuseHeld(h, new NodeRpcError("failed", "refused", NODE, "preview-sessions-exhausted")); + await settle(); + expect(h.held).toHaveLength(1); // B's promoted open, in flight + releaseHeld(h); + await expect(pA).rejects.toBeInstanceOf(NodeRpcError); + await expect(pB).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + // No phantom lane holds a slot: A's refused reservation left nothing + // behind (probe + three + A's refusal + B's promotion = six forward opens + // ever sent, four of them live), so the next caller simply queues with + // no wire activity. + expect(cmds(h, "preview_open").filter((c) => c.kind === "forward")).toHaveLength(6); + const extra = broker().openLaneForConnection(ref); + extra.catch(() => {}); // the reset shutdown will reject it; that is this test's own teardown + await settle(); + expect(h.held).toHaveLength(0); + }); +}); diff --git a/apps/server/api/src/services/preview-broker.ts b/apps/server/api/src/services/preview-broker.ts index 4af2644c1..e6abae515 100644 --- a/apps/server/api/src/services/preview-broker.ts +++ b/apps/server/api/src/services/preview-broker.ts @@ -230,19 +230,21 @@ interface PreviewBrokerDeps { log: (line: string) => void; } +/** The access resolver's repos, built on first ask (never at import) and reused. */ +let accessRepos: { subshells: SubshellsRepository; shares: SubshellSharesRepository } | undefined; + function productionDeps(): PreviewBrokerDeps { return { nowMs: () => Date.now(), send: (nodeId, cmd) => sendCommand(nodeId, cmd), linkSend: (nodeId, frame) => sendPreviewFrameOverNodeSocket(nodeId, frame), // The repos are built directly (not through getRequestlessContext) so - // this module never sits in the context <-> services import circle. + // this module never sits in the context <-> services import circle, and + // once — they are stateless over `db`, and building them per ask was + // pure allocation. accessFor: async (userId, paneId) => { - const { access } = await loadSubshellAccess( - { subshells: new SubshellsRepository(db), shares: new SubshellSharesRepository(db) }, - userId, - paneId, - ); + accessRepos ??= { subshells: new SubshellsRepository(db), shares: new SubshellSharesRepository(db) }; + const { access } = await loadSubshellAccess(accessRepos, userId, paneId); return access; }, audit, @@ -332,6 +334,15 @@ function mintRef(): string { interface LaneRecord { lane: PreviewLane; session: PreviewSession; + /** + * True from the synchronous reservation until the node's ack: the cap and + * the queue see the lane from before its `preview_open` is even sent + * (review 2026-10-23). Nothing can address a pending lane externally — + * its ref reaches callers only after the ack, and the node cannot name a + * ref it has not acked — so `pending` guards no read path; it exists to + * make the registration honest. + */ + pending: boolean; } interface StreamEntry { @@ -359,6 +370,14 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { const portsCache = new Map(); /** paneId -> queued lane requests beyond the per-pane cap (decision 2). */ const laneWaitersByPane = new Map(); + /** + * paneId -> the flying zero->one bridge open (review 2026-10-23). One cold + * open per pane may ever be in flight: concurrent startWatch callers await + * the winner instead of each minting a bridge the last-set-wins map would + * strand (an orphan no verb can reach and sweepIdle never walks). Cleared + * when the chain settles either way, so a failed open is not sticky. + */ + const watchOpeningByPane = new Map>(); const sweepTimer = setInterval(() => { void broker.sweepIdle().catch((err: unknown) => deps.log(`preview idle sweep failed: ${String(err)}`)); @@ -442,23 +461,49 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { } } - /** Mints a fresh lane ref, opens it on the node (a refusal propagates and records nothing), and registers it. */ + /** + * Mints a fresh lane ref, OPENS it on the node, and flips the registration + * live. The reservation is the load-bearing part (review 2026-10-23): the + * placeholder lands in the very structures `laneCountForPane` counts BEFORE + * the first await, so two concurrent callers can never both pass the cap + * check and both spend a node slot; the second one queues instead. A + * refusal releases through the same close path a real lane uses, so the + * freed slot still promotes the pane's first waiter. The close reason on + * the release is invisible (a placeholder has no subscribers yet); it says + * `viewer-left` because the caller's promise rejecting IS the leaving. + */ async function mintLane(session: PreviewSession): Promise<{ laneRef: string }> { const laneRef = mintRef(); - const data = await deps.send(session.nodeId, { - type: "preview_open", - ref: laneRef, - kind: "forward", - paneId: session.paneId, - port: session.port, - lifetimeMs: PREVIEW_LIFETIME_MAX_MS, - }); - if (parseNodePreviewOpenResult(data) === null) { - throw new Error("preview_open answered a shape the grammar refuses"); - } const lane: PreviewLane = { ref: laneRef, sessionRef: session.ref, subscribers: new Set() }; session.lanes.set(laneRef, lane); - lanesByRef.set(laneRef, { lane, session }); + lanesByRef.set(laneRef, { lane, session, pending: true }); + try { + const data = await deps.send(session.nodeId, { + type: "preview_open", + ref: laneRef, + kind: "forward", + paneId: session.paneId, + port: session.port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + if (parseNodePreviewOpenResult(data) === null) { + throw new Error("preview_open answered a shape the grammar refuses"); + } + } catch (err) { + // No wire close on the release: the node never acked this ref, so the + // only thing the plane owns is the reservation itself. + const rec = lanesByRef.get(laneRef); + if (rec) await removeLane(rec, "viewer-left", false); + throw err; + } + const rec = lanesByRef.get(laneRef); + if (!rec) { + // The session died under the in-flight open (closeForwardSession walked + // the placeholder away). Reject honestly; a node-side lane that the ack + // still races expires with its own lifetime. + throw new Error(`preview session ${session.ref.slice(0, 8)}… closed while its lane was opening`); + } + rec.pending = false; session.lastActivity = deps.nowMs(); return { laneRef }; } @@ -479,7 +524,7 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { // lane's close is the wire work (and lane closes audit nothing; the // SESSION pair is the one row). const laneCloses = [...session.lanes.values()].map((lane) => - removeLane({ lane, session }, reason, true).catch((err: unknown) => + removeLane({ lane, session, pending: false }, reason, true).catch((err: unknown) => deps.log(`preview lane close during sweep threw: ${String(err)}`), ), ); @@ -519,6 +564,83 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { }); } + /** The live bridge's join path: true when the caller joined, refusal thrown by name. */ + function joinBridge({ viewerUserId, paneId, port }: { viewerUserId: string; paneId: string; port: number }): boolean { + const bridgeRef = bridgeRefByPane.get(paneId); + const bridge = bridgeRef ? sessionsByRef.get(bridgeRef) : undefined; + if (!bridge) return false; + if (bridge.port !== port) { + // Two bridges on one pane would interleave on a channel with no + // discriminator, and the pane-keyed stopWatch could not name the + // right one. A matching port just joins. + throw new PreviewRefusal( + "watch-port-conflict", + `this pane's screencast bridge is already watching port ${bridge.port}`, + ); + } + const sum = [...bridge.viewers.values()].reduce((a, b) => a + b, 0); + if (sum + 1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + // PER BRIDGE (decision 7): a per-viewer cap would let one + // account's 40 tabs sail past the limit the spec named. + throw new PreviewRefusal( + "preview-watch-over-cap", + `this pane's screencast bridge already has ${PREVIEW_WATCH_SUBSCRIBERS_MAX} subscribers`, + ); + } + bridge.viewers.set(viewerUserId, (bridge.viewers.get(viewerUserId) ?? 0) + 1); + bridge.lastActivity = deps.nowMs(); + return true; + } + + /** The serialized zero->one edge: ask the node, register the bridge, audit the pair. */ + async function openBridge({ + viewerUserId, + paneId, + nodeId, + port, + }: { + viewerUserId: string; + paneId: string; + nodeId: string; + port: number; + }): Promise { + if (1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + throw new PreviewRefusal("preview-watch-over-cap", "screencast subscribers cap below one"); + } + const ref = mintRef(); + const data = await deps.send(nodeId, { + type: "preview_open", + ref, + kind: "screencast", + paneId, + port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + if (parseNodePreviewOpenResult(data) === null) { + throw new Error("preview_open answered a shape the grammar refuses"); + } + // The ack's `subscribers` count is IGNORED: the bridge counts itself + // (this broker owns the refcount; the node's number is advisory at best). + const session: PreviewSession = { + ref, + kind: "screencast", + mode: "http", // bridges have no leg; the member exists for the proxy's discriminator + paneId, + nodeId, + port, + createdBy: viewerUserId, + createdAt: deps.nowMs(), + lanes: new Map(), + viewers: new Map([[viewerUserId, 1]]), + lastActivity: deps.nowMs(), + cookieJar: new Map(), + }; + sessionsByRef.set(ref, session); + bridgeRefByPane.set(paneId, ref); + await auditPreview("node.preview_watch.open", nodeId, { paneId, nodeId, port, kind: "screencast" }); + return session; + } + function rejectWaitersFor(paneId: string, which: (w: LaneWaiter) => boolean, err: Error): void { const queue = laneWaitersByPane.get(paneId); if (!queue || queue.length === 0) return; @@ -669,66 +791,28 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { }, async startWatch({ viewerUserId, paneId, nodeId, port }) { - const bridgeRef = bridgeRefByPane.get(paneId); - if (bridgeRef) { - const bridge = sessionsByRef.get(bridgeRef); - if (bridge) { - if (bridge.port !== port) { - // Two bridges on one pane would interleave on a channel with no - // discriminator, and the pane-keyed stopWatch could not name the - // right one. A matching port just joins. - throw new PreviewRefusal( - "watch-port-conflict", - `this pane's screencast bridge is already watching port ${bridge.port}`, - ); - } - const sum = [...bridge.viewers.values()].reduce((a, b) => a + b, 0); - if (sum + 1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { - // PER BRIDGE (decision 7): a per-viewer cap would let one - // account's 40 tabs sail past the limit the spec named. - throw new PreviewRefusal( - "preview-watch-over-cap", - `this pane's screencast bridge already has ${PREVIEW_WATCH_SUBSCRIBERS_MAX} subscribers`, - ); - } - bridge.viewers.set(viewerUserId, (bridge.viewers.get(viewerUserId) ?? 0) + 1); - bridge.lastActivity = deps.nowMs(); - return { ok: true }; - } + if (joinBridge({ viewerUserId, paneId, port })) return { ok: true }; + const flying = watchOpeningByPane.get(paneId); + if (flying) { + // The pane's one cold open is already flying (two tabs POSTed at + // once): await the winner's ack, then take the join path against the + // now-live bridge — port-conflict and the per-bridge cap apply to + // the joiner exactly as to a late caller. If the open FAILED, the + // rejection is this caller's honest answer too; the map entry is + // already cleared, so the NEXT startWatch flies fresh. + await flying; + return broker.startWatch({ viewerUserId, paneId, nodeId, port }); } - if (1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { - throw new PreviewRefusal("preview-watch-over-cap", "screencast subscribers cap below one"); + // Cold: this caller owns the open, and registering the bridge happens + // inside the serialized chain — the race is gone because only one open + // per pane ever flies (review 2026-10-23). + const opening = openBridge({ viewerUserId, paneId, nodeId, port }); + watchOpeningByPane.set(paneId, opening); + try { + await opening; + } finally { + watchOpeningByPane.delete(paneId); } - const ref = mintRef(); - const data = await deps.send(nodeId, { - type: "preview_open", - ref, - kind: "screencast", - paneId, - port, - lifetimeMs: PREVIEW_LIFETIME_MAX_MS, - }); - const open = parseNodePreviewOpenResult(data); - if (open === null) throw new Error("preview_open answered a shape the grammar refuses"); - // The ack's `subscribers` count is IGNORED: the bridge counts itself - // (this broker owns the refcount; the node's number is advisory at best). - const session: PreviewSession = { - ref, - kind: "screencast", - mode: "http", // bridges have no leg; the member exists for the proxy's discriminator - paneId, - nodeId, - port, - createdBy: viewerUserId, - createdAt: deps.nowMs(), - lanes: new Map(), - viewers: new Map([[viewerUserId, 1]]), - lastActivity: deps.nowMs(), - cookieJar: new Map(), - }; - sessionsByRef.set(ref, session); - bridgeRefByPane.set(paneId, ref); - await auditPreview("node.preview_watch.open", nodeId, { paneId, nodeId, port, kind: "screencast" }); return { ok: true }; }, @@ -910,12 +994,15 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { for (const viewer of [...session.viewers.keys()]) { if (!accessAtLeast(await access(viewer), "view")) session.viewers.delete(viewer); } - if (session.viewers.size === 0) { - await closeWatchBridge(session, "revoked", true); - continue; - } - const map = streamsByPane.get(paneId); - if (!map) continue; + if (session.viewers.size === 0) await closeWatchBridge(session, "revoked", true); + } + // Stream sockets are PANE-keyed, not session-keyed: a pane can hold + // attached sockets with no bridge alive (the node reported the bridge + // closed; the sockets ride until something feeds them again). So this + // walk runs for EVERY shares write on the pane, forward-only included — + // pane-complete by construction, harmless when empty. + const map = streamsByPane.get(paneId); + if (map) { for (const [socketKey, entry] of [...map]) { if (!accessAtLeast(await access(entry.holderUserId), "view")) map.delete(socketKey); } @@ -946,8 +1033,14 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { const debt = entry.socket.getBufferedAmount?.() ?? 0; if (debt > PREVIEW_STREAM_DEBT_MAX) { map.delete(socketKey); - void auditPreview("node.preview_watch.viewer_drop", null, { + // The row explains a dark canvas: which node, which port, which + // kind, why. (The attach-cap row above keeps what it has — at that + // point no session has even been consulted.) + void auditPreview("node.preview_watch.viewer_drop", session.nodeId, { paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "screencast", reason: "slow-link", }).catch((err: unknown) => deps.log(`preview slow-link drop audit threw: ${String(err)}`)); continue; @@ -1029,6 +1122,7 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { clearInterval(sweepTimer); sessionsByRef.clear(); bridgeRefByPane.clear(); + watchOpeningByPane.clear(); // the chains die with the instance they were flying on lanesByRef.clear(); streamsByPane.clear(); displaysByPane.clear(); From 29c6e5ffb4cf6bb1aa40a8a68719ad8349ed57d9 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 02:09:16 -0700 Subject: [PATCH 041/139] feat(api): preview REST surface, gated screen launches, and the view/edit authz matrix --- .../__tests__/preview-routes-auth.test.ts | 731 ++++++++++++++++++ .../api/subshells/create-subshell.route.ts | 21 + apps/server/api/src/api/subshells/index.ts | 6 + .../api/src/api/subshells/preview-gate.ts | 66 ++ .../src/api/subshells/preview-info.route.ts | 133 ++++ .../src/api/subshells/preview-open.route.ts | 103 +++ .../src/api/subshells/preview-watch.route.ts | 145 ++++ .../src/api/subshells/subshell-exit.route.ts | 14 + .../__tests__/request-log-ignore.test.ts | 9 + apps/server/api/src/plugins/context.plugin.ts | 3 + .../api/src/services/nodes/node-launcher.ts | 8 + .../src/services/nodes/preview-refusals.ts | 75 ++ .../api/src/services/nodes/remote-launcher.ts | 43 +- .../src/services/subshell-manager.service.ts | 12 + .../api/src/services/subshells.service.ts | 24 + 15 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts create mode 100644 apps/server/api/src/api/subshells/preview-gate.ts create mode 100644 apps/server/api/src/api/subshells/preview-info.route.ts create mode 100644 apps/server/api/src/api/subshells/preview-open.route.ts create mode 100644 apps/server/api/src/api/subshells/preview-watch.route.ts create mode 100644 apps/server/api/src/services/nodes/preview-refusals.ts diff --git a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts new file mode 100644 index 000000000..5fcb7a36a --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts @@ -0,0 +1,731 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { authDatabase } from "@/auth/database.js"; +import { ensureSystemUser } from "@/auth/system-user.js"; +import { getAuth } from "@/auth.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { holdConnection, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { + attachScriptedNode, + ok, + type ScriptedHandlers, + type ScriptedNode, + statDirEcho, +} from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * The Task 9 authz matrix and shape contract for the preview REST surface: + * `GET /api/subshells/:id/preview`, `POST /:id/preview/open`, + * `POST /:id/preview/watch`, `DELETE /:id/preview/watch`, and the create + * path's gated `screen` field. + * + * The spine is the actor table, cloned from the ssh route suites: `view` + * reads and watches, `edit` also forwards, a stranger sees only 404s, and + * the pane's own subshell key resolves as its OWNER with shares off (docs/ + * security.md §4), so it acts on its own rows and cannot discover foreign + * ones. Node keys 401 on REST (§5.5), a system key is nowhere without a + * deliberate grant and lands wherever one points (§3), and a scoped key + * without the route's annotation permission is refused at the annotation + * with the NAMED-permission branch. + * + * The shape rows pin spec §6's truthfulness rules: an unreachable node + * answers the same shape with `nodeEnabled: false` and a reason rather than + * an error; a switch-on non-Linux node answers `nodeEnabled: true` with the + * ports refusal NAMED (a blank list with no reason reads as "nothing is + * listening"); a display that died mid-pane passes `screenLost` through + * named beside `screen: null` (spec §9); and a terminated pane short-circuits + * ROUTE-side without any RPC leaving the plane. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +const nodeId = `pvnode-${crypto.randomUUID().slice(0, 8)}`; + +/** The pane's node, live by default; each test overrides what it answers. */ +function liveHandlers(overrides: ScriptedHandlers = {}): ScriptedHandlers { + return { + stat_dir: statDirEcho, + launch: ok, + preview_open: (cmd) => (cmd.type === "preview_open" ? { ref: cmd.ref, subscribers: 1 } : new Error("wrong cmd")), + preview_close: ok, + preview_ports_get: () => ({ + ports: [{ proto: "tcp", port: 3000, pid: 4242 }], + cdpPort: 9222, + screen: null, + }), + ...overrides, + }; +} + +let sim: ScriptedNode; + +function attachLive(overrides: ScriptedHandlers = {}) { + sim?.detach(); + sim = attachScriptedNode(nodeId, liveHandlers(overrides)); + return sim; +} + +/** Call a preview door (or any subshell path) with one actor's credential. */ +async function call( + method: string, + path: string, + opts: { cookie?: string; bearer?: string; body?: unknown } = {}, +): Promise { + const headers = new Headers(); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + if (opts.body !== undefined) { + headers.set("content-type", "application/json"); + } + return app.fetch( + new Request(`http://localhost:3080/api/subshells${path}`, { + method, + headers, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }), + ); +} + +const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; +const jsonBody = async (res: Response) => (await res.json()) as Record; + +/** The four preview doors, so the actor table reads as a table. */ +const DOORS = [ + { label: "info", method: "GET", path: (id: string) => `/${id}/preview` }, + { label: "open", method: "POST", path: (id: string) => `/${id}/preview/open`, body: { port: 3000 } }, + { label: "watch", method: "POST", path: (id: string) => `/${id}/preview/watch`, body: { port: 3000 } }, + { label: "watch stop", method: "DELETE", path: (id: string) => `/${id}/preview/watch` }, +] as const; + +describe("preview REST surface (Task 9)", () => { + const pw = "preview-pass-1"; + const ownerEmail = `pv-owner-${crypto.randomUUID()}@subshell.local`; + const viewerEmail = `pv-viewer-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `pv-editor-${crypto.randomUUID()}@subshell.local`; + const foreignEmail = `pv-foreign-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let ownerCookie: string; + let viewerId: string; + let viewerCookie: string; + let editorId: string; + let editorCookie: string; + let foreignId: string; + let foreignCookie: string; + let systemUserId: string; + const createdSubshellIds: string[] = []; + const createdKeyIds: string[] = []; + + /** A RUNNING row written straight to the table. */ + async function directRow( + overrides: { userId?: string; status?: "running" | "terminated"; name?: string } = {}, + ): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: "terminal", + name: overrides.name ?? `pv ${id.slice(0, 4)}`, + workingDir: "/tmp", + tmuxSocket: `pv-sock-${id}`, + nodeId, + status: overrides.status ?? "running", + alive: overrides.status === "terminated" ? 0 : 1, + ssh: null, + }); + return id; + } + + async function grantPane(paneId: string, granteeUserId: string, permission: "view" | "edit"): Promise { + await shares.replaceForSubshell(paneId, [{ granteeUserId, permission }], ownerId); + } + + async function bearerFor(paneId: string): Promise { + const token = await issueSubshellToken(paneId, ownerId); + const row = await subshells.findById(paneId); + if (row?.apiKeyId) createdKeyIds.push(row.apiKeyId); + return token; + } + + async function paneCount(userId: string): Promise { + const r = await db + .selectFrom("subshells") + .select((eb) => eb.fn.countAll().as("n")) + .where("userId", "=", userId) + .executeTakeFirst(); + return Number(r?.n ?? 0); + } + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + const mk = async (email: string): Promise<[string, string]> => { + const id = await users.createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + return [id, await signIn(email, pw)]; + }; + [ownerId, ownerCookie] = await mk(ownerEmail); + [viewerId, viewerCookie] = await mk(viewerEmail); + [editorId, editorCookie] = await mk(editorEmail); + [foreignId, foreignCookie] = await mk(foreignEmail); + systemUserId = await ensureSystemUser(); + await nodes.create({ id: nodeId, ownerUserId: ownerId, name: "Preview Node", kind: "agent" }); + await nodes.setPreviewEnabled(nodeId, { on: true, changedAt: new Date().toISOString() }); + }); + + afterAll(async () => { + sim?.detach(); + shutdownPreviewBrokerForTests(); + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await subshells.delete(id).catch(() => {}); + for (const kid of createdKeyIds) authDatabase().run("DELETE FROM apikey WHERE id = ?", [kid]); + for (const email of [ownerEmail, viewerEmail, editorEmail, foreignEmail]) { + await deleteUserByEmailOrId(email).catch(() => {}); + } + await nodes.deleteById(nodeId).catch(() => {}); + }); + + beforeEach(async () => { + // A fresh registry keeps a held connection from one row bleeding into the + // next; a fresh broker (production seams, so the real RPC reaches the + // scripted node) keeps lanes and watches from doing the same. + resetNodeRegistryForTests(); + resetForTests(); + await nodes.setPreviewEnabled(nodeId, { on: true, changedAt: new Date().toISOString() }); + }); + + /* ---------------------------------------------------------------- */ + /* the actor table */ + /* ---------------------------------------------------------------- */ + + it("the owner reaches all four doors", async () => { + attachLive(); + const pane = await directRow(); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: ownerCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("an edit grantee reaches all four doors too", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, editorId, "edit"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: editorCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("a view grantee reads and watches, and is 403 refused the forward", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, viewerId, "view"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: viewerCookie, + ...("body" in door ? { body: door.body } : {}), + }); + // Forwarding a raw lane is `edit`; info and the watch doors are reads. + expect(res.status).toBe(door.label === "open" ? 403 : 200); + } + }); + + it("a foreign account sees 404 on all four doors, never a 403", async () => { + attachLive(); + const pane = await directRow(); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: foreignCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + }); + + it("a pane's own key acts as its owner, shares off, on both sides (§4)", async () => { + attachLive(); + // docs/security.md §4: raw REST resolves a pane's own subshell token as + // its OWNER with boost and shares OFF. Both halves matter: the owner's + // own rows must answer 200 (that is the sibling-action door the posture + // ACCEPTS), and a foreign row must answer 404 (the token sees no shares, + // so what it cannot own simply is not there). + const own = await directRow(); + const token = await bearerFor(own); + const other = await directRow(); // still the owner's pane, reached as its owner + for (const door of DOORS) { + const res = await call(door.method, door.path(other), { + bearer: token, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + // The foreign half: a stranger's pane is invisible to the token. + const foreignPane = await directRow({ userId: foreignId }); + for (const door of DOORS) { + const res = await call(door.method, door.path(foreignPane), { + bearer: token, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + }); + + it("a node key 401s on REST with the explicit node-key message (§5.5)", async () => { + attachLive(); + const pane = await directRow(); + const created = (await getAuth().api.createApiKey({ + body: { name: "pv-node-key", userId: ownerId, metadata: { kind: "node", nodeId } }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(created.id); + // Pinned against the auth-guard-bearer.test.ts:167 precedent: the REST + // guard is a 401 surface for node keys, never a 403 that leaks a route. + const res = await call("GET", `/${pane}/preview`, { bearer: created.key }); + expect(res.status).toBe(401); + const body = await errorBody(res); + expect(body.code).toBe(BackendErrorCodes.INVALID_CREDENTIALS); + expect(body.message).toMatch(/node keys/i); + }); + + it("a system key is nowhere ungranted, and a deliberate grant reaches it (§3)", async () => { + attachLive(); + const pane = await directRow(); + // A legacy unversioned system key carries `systemKeyPermissions === + // null`, which passes the scoped-key annotation gate untouched (that is + // why the pane-visibility rows carry the weight); the gate that refuses + // it is the ordinary access resolver: the system user owns nothing and + // holds no grant, so every door answers 404. + const sysKey = (await getAuth().api.createApiKey({ + body: { name: "pv-system", userId: systemUserId, metadata: { kind: "system" } }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(sysKey.id); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + bearer: sysKey.key, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + // The positive half (docs/security.md §3): a deliberate grant to the + // system user DOES reach it. `edit` so all four doors answer. + await grantPane(pane, systemUserId, "edit"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + bearer: sysKey.key, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("a scoped key lacking the route's permission is refused at the annotation, by name", async () => { + attachLive(); + const pane = await directRow(); + // Versioned and scoped: the guard's NAMED-permission branch fires + // (auth-guard.ts: "This API key needs the ${requiredSystemPermission} + // permission."), not the legacy "isn't available" wording, because every + // preview route carries a permission annotation like its siblings. + const scoped = (await getAuth().api.createApiKey({ + body: { + name: "pv-scoped", + userId: systemUserId, + metadata: { kind: "system", permissionVersion: 1, systemPermissions: ["channels:read"] }, + }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(scoped.id); + const read = await call("GET", `/${pane}/preview`, { bearer: scoped.key }); + expect(read.status).toBe(403); + expect((await errorBody(read)).message).toMatch(/needs the subshells:read permission/); + const exec = await call("POST", `/${pane}/preview/open`, { bearer: scoped.key, body: { port: 3000 } }); + expect(exec.status).toBe(403); + expect((await errorBody(exec)).message).toMatch(/needs the subshells:execute permission/); + // And with the permission it reaches, proving the branch was the gate. + const granted = (await getAuth().api.createApiKey({ + body: { + name: "pv-scoped-ok", + userId: systemUserId, + metadata: { kind: "system", permissionVersion: 1, systemPermissions: ["subshells:read", "subshells:execute"] }, + }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(granted.id); + await grantPane(pane, systemUserId, "edit"); + const seen = await call("GET", `/${pane}/preview`, { bearer: granted.key }); + expect(seen.status).toBe(200); + }); + + /* ---------------------------------------------------------------- */ + /* the info shape */ + /* ---------------------------------------------------------------- */ + + it("a healthy live pane answers the full shape", async () => { + attachLive(); + const pane = await directRow(); + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBeUndefined(); + expect(body.ports).toEqual([{ proto: "tcp", port: 3000, pid: 4242 }]); + expect(body.cdpPort).toBe(9222); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBeUndefined(); + }); + + it("an unreachable node answers the same shape, nodeEnabled false, never an error", async () => { + attachLive(); + const pane = await directRow(); + sim.detach(); // the link is gone; the row still says running + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-offline"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("a held node names the hold, and below-floor names age", async () => { + const holder = attachLive(); + const pane = await directRow(); + const held = holdConnection(nodeId, holder.conn, { + reason: "protocol-mismatch", + agentVersion: "0.0.1", + protocolVersion: 1, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + expect(held).toBeDefined(); + let body = await jsonBody(await call("GET", `/${pane}/preview`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-held"); + // Swap the held record for the too-old gate: same door, different truth. + resetNodeRegistryForTests(); + const s2 = attachScriptedNode(nodeId, liveHandlers()); + sim = s2; + holdConnection(nodeId, s2.conn, { + reason: "below-floor", + agentVersion: "0.1.0", + protocolVersion: 14, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + body = await jsonBody(await call("GET", `/${pane}/preview`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-too-old"); + }); + + it("a switch-off node answers a reason and sends no RPC", async () => { + const node = attachLive(); + const pane = await directRow(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("preview-disabled"); + expect(body.ports).toEqual([]); + expect(node.countOf("preview_ports_get")).toBe(0); + }); + + it("an online, switch-on, non-Linux node names preview-ports-unsupported with nodeEnabled TRUE", async () => { + // The mac leg of Task 14 reads exactly this: nodeEnabled describes the + // NODE (online, switch on), and the refusal the node itself names rides + // in `reason`. A blank [] with no reason would read as "nothing is + // listening" and lie about why. + attachLive({ + preview_ports_get: () => new Error("preview-ports-unsupported"), + }); + const pane = await directRow(); + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBe("preview-ports-unsupported"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("a display that died mid-pane passes screenLost named, paired with screen null", async () => { + // Spec §9: the marker arrives from the node and passes through named, + // never inferred. A route that swallowed it would render silence where + // the pane should say "the screen went away". + attachLive({ + preview_ports_get: () => ({ + ports: [{ proto: "tcp6", port: 8080, pid: 91 }], + cdpPort: null, + screen: null, + screenLost: "x11vnc", + }), + }); + const pane = await directRow(); + const body = await jsonBody(await call("GET", `/${pane}/preview`, { cookie: ownerCookie })); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBe("x11vnc"); + expect(body.ports).toEqual([{ proto: "tcp6", port: 8080, pid: 91 }]); + }); + + it("a live display passes the screen pair through", async () => { + attachLive({ + preview_ports_get: () => ({ + ports: [], + cdpPort: null, + screen: { display: ":90", rfbPort: 5910 }, + }), + }); + const pane = await directRow(); + const body = await jsonBody(await call("GET", `/${pane}/preview`, { cookie: ownerCookie })); + expect(body.screen).toEqual({ display: ":90", rfbPort: 5910 }); + expect(body.screenLost).toBeUndefined(); + }); + + it("a terminated pane answers the empty shape with no RPC leaving the plane", async () => { + // The route-side short-circuit: a pane that is simply gone is not a + // preview failure. Applying the refusal-mapping rule here would answer + // `nodeEnabled: false` naming a ports refusal for a row that cannot + // answer anything, and no `screenLost`: a closed pane has no display + // that could die. The broker's spy (the wire) must stay untouched. + const node = attachLive(); + const pane = await directRow({ status: "terminated" }); + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBeUndefined(); + // nodeEnabled/reason still describe the NODE (online, switch on). + expect(body.nodeEnabled).toBe(true); + expect(node.countOf("preview_ports_get")).toBe(0); + }); + + /* ---------------------------------------------------------------- */ + /* open and watch */ + /* ---------------------------------------------------------------- */ + + it("open hands out a proxyBase for the ref it minted", async () => { + const node = attachLive(); + const pane = await directRow(); + const res = await call("POST", `/${pane}/preview/open`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.proxyBase).toMatch(/^\/pv\/[0-9a-f]{32}\/$/); + const opens = node.cmdsOf("preview_open"); + expect(opens.length).toBeGreaterThan(0); + expect(opens[0].paneId).toBe(pane); + expect(opens[0].port).toBe(3000); + }); + + // The string case names a string that cannot READ as a number: Elysia's + // schema layer coerces a numeric string ("3000") like every other numeric + // surface on this app, and an honest bound check says so rather than + // pretending a port of "3000" arrives. + it.each([ + ["zero", 0], + ["above the port range", 70000], + ["a fraction", 1.5], + ["a string that is not a number", "not-a-port"], + ["missing entirely", undefined], + ])("open refuses port %s at the schema", async (_label, port) => { + const node = attachLive(); + const pane = await directRow(); + const res = await call("POST", `/${pane}/preview/open`, { + cookie: ownerCookie, + body: port === undefined ? {} : { port }, + }); + expect(res.status).toBe(400); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + expect(node.countOf("preview_open")).toBe(0); + }); + + it("watch with no body defaults to the cached cdpPort, and stop is idempotent", async () => { + const node = attachLive(); + const pane = await directRow(); + const start = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: {} }); + expect(start.status).toBe(200); + expect(await jsonBody(start)).toEqual({ ok: true }); + const opens = node.cmdsOf("preview_open"); + expect(opens.length).toBe(1); + expect(opens[0].kind).toBe("screencast"); + expect(opens[0].port).toBe(9222); // the ports answer's cdpPort + const stop = await call("DELETE", `/${pane}/preview/watch`, { cookie: ownerCookie }); + expect(stop.status).toBe(200); + expect(await jsonBody(stop)).toEqual({ ok: true }); + // The caller unsubscribed; a second stop is a no-op, not a 404 hunt. + const stop2 = await call("DELETE", `/${pane}/preview/watch`, { cookie: ownerCookie }); + expect(stop2.status).toBe(200); + expect(node.countOf("preview_close")).toBeGreaterThanOrEqual(1); + }); + + it("watch with no port and no cached cdpPort is a 400 that names what is missing", async () => { + attachLive({ + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const pane = await directRow(); + const res = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: {} }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(/cdpPort/i); + expect(body.message).toMatch(/port/i); + }); + + it("watch naming a port the live bridge does not hold is 409, surfaced by name", async () => { + attachLive(); + const pane = await directRow(); + const first = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(first.status).toBe(200); + // The bridge exists on 3000; re-pointing the same pane's one bridge to + // 4000 is the broker's `watch-port-conflict` refusal (Task 8 owns the + // rule; the route owes it a named 409, not a bare 500). + const clash = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: { port: 4000 } }); + expect(clash.status).toBe(409); + expect((await errorBody(clash)).message).toMatch(/watch-port-conflict/); + const stop = await call("DELETE", `/${pane}/preview/watch`, { cookie: ownerCookie }); + expect(stop.status).toBe(200); + }); + + it("watch on an offline node is a 409 naming it, never a 500", async () => { + attachLive(); + const pane = await directRow(); + sim.detach(); + const res = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/offline|not connected|gone/i); + }); + + /* ---------------------------------------------------------------- */ + /* the create path's screen field */ + /* ---------------------------------------------------------------- */ + + /** Install the harness so the create path reaches its node resolution. */ + async function makeLaunchable(): Promise { + attachLive(); + await nodes.applyInventory( + nodeId, + JSON.stringify([{ harnessId: "claude-code", installed: true, binaryPath: "/usr/bin/claude" }]), + ); + } + + async function createBody(extra: Record = {}) { + return { + harnessId: "claude-code", + workingDir: "/tmp", + nodeId, + ...extra, + }; + } + + it("a screen launch on a preview-off node is refused BEFORE the launch RPC", async () => { + // The preflight runs in the service, before the row, the token, and the + // wire: the fake's `launch` spy must stay empty. The remedy names the + // switch, mirroring spec §6's wording; the node gate (Task 7) stays the + // authority for everything the plane cannot see. + await makeLaunchable(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + const before = await paneCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv screened", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(/turn on Preview for this node/i); + expect(sim.countOf("launch")).toBe(0); + expect(await paneCount(ownerId)).toBe(before); + }); + + it("a screen launch on a preview-on node reaches the wire carrying screen", async () => { + await makeLaunchable(); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv screen ok", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const launches = sim.cmdsOf("launch"); + expect(launches.length).toBe(1); + expect(launches[0].screen).toEqual({ width: 1280, height: 800 }); + const created = await jsonBody(res); + createdSubshellIds.push(String(created.id)); + }); + + it("a plain launch carries no screen member at all", async () => { + await makeLaunchable(); + const res = await call("POST", "", { cookie: ownerCookie, body: await createBody({ name: "pv plain" }) }); + expect(res.status).toBe(200); + const launches = sim.cmdsOf("launch"); + expect(launches.length).toBe(1); + expect(launches[0].screen).toBeUndefined(); + const created = await jsonBody(res); + createdSubshellIds.push(String(created.id)); + }); + + it.each([ + ["width below the floor", { width: 319, height: 800 }], + ["height above the ceiling", { width: 1280, height: 5000 }], + ["a fractional width", { width: 1000.5, height: 800 }], + ["a non-object", "1280x800"], + ])("create refuses screen %s at the schema", async (_label, screen) => { + await makeLaunchable(); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv bounds", screen }), + }); + expect(res.status).toBe(400); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + expect(sim.countOf("launch")).toBe(0); + }); + + /* ---------------------------------------------------------------- */ + /* the pane's own exit tears the preview down */ + /* ---------------------------------------------------------------- */ + + it("a self-reported exit closes the pane's preview state as pane-gone", async () => { + attachLive(); + const pane = await directRow(); + const token = await bearerFor(pane); + // Start a watch so there IS bridge state to lose, then report the exit + // exactly the pane's own reporter call would. + const start = await call("POST", `/${pane}/preview/watch`, { bearer: token, body: { port: 3000 } }); + expect(start.status).toBe(200); + expect(getPreviewBroker().sessionForPane(pane)).not.toBeNull(); + const res = await call("POST", `/${pane}/exit`, { bearer: token, body: { exitCode: 0 } }); + expect(res.status).toBe(200); + // The broker is the one that knows "this pane is gone" now: every lane + // and bridge of it is closed (the audit rides onDisplayClosed inside the + // broker; the route never calls it on its own). + expect(getPreviewBroker().sessionForPane(pane)).toBeNull(); + }); +}); diff --git a/apps/server/api/src/api/subshells/create-subshell.route.ts b/apps/server/api/src/api/subshells/create-subshell.route.ts index a2024cc41..a5e09ced7 100644 --- a/apps/server/api/src/api/subshells/create-subshell.route.ts +++ b/apps/server/api/src/api/subshells/create-subshell.route.ts @@ -34,6 +34,26 @@ const CreateSubshellBodySchema = t.Object({ "Workspace to file the new subshell in: a tab row is written for it beside the launch. 404 when it is not the caller's (or absent). Omitted = the pane starts ungrouped", }), ), + screen: t.Optional( + t.Object( + { + width: t.Integer({ + minimum: 320, + maximum: 4096, + description: "Virtual display width in pixels the node allocates for this pane", + }), + height: t.Integer({ + minimum: 320, + maximum: 4096, + description: "Virtual display height in pixels the node allocates for this pane", + }), + }, + { + description: + "Ask the node for a virtual display (Xvfb + screencast bridge) for this pane. Refused before the launch when the node's Preview switch is off, and by the node itself when its platform or binaries cannot host one", + }, + ), + ), }); /** Shared with restart (same response shape). */ @@ -88,6 +108,7 @@ export const createSubshellRoute = new Elysia() prompt: body.prompt, nodeId: body.nodeId, workspaceId: body.workspaceId, + screen: body.screen, machineActor: actor !== "cookie", // A launch that arrives on a PANE'S OWN token is an agent opening a // sibling to talk to it (`create_subshell`); a system key is admin diff --git a/apps/server/api/src/api/subshells/index.ts b/apps/server/api/src/api/subshells/index.ts index 343b00ded..b9bb09e12 100644 --- a/apps/server/api/src/api/subshells/index.ts +++ b/apps/server/api/src/api/subshells/index.ts @@ -7,6 +7,9 @@ import { getSubshellRoute } from "@/api/subshells/get-subshell.route.js"; import { getSubshellLogRoute } from "@/api/subshells/get-subshell-log.route.js"; import { inputSubshellRoute } from "@/api/subshells/input-subshell.route.js"; import { listSubshellsRoute } from "@/api/subshells/list-subshells.route.js"; +import { previewInfoRoute } from "@/api/subshells/preview-info.route.js"; +import { previewOpenRoute } from "@/api/subshells/preview-open.route.js"; +import { previewWatchRoute } from "@/api/subshells/preview-watch.route.js"; import { restartSubshellRoute } from "@/api/subshells/restart-subshell.route.js"; import { subshellAttentionRoute } from "@/api/subshells/subshell-attention.route.js"; import { subshellExitRoute } from "@/api/subshells/subshell-exit.route.js"; @@ -31,6 +34,9 @@ export const subshellRoutes = new Elysia({ prefix: "/api/subshells" }).use([ summarySubshellRoute, getSubshellRoute, getSubshellLogRoute, + previewInfoRoute, + previewOpenRoute, + previewWatchRoute, subshellSharesRoutes, updateSubshellNotifyRoute, subshellAttentionRoute, diff --git a/apps/server/api/src/api/subshells/preview-gate.ts b/apps/server/api/src/api/subshells/preview-gate.ts new file mode 100644 index 000000000..7fe8413e2 --- /dev/null +++ b/apps/server/api/src/api/subshells/preview-gate.ts @@ -0,0 +1,66 @@ +import { type GuardActor, HttpError } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; +import { getHeld, isNodeOffline } from "@/services/nodes/node-registry.js"; +import { getPreviewEnabled } from "@/services/nodes/preview-enabled.js"; + +/** + * The shared door for the three preview routes. It mirrors the subshell + * service's private gate exactly (same 404 for absent-or-invisible, same 403 + * wording for insufficient) because a bearer pane key resolves as its owner + * with shares off, and the preview routes must not widen or narrow that. + */ + +const repos = { + subshells: new SubshellsRepository(db), + shares: new SubshellSharesRepository(db), +}; + +/** The row plus the resolved access, or the HTTP refusal a stranger deserves. */ +export async function gatePreview( + viewerId: string, + subshellId: string, + min: Exclude, + actor: GuardActor, +): Promise<{ row: SubshellTable; access: Access }> { + const { row, access } = await loadSubshellAccess(repos, viewerId, subshellId, { + allowShares: actor !== "subshell-key", + }); + if (!row || access === "none") throw new HttpError(404, "Subshell not found"); + if (!accessAtLeast(access, min)) { + throw new HttpError(403, "You don't have permission to do that with this subshell"); + } + return { row, access }; +} + +/** + * What the NODE can do about preview, independent of any one pane: the + * switch, the held gates, and the link. `reason` is a stable word the SPA + * maps to a tooltip; the closed set is `preview-disabled`, `node-held`, + * `node-too-old`, `node-offline`, plus the per-failure words the info route + * adds when the live call itself refuses. + */ +export interface PreviewNodeState { + nodeEnabled: boolean; + reason?: string; +} + +/** The node-side truth for one machine: switch off, held, gone, or healthy. */ +export async function previewNodeState(nodeId: string): Promise { + if (!(await getPreviewEnabled(nodeId))) { + return { nodeEnabled: false, reason: "preview-disabled" }; + } + const held = getHeld(nodeId); + if (held) { + // `below-floor` is the version gate; every other hold (protocol + // mismatch, encryption-required) is a handshake fact, not an age fact. + return { nodeEnabled: false, reason: held.reason === "below-floor" ? "node-too-old" : "node-held" }; + } + if (isNodeOffline(nodeId)) { + return { nodeEnabled: false, reason: "node-offline" }; + } + return { nodeEnabled: true }; +} diff --git a/apps/server/api/src/api/subshells/preview-info.route.ts b/apps/server/api/src/api/subshells/preview-info.route.ts new file mode 100644 index 000000000..788a47895 --- /dev/null +++ b/apps/server/api/src/api/subshells/preview-info.route.ts @@ -0,0 +1,133 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { PREVIEW_PORTS_UNSUPPORTED } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; + +/** + * `GET /api/subshells/:id/preview` (spec §6): what the pane currently offers + * to watch. The answer is ALWAYS this shape, never an error, because the + * open pane view polls it: an unreachable node, a switched-off machine, a + * platform without the ports scan, and a pane that is simply gone each read + * as the same fields with `nodeEnabled` and `reason` carrying the truth. + * + * Two shape rules carry weight. A switch-on node that refuses the ports scan + * for platform reasons answers `nodeEnabled: true` with the refusal NAMED in + * `reason` (a node that cannot speak is still online; a blank `[]` with no + * reason reads as "nothing is listening" and lies about why). And a closed + * pane short-circuits route-side to the empty shape without any RPC: the + * refusal-mapping rule is for live panes whose node-side answer failed, and + * a pane that is gone has no display that could die, so no `screenLost`. + */ + +const PreviewPortSchema = t.Object({ + proto: t.Union([t.Literal("tcp"), t.Literal("tcp6")], { + description: "Address family the listener is on", + }), + port: t.Integer({ description: "Listening port on the pane's machine (loopback)" }), + pid: t.Integer({ description: "Process holding the listener, inside the pane's tree" }), +}); + +const PreviewScreenSchema = t.Object({ + display: t.String({ description: "X display the pane's virtual screen runs on (for example :90)" }), + rfbPort: t.Integer({ description: "Loopback RFB (VNC) port the screencast bridge reads from" }), +}); + +const PreviewInfoResponseSchema = t.Object({ + ports: t.Array(PreviewPortSchema, { + description: "Listening sockets in the pane's process tree; empty when there are none or the node cannot answer", + }), + cdpPort: t.Nullable(t.Integer({ description: "The pane's open devtools port, or null when nothing speaks CDP" })), + screen: t.Nullable(PreviewScreenSchema, { + description: "The pane's virtual display when it has one, or null (never a guess)", + }), + screenLost: t.Optional( + t.Union([t.Literal("Xvfb"), t.Literal("x11vnc")], { + description: "Set only beside screen null: which display process died since the pane launched", + }), + ), + nodeEnabled: t.Boolean({ + description: "Whether the pane's NODE can serve preview right now (switch, gates, link), not the pane", + }), + reason: t.Optional( + t.String({ + description: + "Why nodeEnabled is false (preview-disabled, node-held, node-too-old, node-offline, or the node's named refusal); also carries the ports refusal word when nodeEnabled is true and the node itself refused the scan", + }), + ), +}); + +/** The empty shape for every "the node cannot answer" branch. */ +const EMPTY_PORTS = { ports: [] as never[], cdpPort: null, screen: null } as const; + +/** Map a failed `preview_ports_get` onto spec §6's nodeEnabled/reason pair. */ +function mapPortsFailure(err: unknown): { nodeEnabled: boolean; reason: string } { + if (err instanceof NodeRpcError) { + if (err.code === "offline") return { nodeEnabled: false, reason: "node-offline" }; + if (err.code === "timeout") return { nodeEnabled: false, reason: "node-timeout" }; + // The agent's own refusal, passed through NAMED (the wire-constant + // habit). `preview-ports-unsupported` keeps `nodeEnabled: true`: the + // node is online and switched on, it just has no /proc to read. + if (err.detail !== undefined && err.code === "failed") { + if (err.detail === PREVIEW_PORTS_UNSUPPORTED) return { nodeEnabled: true, reason: PREVIEW_PORTS_UNSUPPORTED }; + return { nodeEnabled: false, reason: err.detail }; + } + return { nodeEnabled: false, reason: `node-refused (${err.code})` }; + } + // A malformed answer or a bug below: still not an error for a polling page. + return { nodeEnabled: false, reason: "preview-refused" }; +} + +export const previewInfoRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .get( + "/:id/preview", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + const { row } = await gatePreview(user.id, params.id, "view", actor); + const nodeId = row.nodeId; + const nodeState = await previewNodeState(nodeId); + // A pane that is not running is answered route-side, with no RPC: + // short-circuit order is closed-first so a dead row on a live node + // still says "no ports, no screen", and nodeState keeps describing + // the NODE rather than the pane. + if (row.status !== "running") { + return { ...EMPTY_PORTS, ...nodeState }; + } + if (!nodeState.nodeEnabled) { + return { ...EMPTY_PORTS, ...nodeState }; + } + try { + const answer = await getPreviewBroker().getPorts(nodeId, params.id); + return { + ports: answer.ports, + cdpPort: answer.cdpPort, + screen: answer.screen, + ...(answer.screenLost === undefined ? {} : { screenLost: answer.screenLost }), + nodeEnabled: true, + }; + } catch (err) { + return { ...EMPTY_PORTS, ...mapPortsFailure(err) }; + } + }, + { + response: { + 200: PreviewInfoResponseSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "getSubshellPreviewInfo", + tags: ["subshells"], + description: + "What the pane currently offers to watch: listening ports, devtools port, virtual display, and whether its node can serve preview. Always answers, never errors, because the open pane view polls it", + }, + }, + ); diff --git a/apps/server/api/src/api/subshells/preview-open.route.ts b/apps/server/api/src/api/subshells/preview-open.route.ts new file mode 100644 index 000000000..47f645b06 --- /dev/null +++ b/apps/server/api/src/api/subshells/preview-open.route.ts @@ -0,0 +1,103 @@ +import { Elysia, t } from "elysia"; +import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { detailIsNodeOff, PREVIEW_PANE_GONE } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; + +/** + * `POST /api/subshells/:id/preview/open` (spec §6): open one forward lane to + * a pane port and hand out the proxy path. Forwarding a raw TCP lane is an + * `edit` act; watching and reading are not. The ref is the proxy's only + * secret, so it is minted here (the broker owns its whole lifecycle) and the + * route ships nothing but `/pv//` for the SPA to point a browser at. + */ + +const PreviewOpenBodySchema = t.Object({ + port: t.Integer({ + minimum: 1, + maximum: 65_535, + description: "Loopback port inside the pane's machine to forward (the node dials loopback only)", + }), +}); + +const PreviewOpenResponseSchema = t.Object({ + proxyBase: t.String({ + description: "Path prefix the SPA proxies the forwarded lane through, ending in a slash", + }), +}); + +export const previewOpenRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/:id/preview/open", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + const { row } = await gatePreview(user.id, params.id, "edit", actor); + const nodeState = await previewNodeState(row.nodeId); + if (!nodeState.nodeEnabled) { + // The info poll already said this; arriving here means the page was + // stale (or the switch moved mid-click). Say the same thing as a 409. + throw new HttpError(409, `Preview is not available on that node right now (${nodeState.reason}).`); + } + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: user.id, + paneId: params.id, + nodeId: row.nodeId, + port: body.port, + }); + return { proxyBase: `/pv/${ref}/` }; + } catch (err) { + if (err instanceof PreviewRefusal) { + // The broker's own caps (lanes over the per-pane/per-node cap): + // named, 409, so the SPA can say "close a preview first". + throw new HttpError(409, `${err.code}: ${err.message}`); + } + if (err instanceof NodeRpcError) { + if (err.code === "offline") { + throwApiOffline(); + } + if (err.code === "failed" && err.detail !== undefined) { + if (err.detail.startsWith(PREVIEW_PANE_GONE) || detailIsNodeOff(err.detail)) { + throw new HttpError(409, err.detail); + } + throw new HttpError(409, `That node refused the preview: ${err.detail}`); + } + if (err.code === "timeout") { + throw new HttpError(409, "The node did not answer the preview open in time. Try again in a moment."); + } + // `unsupported`: the connected agent predates the preview command. + throw new HttpError(409, "That node's agent does not support preview yet. Update the node, then retry."); + } + throw err; + } + }, + { + body: PreviewOpenBodySchema, + response: { + 200: PreviewOpenResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + systemPermission: "subshells:execute", + detail: { + operationId: "openSubshellPreview", + tags: ["subshells"], + description: + "Open a forward lane to one loopback port of the pane and return the proxy path. Needs edit: the raw lane is the pane's own protocol, not a rendered view", + }, + }, + ); + +/** The one 409 every "cannot talk to that node" branch renders. */ +function throwApiOffline(): never { + throw new HttpError(409, "That node is not connected right now. Reconnect it, or watch the pane's screen instead."); +} diff --git a/apps/server/api/src/api/subshells/preview-watch.route.ts b/apps/server/api/src/api/subshells/preview-watch.route.ts new file mode 100644 index 000000000..dfe063084 --- /dev/null +++ b/apps/server/api/src/api/subshells/preview-watch.route.ts @@ -0,0 +1,145 @@ +import { Elysia, t } from "elysia"; +import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { PREVIEW_PORTS_UNSUPPORTED } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; + +/** + * The screencast watch doors, one file per resource rule: `POST + * /api/subshells/:id/preview/watch` starts the pane's ONE screencast bridge, + * `DELETE /api/subshells/:id/preview/watch` says "this viewer is done". + * Watching is read-side (the node only ever sends devtools screencast + * frames), so both doors sit at `view`, the same split `open` draws at + * `edit`. + * + * No ref is handed out and none is needed: the bridge is keyed by pane and + * every viewer of it shares one, so stop is pane-keyed too. A DELETE with no + * bridge is a no-op, 200 (the caller unsubscribes, idempotently); the route + * never asks whether one exists, because `stopWatch` already answers that + * question truthfully and a route-side hunt would be a second answer. + * + * POST without a `port` falls back to the pane's cached `cdpPort` through the + * broker (its 5 s cache is what makes the poll cheap); a pane with neither + * gets a 400 naming what is missing. A port the LIVE bridge does not hold is + * the broker's `watch-port-conflict` refusal, surfaced as a named 409. + */ + +const PreviewWatchBodySchema = t.Object({ + port: t.Optional( + t.Integer({ + minimum: 1, + maximum: 65_535, + description: "Devtools port to screencast; omitted falls back to the pane's cached cdpPort", + }), + ), +}); + +const PreviewWatchResponseSchema = t.Object({ + ok: t.Literal(true, { description: "The bridge is running (or, for DELETE, the viewer's interest is withdrawn)" }), +}); + +export const previewWatchRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/:id/preview/watch", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + const { row } = await gatePreview(user.id, params.id, "view", actor); + const nodeState = await previewNodeState(row.nodeId); + if (!nodeState.nodeEnabled) { + throw new HttpError(409, `Preview is not available on that node right now (${nodeState.reason}).`); + } + const broker = getPreviewBroker(); + let port = body.port; + if (port === undefined) { + // The cached ports answer is the fallback; a failure here is mapped, + // because the caller asked the pane, not the cache, what to watch. + try { + port = (await broker.getPorts(row.nodeId, params.id)).cdpPort ?? undefined; + } catch (err) { + if (err instanceof NodeRpcError && err.code === "failed" && err.detail === PREVIEW_PORTS_UNSUPPORTED) { + throw new HttpError(400, `That node cannot scan its panes' ports (${err.detail}). Pass a port directly.`); + } + if (err instanceof NodeRpcError && err.code === "offline") { + throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + } + throw new HttpError(409, "The pane's open ports could not be read. Pass a port directly."); + } + if (port === undefined) { + throw new HttpError( + 400, + "The request named no port and the pane has no cached cdpPort to fall back to. Pass a port, or let a devtools port be seen first.", + ); + } + } + try { + await broker.startWatch({ viewerUserId: user.id, paneId: params.id, nodeId: row.nodeId, port }); + return { ok: true } as const; + } catch (err) { + if (err instanceof PreviewRefusal) { + // watch-port-conflict (the bridge is busy on another port) and the + // watch cap, both named (Task 8 owns the rule; the route owes the + // SPA a 409 that says which). + throw new HttpError(409, `${err.code}: ${err.message}`); + } + if (err instanceof NodeRpcError && err.code === "failed" && err.detail !== undefined) { + // The node's refusal of the screencast open (`not-cdp`, pane gone, + // switch off mid-call): pass the word through named. + throw new HttpError(409, `That node refused the watch: ${err.detail}`); + } + if (err instanceof NodeRpcError && err.code === "offline") { + throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + } + throw err; + } + }, + { + body: PreviewWatchBodySchema, + response: { + 200: PreviewWatchResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "startSubshellPreviewWatch", + tags: ["subshells"], + description: + "Start (or join) the pane's single screencast bridge. A missing port falls back to the cached cdpPort; with neither, the refusal says what is missing", + }, + }, + ) + .delete( + "/:id/preview/watch", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + await gatePreview(user.id, params.id, "view", actor); + // Pane-keyed and idempotent: the broker decides whether this was the + // last viewer; a second DELETE changes nothing and still answers ok. + await getPreviewBroker().stopWatch(user.id, params.id); + return { ok: true } as const; + }, + { + response: { + 200: PreviewWatchResponseSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "stopSubshellPreviewWatch", + tags: ["subshells"], + description: + "Withdraw this viewer's interest in the pane's screencast. Idempotent: no bridge open is a success, not a hunt", + }, + }, + ); diff --git a/apps/server/api/src/api/subshells/subshell-exit.route.ts b/apps/server/api/src/api/subshells/subshell-exit.route.ts index 6870d2893..352eb17c4 100644 --- a/apps/server/api/src/api/subshells/subshell-exit.route.ts +++ b/apps/server/api/src/api/subshells/subshell-exit.route.ts @@ -2,6 +2,8 @@ import { Elysia, t } from "elysia"; import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/plugins/context.plugin.js"; import { apiModels } from "@/schema/index.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; +import { logger } from "@/utils/logger.js"; const ExitBodySchema = t.Object({ exitCode: t.Optional( @@ -49,6 +51,18 @@ export const subshellExitRoute = new Elysia() throw new HttpError(403, "Only a subshell's own key may report its exit"); } await ctx.services.subshells.reportExit(params.id, body.exitCode ?? null); + // The pane is gone, so everything hanging off it goes too: every + // forward lane, the screencast bridge, and the display-audit fact + // (the broker files the display close through its own bookkeeping, + // so the route never calls `onDisplayClosed` beside it). Best-effort + // by design: the death row is already written, and a preview that + // refuses to tear down must not turn the pane's own report into a 500 + // (the reconcile sweep and the node's link-drop pass are the backstop). + try { + await getPreviewBroker().closeForPane(params.id, "pane-gone"); + } catch (err) { + logger.warn(`preview teardown after exit report for ${params.id} failed: ${String(err)}`); + } return { ok: true } as const; }, { diff --git a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts index 1ff8e2a55..c5e04eddc 100644 --- a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts +++ b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts @@ -37,6 +37,15 @@ describe("REQUEST_LOG_IGNORE", () => { expect(ignored("/api/nodes/local/logs")).toBe(true); }); + it("ignores the open pane view's preview poll but not its acts", () => { + // The pane view polls GET /:id/preview while it is open (spec §7); + // opening a forward or starting a watch changes what the pane does and + // must stay in the log. + expect(ignored("/api/subshells/018f2c4e-1a2b-7c3d-9e4f-5a6b7c8d9e0f/preview")).toBe(true); + expect(ignored("/api/subshells/x/preview/open")).toBe(false); + expect(ignored("/api/subshells/x/preview/watch")).toBe(false); + }); + it("does not swallow the node routes that are ACTS rather than polls", () => { // Service verbs, repointing and the logging switch each change a machine. // Those lines are the reason an admin turns debug logging on at all. diff --git a/apps/server/api/src/plugins/context.plugin.ts b/apps/server/api/src/plugins/context.plugin.ts index f8a98e7c0..2eec81510 100644 --- a/apps/server/api/src/plugins/context.plugin.ts +++ b/apps/server/api/src/plugins/context.plugin.ts @@ -39,6 +39,9 @@ export const REQUEST_LOG_IGNORE: (string | RegExp)[] = [ // A node's log, polled once a second by whoever has that node's page open. // A regex because the id is in the path. /^\/api\/nodes\/[^/]+\/logs$/, + // The preview poll an open pane view runs (spec §7): the exact path, so + // the open/watch acts under it keep their request lines. + /^\/api\/subshells\/[^/]+\/preview$/, /^\/ws(\/|$)/, // Minted once per live-feed connect, and the feed RETRIES on a bounded // backoff (spec 2026-09-19 § 5) — so a tab whose session has gone while the diff --git a/apps/server/api/src/services/nodes/node-launcher.ts b/apps/server/api/src/services/nodes/node-launcher.ts index d7c534805..b6c758f36 100644 --- a/apps/server/api/src/services/nodes/node-launcher.ts +++ b/apps/server/api/src/services/nodes/node-launcher.ts @@ -58,6 +58,14 @@ export interface LaunchPlan { * (createSubshell stays strict). */ bestEffortLog?: boolean; + /** + * The virtual display size to request (pane-preview spec §6). The agent + * node allocates the Xvfb + screencast pair before the pane starts (and + * refuses the launch by name when its platform or binaries cannot host + * one); `LocalLauncher` ignores the member, since a control-plane pane has + * no allocation machinery yet. Absent on every plain launch. + */ + screen?: { width: number; height: number }; } /** diff --git a/apps/server/api/src/services/nodes/preview-refusals.ts b/apps/server/api/src/services/nodes/preview-refusals.ts new file mode 100644 index 000000000..ce31c6d66 --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-refusals.ts @@ -0,0 +1,75 @@ +/** + * The plane's mirror of the node-owned preview refusal words (pane-preview + * spec §6's naming rule: refusals surface NAMED, never reworded into + * silence). The words are authored on the node (`apps/node/agent/src/ + * preview/ports.ts`, `preview/display.ts`, `commands/preview-ports-exec.ts`) + * and arrive here verbatim inside `NodeRpcError.detail`; this module only + * pins the spellings and pairs each with the operator's remedy, the same + * shape `packages/subshell-protocol/src/ssh-errors.ts` is for ssh refusals. + * + * Some words throw bare and some carry a colon suffix naming which resource + * ran out (`preview-display-missing-binaries: x11vnc`), so matching is by + * WORD or WORD-with-suffix, never a bare substring. + */ + +/** The ports scan refused: this platform has no /proc walk (spec §6). */ +export const PREVIEW_PORTS_UNSUPPORTED = "preview-ports-unsupported"; +/** The ports scan refused: the pane is not running on that node anymore. */ +export const PREVIEW_PORTS_PANE_GONE = "preview-ports-pane-gone"; +/** The node's own preview switch is off; the word carries the remedy. */ +export const PREVIEW_NODE_OFF = "preview-node-off: turn on Preview for this node"; +/** A screen launch refused: this platform hosts no X server stack. */ +export const PREVIEW_DISPLAY_UNSUPPORTED = "preview-display-unsupported"; +/** A screen launch refused; the thrown message names the binary. */ +export const PREVIEW_DISPLAY_MISSING_BINARIES = "preview-display-missing-binaries"; +/** A screen launch refused; the detail names what failed to come up. */ +export const PREVIEW_DISPLAY_NOT_READY = "preview-display-not-ready"; +/** The open executor refused: the named pane is gone. */ +export const PREVIEW_PANE_GONE = "preview-pane-gone"; +/** The open executor refused: the port is not a CDP endpoint. */ +export const PREVIEW_NOT_CDP = "not-cdp"; + +/** + * Does a node refusal's detail name `word`? True for the bare word and for + * the word with its suffix (`word: something`), so the pair survives the + * agent's richer messages while a substring hit inside another word cannot + * create a false match. + */ +export function detailNamesWord(detail: string, word: string): boolean { + return detail === word || detail.startsWith(`${word}: `); +} + +/** A node switch-off refusal's detail: the bare prefix or the full remedy string. */ +export function detailIsNodeOff(detail: string): boolean { + return detailNamesWord(detail, "preview-node-off"); +} + +/** + * The screen-launch remedies, keyed by word. `launch` (Task 9's create path) + * renders these as 400s that name both the refusal and the next step; the + * node's own words ride along so the operator can grep them. + */ +export const SCREEN_LAUNCH_REMEDIES: Readonly> = { + [PREVIEW_NODE_OFF]: + "Preview is off for that node, so its panes cannot get a virtual screen. Turn on Preview for this node (the node's settings row), then launch again.", + [PREVIEW_DISPLAY_UNSUPPORTED]: + "That node's platform hosts no X server stack, so a screen pane cannot start there. Use a Linux node, or launch without a screen.", + [PREVIEW_DISPLAY_MISSING_BINARIES]: + "That node is missing one of the display binaries (Xvfb, x11vnc, xauth), so a screen pane cannot start there. Install them on that machine, or launch without a screen.", + [PREVIEW_DISPLAY_NOT_READY]: + "That node's virtual display did not come up inside the launch budget, so the screen pane was refused. Try again, or launch without a screen.", +}; + +/** + * The remedy for a screen launch the NODE refused: the paired sentence when + * the word is known (plus the node's own detail, verbatim), null for detail + * this mirror does not speak. + */ +export function screenLaunchRemedy(detail: string): string | null { + for (const [word, remedy] of Object.entries(SCREEN_LAUNCH_REMEDIES)) { + if (detail === word || detail.startsWith(`${word}: `)) { + return detail === word ? remedy : `${remedy} (${detail})`; + } + } + return null; +} diff --git a/apps/server/api/src/services/nodes/remote-launcher.ts b/apps/server/api/src/services/nodes/remote-launcher.ts index 407c632e7..24058a9d2 100644 --- a/apps/server/api/src/services/nodes/remote-launcher.ts +++ b/apps/server/api/src/services/nodes/remote-launcher.ts @@ -2,8 +2,10 @@ import { buildSshConfigPath, type HarnessPlugin } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, + type NodePreviewScreenWire, type NodeProbeEntry, parseNodeCaptureResult, + parseNodeLaunchResult, parseNodeLogReadResult, parseNodePaneCursorResult, parseNodePaneSizeResult, @@ -12,8 +14,11 @@ import { parseNodePromptDeliver, parseNodeStatDirResult, } from "@internal/subshell-protocol"; +import { HttpError } from "@/api/auth-guard.js"; import type { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { screenLaunchRemedy } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { detectOnNodeBestEffort, readAgentInventory } from "./inventory.js"; import { LOG_TAIL_BYTES, tailLinesFromWindowText } from "./log-tail.js"; @@ -92,6 +97,13 @@ export interface RemoteLauncherDeps { * caught by the caller — the kick is total. */ detect?: (nodeId: string) => void; + /** + * Ack-echo consumer for a screen launch (default + * `getPreviewBroker().onDisplayOpened`): when the node's `launch` ack names + * the display it allocated, that pairing is the preview broker's audit + * fact. Injected so a launcher test never opens the broker. + */ + onDisplayOpened?: (nodeId: string, paneId: string, screen: NodePreviewScreenWire) => void; } /** @@ -309,15 +321,44 @@ export class RemoteLauncher implements NodeLauncher { // `ssh: undefined` on a plain plan (the frame's parser refuses an // explicit-undefined optional; the mcp comment above is the precedent). ...(plan.ssh ? { ssh: { configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent } } : {}), + // The screen request (pane-preview §6). Conditional spread like ssh's: + // a plain launch's frame carries no member, byte-identical to the + // pre-preview wire the node's parser still requires. + ...(plan.screen ? { screen: plan.screen } : {}), }; + let data: unknown; try { - await this.#send(cmd, LAUNCH_TIMEOUT_MS); + data = await this.#send(cmd, LAUNCH_TIMEOUT_MS); } catch (err) { if (err instanceof NodeRpcError && err.code === "failed" && BINARY_MISSING_RE.test(err.message)) { this.#kickDetect(); } + // A screen the NODE refused: the node owns the refusal word, this + // mirror pairs it with the operator's remedy and renders a 400 that + // names both (the create path's preflight covers only the switch; + // platform, binaries, and a display that never came up are the node's + // to see). Unknown detail rethrows untouched, the established posture. + if (err instanceof NodeRpcError && err.code === "failed" && err.detail !== undefined) { + const remedy = screenLaunchRemedy(err.detail); + if (remedy !== null) throw new HttpError(400, remedy); + } throw err; } + const ack = parseNodeLaunchResult(data); + if (ack === null) throw this.#malformed("launch"); + if (ack.screen) { + // The display exists the moment the ack says so; the audit is a + // courtesy record and must not un-launch a live pane by throwing. + try { + (this.#deps.onDisplayOpened ?? ((n, p, s) => getPreviewBroker().onDisplayOpened(n, p, s)))( + this.#nodeId, + plan.id, + ack.screen, + ); + } catch (err) { + logger.warn(`preview display audit failed for pane ${plan.id}: ${String(err)}`); + } + } } /** diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index e5c10c9cd..8286158a8 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -383,6 +383,7 @@ export class SubshellManagerService { extraPaneEnv, subshellId, workspaceId, + screen, }: { /** Explicit permission ceiling of the creating credential. */ systemKeyPermissions?: ApiKeyPermission[] | null; @@ -467,6 +468,14 @@ export class SubshellManagerService { * the truth rather than a fiction. */ workspaceId?: string; + /** + * The virtual display size to request with the launch (pane-preview spec + * §6): forwarded verbatim to the launcher, which ships it on the frame; + * the AGENT node allocates (or refuses by name), `LocalLauncher` ignores + * it. The service preflighted the node's Preview switch before arriving + * here, so no row exists unless the plane already said yes. + */ + screen?: { width: number; height: number }; }): Promise<{ id: string; tmuxSocket: string; apiKey: string; promptDelivered: boolean }> { if (subshellId !== undefined && !/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) { throw new Error("createSubshell: subshellId is outside the id grammar"); @@ -641,6 +650,9 @@ export class SubshellManagerService { // The composed ssh member (spec 2026-10-07 decision 4). Conditional // spread: absent on every non-ssh launch, byte-identical plan there. ...(ssh ? { ssh: { configPath: ssh.configPath, fileContent: ssh.fileContent } } : {}), + // Conditional too (pane-preview §6): a plain launch's frame carries + // no screen member at all, byte-identical to the pre-preview plan. + ...(screen ? { screen } : {}), }); if (prompt?.trim()) { promptDelivered = await this.#deliverPrompt( diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index 983f65c55..3c4d467ac 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -38,6 +38,7 @@ import { probeQuiet, waitSentinel, } from "@/services/nodes/pane-exec.js"; +import { getPreviewEnabled } from "@/services/nodes/preview-enabled.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; import { getPreviewBroker } from "@/services/preview-broker.js"; @@ -450,6 +451,7 @@ export class SubshellsService extends BaseService { extraPaneEnv, subshellId, workspaceId, + screen, }: { /** Permission ceiling passed to the new pane credential. */ systemKeyPermissions?: ApiKeyPermission[] | null; @@ -511,6 +513,14 @@ export class SubshellsService extends BaseService { * row before anything spawns. */ workspaceId?: string; + /** + * A virtual display for the pane (pane-preview spec §6): the node to be + * allocates Xvfb + a screencast bridge at this size. The plane prefights + * the node's Preview switch BEFORE any row, token, or RPC; the node + * stays the authority on everything the plane cannot see (platform, + * binaries), and refuses the launch by name. + */ + screen?: { width: number; height: number }; }): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { // Lockdown is the FIRST question the instance asks of every create // (operator ask 2026-09-24): it answers valid bodies and invalid ones @@ -635,6 +645,19 @@ export class SubshellsService extends BaseService { 409, ); } + // The gated screen preflight (spec §6): a `screen` launch against a node + // whose Preview switch is off is refused HERE, before the row, the + // token, and the wire, with the same remedy the node itself names. The + // node's Task 7 gate stays the authority for every fact the plane's row + // cannot hold (platform, binaries). Placed in the service rather than + // the launcher so a refused create never creates a pane at all. + if (screen !== undefined && !(await getPreviewEnabled(resolvedNodeId))) { + throw new SubshellCreateError( + "bad_request", + "Preview is off for that node, so a screen pane cannot start there. Turn on Preview for this node (the node's settings row), or launch without a screen.", + 400, + ); + } // The deferred default (spec 2026-10-01 §2): the launch node's home, read // only from sources this request already has — the local host's homedir, // or the agent's reported `ready` facts. A node that has never reported a @@ -684,6 +707,7 @@ export class SubshellsService extends BaseService { ...(extraPaneEnv !== undefined ? { extraPaneEnv } : {}), ...(subshellId !== undefined ? { subshellId } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(screen !== undefined ? { screen } : {}), }) .catch(rethrowLaunchRefusal); // Feed the picker's Recents (and the new-subshell form's pre-fill) from From 5041d2ee6b6ae23a0e61f439e02e804a4409c603 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 02:36:26 -0700 Subject: [PATCH 042/139] fix(api): map watch timeout and unsupported, pin the screen-refusal legs --- .../__tests__/preview-routes-auth.test.ts | 223 +++++++++++++++++- .../src/api/subshells/preview-info.route.ts | 20 +- .../src/api/subshells/preview-watch.route.ts | 26 +- .../server/api/src/services/preview-broker.ts | 11 + 4 files changed, 268 insertions(+), 12 deletions(-) diff --git a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts index 5fcb7a36a..faeb47e77 100644 --- a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts @@ -13,6 +13,7 @@ import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { holdConnection, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; @@ -173,6 +174,30 @@ describe("preview REST surface (Task 9)", () => { return Number(r?.n ?? 0); } + /** + * ENABLED api keys owned by `id`. Revocation disables the row rather than + * deleting it (`revokeSubshellToken` -> `setApiKeyEnabled(…, false)`), so a + * rolled-back create must not change this count even though its key row + * still exists, disabled. + */ + function liveKeyCount(userId: string): number { + const row = authDatabase() + .prepare<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM apikey WHERE "referenceId" = ? AND enabled = 1`) + .get(userId); + return Number(row?.n ?? 0); + } + + /** The create-path rollback leaves the row terminated, not deleted: read its status. */ + async function paneStatusByName(userId: string, name: string): Promise { + const r = await db + .selectFrom("subshells") + .select("status") + .where("userId", "=", userId) + .where("name", "=", name) + .executeTakeFirst(); + return r?.status; + } + beforeAll(async () => { await setupAuthTables(); resetNodeRegistryForTests(); @@ -478,6 +503,49 @@ describe("preview REST surface (Task 9)", () => { expect(body.screen).toBeNull(); }); + it("an agent that predates the ports command answers the mac-leg shape, nodeEnabled TRUE", async () => { + // A plane-side `unsupported` (the connected agent predates + // preview_ports_get, so there is no `detail` to pass through) means + // exactly what the detail-bearing refusal means: the node is online and + // switched on, it just cannot scan. Same branch, same reason word. The + // seam throws the plane's own verdict because nothing older than the + // command vocabulary exists to script. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("unsupported", `node "${nodeId}" does not support preview_ports_get`, nodeId); + }, + }); + const res = await call("GET", `/${pane}/preview`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBe("preview-ports-unsupported"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("a node failure the plane cannot name still answers the composed node-refused reason", async () => { + // `failed` with no detail sits outside every named branch, so the + // composed `node-refused (failed)` is what a polling page reads. It is + // now DOCUMENTED in the schema description, which is what this row + // pins: the composition stays honest, and a named-word refactor that + // starts swallowing detail would flip this row red. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("failed", `node "${nodeId}" reported: something the wire did not name`, nodeId); + }, + }); + const body = await jsonBody(await call("GET", `/${pane}/preview`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-refused (failed)"); + expect(body.ports).toEqual([]); + }); + it("a display that died mid-pane passes screenLost named, paired with screen null", async () => { // Spec §9: the marker arrives from the node and passes through named, // never inferred. A route that swallowed it would render silence where @@ -626,13 +694,52 @@ describe("preview REST surface (Task 9)", () => { expect(body.message).toMatch(/offline|not connected|gone/i); }); + it("watch whose preview_open times out is a 409 naming a retry, never a 500", async () => { + // The node-state pre-gate cannot see this: a stale SPA POSTs a watch at + // a node that went slow after its last info poll, and the plain Error a + // timeout carries would render a 500. Mirror of open's timeout arm + // (same sentence shape, the named retry). The seam throws the plane's + // own verdict because waiting out the real RPC budget to prove a + // mapping is a test spending seconds it has not earned. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("timeout", `node "${nodeId}" did not answer preview_open in time`, nodeId); + }, + }); + const res = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/did not answer the preview watch in time/); + expect(body.message).toMatch(/try again/i); + }); + + it("watch against an agent that predates the preview command is a 409 naming the update, never a 500", async () => { + // The other half of the stale-SPA race: an older node answers the plane + // with `unsupported`, which carries no status either. Same 409 shape as + // open's, naming the remedy (update the node). + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("unsupported", `node "${nodeId}" does not support preview_open`, nodeId); + }, + }); + const res = await call("POST", `/${pane}/preview/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/does not support preview yet/); + expect(body.message).toMatch(/update the node/i); + }); + /* ---------------------------------------------------------------- */ /* the create path's screen field */ /* ---------------------------------------------------------------- */ /** Install the harness so the create path reaches its node resolution. */ - async function makeLaunchable(): Promise { - attachLive(); + async function makeLaunchable(overrides: ScriptedHandlers = {}): Promise { + attachLive(overrides); await nodes.applyInventory( nodeId, JSON.stringify([{ harnessId: "claude-code", installed: true, binaryPath: "/usr/bin/claude" }]), @@ -708,6 +815,118 @@ describe("preview REST surface (Task 9)", () => { expect(sim.countOf("launch")).toBe(0); }); + // The node-side half of the screen gate: refusals that arrive FROM the + // wire, which the service preflight cannot see (an older node, or a + // switch flipped mid-race). Each row's `echo` says whether the remedy + // matcher appends the node's own detail: it does only for a SUFFIXED form + // (the missing-binaries pair). The bare word and the node-off word match + // their keys exactly, and the node-off key already IS its remedy string, + // so neither is appended to. + const SCREEN_REFUSALS: ReadonlyArray = [ + ["preview-display-unsupported", /hosts no X server stack/, false], + ["preview-display-missing-binaries: x11vnc", /missing one of the display binaries/, true], + ["preview-node-off: turn on Preview for this node", /Turn on Preview for this node/i, false], + ]; + + it.each(SCREEN_REFUSALS)( + "a node-side screen refusal %s is a 400 with the remedy, and the create rolls back", + async (detail, remedy, echo) => { + // The DB switch stays ON (beforeEach set it), so the preflight passes + // and the refusal only ever comes from the scripted launch itself. + // One `launch` flying is the contrast with the preflight row's zero. + await makeLaunchable({ launch: () => new Error(detail) }); + const name = `pv refused ${detail.slice(0, 22)}`; + const keysBefore = liveKeyCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name, screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(remedy); + if (echo) { + // The node's word rides along in the message so an operator can + // grep exactly what the agent said. + expect(body.message).toContain(detail); + } + expect(sim.countOf("launch")).toBe(1); + // The manager's rollback: best-effort kill, the row ends TERMINATED + // (it was written before the wire, so the row stays, retired), and the + // pane's token stops being an enabled key. + expect(await paneStatusByName(ownerId, name)).toBe("terminated"); + expect(liveKeyCount(ownerId)).toBe(keysBefore); + }, + ); + + it("a malformed launch ack rolls the create back and answers the plain error, never a false 200", async () => { + // `{ screen: "junk" }` fails the launch grammar: the plane cannot say + // what screen the pane has, so it must not claim the pane launched. The + // posture is the plain Error (500 through the handler) plus the full + // rollback; pinned so a rewording of the ack path can neither turn the + // breach into a success nor leak a live row with a live token. + await makeLaunchable({ launch: () => ({ screen: "junk" }) }); + const name = "pv junk ack"; + const keysBefore = liveKeyCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name, screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(500); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INTERNAL_SERVER_ERROR); + expect(await paneStatusByName(ownerId, name)).toBe("terminated"); + expect(liveKeyCount(ownerId)).toBe(keysBefore); + }); + + it("the launch ack's screen echo arms the info answer before the ports scan reports it", async () => { + // The VNC bridge is up when the ack flies; the next preview_ports_get + // may not know the screen yet, so the broker's launch memory answers + // the info poll inside that window. The memory holds the ack verbatim, + // which is also the spy that onDisplayOpened fired. + await makeLaunchable({ + launch: () => ({ screen: { display: ":90", rfbPort: 5910 } }), + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv ack echo", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const created = String((await jsonBody(res)).id); + createdSubshellIds.push(created); + expect(getPreviewBroker().displayForPane(created)).toEqual({ + nodeId, + screen: { display: ":90", rfbPort: 5910 }, + }); + const body = await jsonBody(await call("GET", `/${created}/preview`, { cookie: ownerCookie })); + expect(body.screen).toEqual({ display: ":90", rfbPort: 5910 }); + expect(body.screenLost).toBeUndefined(); + }); + + it("the launch memory never masks a screenLost the node reported", async () => { + // A death the node reported outranks the plane's memory of the birth: + // once the scan says the display process died, info keeps answering + // screen null with the named marker, not the remembered screen. Without + // this leg the overlay could show a dead screen forever. + await makeLaunchable({ + launch: () => ({ screen: { display: ":90", rfbPort: 5910 } }), + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv ack then lost", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const created = String((await jsonBody(res)).id); + createdSubshellIds.push(created); + // Same node identity, new answers: the scan now reports the x11vnc died. + attachLive({ + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null, screenLost: "x11vnc" }), + }); + const body = await jsonBody(await call("GET", `/${created}/preview`, { cookie: ownerCookie })); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBe("x11vnc"); + }); + /* ---------------------------------------------------------------- */ /* the pane's own exit tears the preview down */ /* ---------------------------------------------------------------- */ diff --git a/apps/server/api/src/api/subshells/preview-info.route.ts b/apps/server/api/src/api/subshells/preview-info.route.ts index 788a47895..44cf4f567 100644 --- a/apps/server/api/src/api/subshells/preview-info.route.ts +++ b/apps/server/api/src/api/subshells/preview-info.route.ts @@ -55,7 +55,7 @@ const PreviewInfoResponseSchema = t.Object({ reason: t.Optional( t.String({ description: - "Why nodeEnabled is false (preview-disabled, node-held, node-too-old, node-offline, or the node's named refusal); also carries the ports refusal word when nodeEnabled is true and the node itself refused the scan", + "Why nodeEnabled is false: a plane word (preview-disabled, node-held, node-too-old, node-offline, node-timeout), the composed node-refused (code) or preview-refused for what the plane cannot name, or the node's own refusal word passed through verbatim; also carries the ports refusal word when nodeEnabled is true and the node itself refused the scan", }), ), }); @@ -71,6 +71,12 @@ function mapPortsFailure(err: unknown): { nodeEnabled: boolean; reason: string } // The agent's own refusal, passed through NAMED (the wire-constant // habit). `preview-ports-unsupported` keeps `nodeEnabled: true`: the // node is online and switched on, it just has no /proc to read. + if (err.code === "unsupported") { + // The PLANE's own verdict that the connected agent predates the + // command: it carries no `detail`, but it says exactly what the + // detail-bearing refusal says. Same branch, same reason word. + return { nodeEnabled: true, reason: PREVIEW_PORTS_UNSUPPORTED }; + } if (err.detail !== undefined && err.code === "failed") { if (err.detail === PREVIEW_PORTS_UNSUPPORTED) return { nodeEnabled: true, reason: PREVIEW_PORTS_UNSUPPORTED }; return { nodeEnabled: false, reason: err.detail }; @@ -103,11 +109,19 @@ export const previewInfoRoute = new Elysia() return { ...EMPTY_PORTS, ...nodeState }; } try { - const answer = await getPreviewBroker().getPorts(nodeId, params.id); + const broker = getPreviewBroker(); + const answer = await broker.getPorts(nodeId, params.id); + // The launch ack names the screen before the node's own scan + // reports it (the bridge is up when the ack flies; a scan can lag + // it). The plane's memory may fill a null screen, but it never + // masks a live answer and never overwrites a named screenLost: a + // death the node reported outranks the plane's memory of the birth. + const remembered = + answer.screen === null && answer.screenLost === undefined ? broker.displayForPane(params.id) : null; return { ports: answer.ports, cdpPort: answer.cdpPort, - screen: answer.screen, + screen: remembered && remembered.nodeId === nodeId ? remembered.screen : answer.screen, ...(answer.screenLost === undefined ? {} : { screenLost: answer.screenLost }), nodeEnabled: true, }; diff --git a/apps/server/api/src/api/subshells/preview-watch.route.ts b/apps/server/api/src/api/subshells/preview-watch.route.ts index dfe063084..0d8c29324 100644 --- a/apps/server/api/src/api/subshells/preview-watch.route.ts +++ b/apps/server/api/src/api/subshells/preview-watch.route.ts @@ -87,13 +87,25 @@ export const previewWatchRoute = new Elysia() // SPA a 409 that says which). throw new HttpError(409, `${err.code}: ${err.message}`); } - if (err instanceof NodeRpcError && err.code === "failed" && err.detail !== undefined) { - // The node's refusal of the screencast open (`not-cdp`, pane gone, - // switch off mid-call): pass the word through named. - throw new HttpError(409, `That node refused the watch: ${err.detail}`); - } - if (err instanceof NodeRpcError && err.code === "offline") { - throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + if (err instanceof NodeRpcError) { + if (err.code === "failed" && err.detail !== undefined) { + // The node's refusal of the screencast open (`not-cdp`, pane + // gone, switch off mid-call): pass the word through named. + throw new HttpError(409, `That node refused the watch: ${err.detail}`); + } + if (err.code === "offline") { + throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + } + if (err.code === "timeout") { + // The pre-gate cannot see a node that went slow after the page's + // last info poll, and a timeout carries no status: left alone it + // would render a 500. Mirrors open's timeout arm. + throw new HttpError(409, "The node did not answer the preview watch in time. Try again in a moment."); + } + // `unsupported`: the connected agent predates the preview command + // (a stale SPA POSTs a watch to an older node). Same remedy as + // open's: update the node. + throw new HttpError(409, "That node's agent does not support preview yet. Update the node, then retry."); } throw err; } diff --git a/apps/server/api/src/services/preview-broker.ts b/apps/server/api/src/services/preview-broker.ts index e6abae515..60eccbf93 100644 --- a/apps/server/api/src/services/preview-broker.ts +++ b/apps/server/api/src/services/preview-broker.ts @@ -191,6 +191,13 @@ export interface PreviewBroker { sessionForPane(paneId: string): PreviewSession | null; getPorts(nodeId: string, paneId: string): Promise; onDisplayOpened(nodeId: string, paneId: string, screen: NodePreviewScreenWire): void; + /** + * The screen the plane remembers from this pane's launch ack, or null. + * The ack names the display before the node's own ports scan reports it, + * and Task 9's info route fills that window from here (a reported + * `screenLost` still outranks the memory). + */ + displayForPane(paneId: string): { nodeId: string; screen: NodePreviewScreenWire } | null; onDisplayClosed(paneId: string, reason: PreviewCloseReason): Promise; closeForPane(paneId: string, reason: PreviewCloseReason): Promise; closeAllForNode(nodeId: string, reason: PreviewCloseReason): Promise; @@ -894,6 +901,10 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { ); }, + displayForPane(paneId) { + return displaysByPane.get(paneId) ?? null; + }, + async onDisplayClosed(paneId, reason) { const entry = displaysByPane.get(paneId); if (!entry) return; // double-close is idempotent; a pane that never displayed audits nothing From d606f01f592f9bbc222c65cf15576b76f4ac4dfe Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 03:42:29 -0700 Subject: [PATCH 043/139] feat(server): same-origin /pv proxy with cookie jar, CSP carve-out, and WS tunnel --- apps/server/api/package.json | 3 +- .../__tests__/request-log-ignore.test.ts | 11 + apps/server/api/src/plugins/context.plugin.ts | 6 + .../src/preview/__tests__/pv-proxy.test.ts | 688 ++++++++++++++++++ apps/server/api/src/preview/lane-duplex.ts | 78 ++ apps/server/api/src/preview/lane-http.ts | 232 ++++++ apps/server/api/src/preview/lane-wire.ts | 125 ++++ apps/server/api/src/preview/lane-ws-client.ts | 188 +++++ apps/server/api/src/preview/pv-http.ts | 253 +++++++ apps/server/api/src/preview/pv-ws.ts | 296 ++++++++ apps/server/api/src/server.ts | 13 + bun.lock | 15 +- 12 files changed, 1904 insertions(+), 4 deletions(-) create mode 100644 apps/server/api/src/preview/__tests__/pv-proxy.test.ts create mode 100644 apps/server/api/src/preview/lane-duplex.ts create mode 100644 apps/server/api/src/preview/lane-http.ts create mode 100644 apps/server/api/src/preview/lane-wire.ts create mode 100644 apps/server/api/src/preview/lane-ws-client.ts create mode 100644 apps/server/api/src/preview/pv-http.ts create mode 100644 apps/server/api/src/preview/pv-ws.ts diff --git a/apps/server/api/package.json b/apps/server/api/package.json index a49626810..215465e45 100644 --- a/apps/server/api/package.json +++ b/apps/server/api/package.json @@ -63,7 +63,8 @@ "sanitize-filename": "1.6.4", "serialize-error": "13.0.1", "tar-stream": "3.2.1", - "web-push": "3.6.7" + "web-push": "3.6.7", + "ws": "8.22.0" }, "devDependencies": { "@elysiajs/eden": "1.4.9", diff --git a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts index c5e04eddc..12cf6cf99 100644 --- a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts +++ b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts @@ -62,4 +62,15 @@ describe("REQUEST_LOG_IGNORE", () => { // A route that happens to begin with those letters is a different thing. expect(ignored("/wsx")).toBe(false); }); + + it("ignores every /pv/ proxy path, because the session secret rides the path", () => { + // A request line is a path, and for the preview proxy the path IS the + // credential (spec 2026-10-21 §10): the 32-hex session ref is the only + // secret standing between a log reader and anyone's forwarded pane port. + // A debug log that records the request lines records every live link. + expect(ignored("/pv/4f9c1a2b3d4e5f60718293a4b5c6d7e8/index.html")).toBe(true); + expect(ignored("/pv/4f9c1a2b3d4e5f60718293a4b5c6d7e8/")).toBe(true); + // Not merely the prefix shape: any depth, any verb's path. + expect(ignored("/pv/deadbeefdeadbeefdeadbeefdeadbeef/ws/hmr")).toBe(true); + }); }); diff --git a/apps/server/api/src/plugins/context.plugin.ts b/apps/server/api/src/plugins/context.plugin.ts index 2eec81510..2637d284a 100644 --- a/apps/server/api/src/plugins/context.plugin.ts +++ b/apps/server/api/src/plugins/context.plugin.ts @@ -42,6 +42,12 @@ export const REQUEST_LOG_IGNORE: (string | RegExp)[] = [ // The preview poll an open pane view runs (spec §7): the exact path, so // the open/watch acts under it keep their request lines. /^\/api\/subshells\/[^/]+\/preview$/, + // The /pv proxy: request lines carry the path, the path carries the + // 32-hex session ref, and the ref IS the credential (spec 2026-10-21 + // §10). A debug request line here would log a live link into someone's + // pane port. Same class as the setup-key rule in the API AGENTS.md log + // notes: paths that wear secrets never ride the log. + /^\/pv\//, /^\/ws(\/|$)/, // Minted once per live-feed connect, and the feed RETRIES on a bounded // backoff (spec 2026-09-19 § 5) — so a tab whose session has gone while the diff --git a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts new file mode 100644 index 000000000..46a6e8adc --- /dev/null +++ b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts @@ -0,0 +1,688 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as net from "node:net"; +import { + type NodeCommandBody, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LANE_QUEUE_MAX, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { pvHttpRoute } from "@/preview/pv-http.js"; +import { previewWsRoute } from "@/preview/pv-ws.js"; +import { createApp } from "@/server.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { originRegistry } from "@/services/trusted-origins.js"; + +/** + * The same-origin `/pv` proxy (pane-preview spec 2026-10-21 §6, Task 10). + * + * Two halves, one path: the HTTP rewrite half (`pv-http.ts`) and the WS + * tunnel half (`pv-ws.ts`). The session ref in the path IS the credential, + * so these tests stand the whole surface up the way production does: an + * in-proc `Bun.serve` target answering on plane loopback (a client that + * bypassed the lane would still connect, so every byte assertion + * rides the fake node's COUNT, not just the response), and the REAL broker + * over faked wire seams, exactly like `preview-broker.test.ts`. + * + * The HTTP-side requests drive `createApp()`, the production composition, + * through `app.handle`, so an unmounted proxy cannot pass this file while a + * module-level unit test would; the WS refusals probe the same mounted app + * over a raw socket (a rejected upgrade never becomes a WebSocket client + * event, so the answer is an HTTP status line), and the tunnels run against + * the real listener. + */ + +const NODE = "n-pv"; +const PANE = "p-pv-pane"; +const USER = "u-pv"; +const DOC_ORIGIN = "http://203.0.113.77:3080"; +const PLUGIN = "pv-proxy-test"; + +/* ------------------------------------------------------------------ */ +/* the fake node */ +/* ------------------------------------------------------------------ */ + +/** + * A ~50-line node: it acks every `preview_open` (the broker's probe open and + * each lane open ride the SAME wire command, so the fake keys "dialed" on the + * first `in` byte, which only a real lane ever sends), pipes each lane's `in` + * frames to a fresh loopback socket pointed at the port the open named, + * streams the target's replies back as `out` frames through the real broker, + * and volunteers `target-gone` when a target socket dies. It COUNTS every + * byte it writes toward a target. That count is the lane-bypass guard: a + * proxy that dialed plane loopback directly would still satisfy a naive + * byte-equality check while leaving this at zero. + */ +interface FakeNode { + piped: number; + /** Every ref this fake served a lane for (lanes that actually sent bytes). */ + dialOrder: string[]; + /** Lane closes that reached this fake AFTER the lane did work (probe closes excluded). */ + closedLanes: { ref: string; reason: string }[]; + send(nodeId: string, cmd: NodeCommandBody): Promise; + linkSend(nodeId: string, frame: PreviewDataFrame): void; + /** Ports answer: the display screen is reported only when the test asks. */ + reportScreenRfbPort: number | null; +} + +function makeFakeNode(): FakeNode { + const lanes = new Map(); + const fake: FakeNode = { + piped: 0, + dialOrder: [], + closedLanes: [], + reportScreenRfbPort: null, + async send(_nodeId, cmd) { + if (cmd.type === "preview_open") { + lanes.set(cmd.ref, { port: cmd.port, sock: null, held: [] }); + return { ref: cmd.ref, pending: true }; + } + if (cmd.type === "preview_close") { + const lane = lanes.get(cmd.ref); + if (lane) { + lane.sock?.destroy(); + lanes.delete(cmd.ref); + if (lane.sock) fake.closedLanes.push({ ref: cmd.ref, reason: cmd.reason }); + } + return { ref: cmd.ref, closed: true }; + } + if (cmd.type === "preview_ports_get") { + return { + ports: [], + cdpPort: null, + screen: fake.reportScreenRfbPort ? { display: ":7", rfbPort: fake.reportScreenRfbPort } : null, + }; + } + throw new Error(`pv proxy fake node: unexpected command ${cmd.type}`); + }, + linkSend(_nodeId, frame) { + if (frame.type !== "preview_data" || frame.dir !== "in") return; + const lane = lanes.get(frame.ref); + if (!lane) return; + const bytes = Buffer.from(frame.blob, "base64"); + if (bytes.byteLength === 0) return; + fake.piped += bytes.byteLength; + if (!lane.sock) { + fake.dialOrder.push(frame.ref); + lane.sock = net.connect(lane.port, "127.0.0.1", () => { + for (const h of lane.held.splice(0)) lane.sock?.write(h); + }); + lane.sock.on("data", (d: Buffer) => { + getPreviewBroker().onNodeLaneFrame(NODE, { + type: "preview_data", + ref: frame.ref, + dir: "out", + blob: d.toString("base64"), + }); + }); + lane.sock.on("close", () => { + if (lanes.delete(frame.ref)) { + void getPreviewBroker().onNodeReportedClose(NODE, frame.ref, "target-gone"); + } + }); + lane.sock.on("error", () => lane.sock?.destroy()); + } + if (lane.sock.connecting || !lane.sock.writable) { + lane.held.push(bytes); + return; + } + lane.sock.write(bytes); + }, + }; + return fake; +} + +/* ------------------------------------------------------------------ */ +/* the in-proc target */ +/* ------------------------------------------------------------------ */ + +/** What the target last saw, per lane: the assertions read this, not a guess. */ +interface TargetSeen { + method: string; + url: string; + host: string | null; + cookie: string | null; + body: string; +} +let lastSeen: TargetSeen = { method: "", url: "", host: null, cookie: null, body: "" }; +let targetPort = 0; +let target: Bun.Server<{ path: string }> | undefined; + +/** Raw byte-echo TCP target for `raw` mode (an x11vnc stand-in). */ +let rfbPort = 0; +const rfbLog: Buffer[] = []; +const rfbAcceptList: net.Socket[] = []; +let rfbServer: net.Server | undefined; +/** TCP target that accepts and NEVER answers: stalls the http-mode handshake. */ +let silentPort = 0; +const silentSockets: net.Socket[] = []; +let silentServer: net.Server | undefined; + +beforeAll(async () => { + await ensureMigratedTestDb(); + target = Bun.serve<{ path: string }>({ + port: 0, + hostname: "127.0.0.1", + async fetch(req, server) { + const url = new URL(req.url); + if (url.pathname === "/hmr" || url.pathname.startsWith("/hmr")) { + if (server.upgrade(req, { data: { path: url.pathname } })) return undefined; + return new Response("upgrade refused", { status: 400 }); + } + lastSeen = { + method: req.method, + url: url.pathname + url.search, + host: req.headers.get("host"), + cookie: req.headers.get("cookie"), + body: req.method === "GET" || req.method === "HEAD" ? "" : await req.text(), + }; + if (url.pathname === "/echo") { + return Response.json(lastSeen); + } + if (url.pathname === "/cookie") { + return new Response("ok", { + headers: [ + ["set-cookie", "sid=9; Path=/deep; HttpOnly; Max-Age=60"], + ["set-cookie", "theme=dark; Path=/app; Domain=example.com; Secure"], + ], + }); + } + if (url.pathname === "/csp") { + return new Response("ok", { + headers: [ + ["content-security-policy", "default-src 'self'; frame-ancestors 'none'; img-src *"], + [ + "content-security-policy-report-only", + "default-src 'self'; frame-ancestors https://evil; script-src 'self'", + ], + ], + }); + } + if (url.pathname === "/redirect") { + return new Response(null, { status: 302, headers: { location: "/foo?x=1" } }); + } + return new Response("hello-target", { status: 200 }); + }, + websocket: { + open() {}, + message(ws, msg) { + // Echo verbatim: Bun hands bytes as Buffer and text as string, and + // re-sending either preserves the frame type. + ws.send(msg as string | Uint8Array); + }, + close() {}, + }, + }); + targetPort = target.port as number; + + rfbServer = net.createServer((sock) => { + rfbAcceptList.push(sock); + rfbLog.length = 0; + sock.on("data", (d: Buffer) => { + rfbLog.push(Buffer.from(d)); + sock.write(Buffer.from(d).reverse()); // echo reversed: direction is visible in the bytes + }); + }); + await new Promise((res, rej) => { + if (!rfbServer) return rej(new Error("no rfb server")); + rfbServer.listen(0, "127.0.0.1", res); + }); + rfbPort = (rfbServer.address() as net.AddressInfo).port; + + silentServer = net.createServer((sock) => { + silentSockets.push(sock); // accepted, then silence: the RFB/dev handshake stalls + }); + await new Promise((res, rej) => { + if (!silentServer) return rej(new Error("no silent server")); + silentServer.listen(0, "127.0.0.1", res); + }); + silentPort = (silentServer.address() as net.AddressInfo).port; +}); + +afterAll(async () => { + target?.stop(); + rfbServer?.close(); + for (const s of silentSockets) s.destroy(); + silentServer?.close(); + originRegistry().clearPlugin(PLUGIN); + shutdownPreviewBrokerForTests(); +}); + +/* ------------------------------------------------------------------ */ +/* per-test broker + session */ +/* ------------------------------------------------------------------ */ + +let fake: FakeNode; + +function freshBroker(nextFake: FakeNode): void { + resetForTests({ send: nextFake.send.bind(nextFake), linkSend: nextFake.linkSend.bind(nextFake) }); +} + +async function openSession(port: number): Promise { + const { ref } = await getPreviewBroker().openForward({ viewerUserId: USER, paneId: PANE, nodeId: NODE, port }); + return ref; +} + +beforeEach(() => { + fake = makeFakeNode(); + freshBroker(fake); + lastSeen = { method: "", url: "", host: null, cookie: null, body: "" }; +}); + +afterEach(() => { + originRegistry().clearPlugin(PLUGIN); + for (const s of rfbAcceptList) s.destroy(); + rfbAcceptList.length = 0; + for (const s of silentSockets) s.destroy(); + silentSockets.length = 0; +}); + +/** Drive one request through the PRODUCTION composition. */ +async function proxyFetch(path: string, init?: RequestInit): Promise { + const app = createApp(); + return app.handle(new Request(`http://localhost:3080${path}`, init)); +} + +/* ------------------------------------------------------------------ */ +/* raw-socket upgrade probe */ +/* ------------------------------------------------------------------ */ + +interface UpgradeProbe { + status: number; + raw: string; +} + +/** + * Writes a WebSocket upgrade by hand. A REFUSED upgrade is an HTTP answer the + * browser's WebSocket client only sees as a failed connection, so the status + * line and body can only be read off the socket. + */ +async function probeUpgrade(port: number, path: string, origin?: string): Promise { + return new Promise((resolve, reject) => { + const sock = net.connect(port, "127.0.0.1", () => { + sock.write( + `GET ${path} HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + (origin ? `Origin: ${origin}\r\n` : "") + + "\r\n", + ); + }); + let raw = ""; + let settle: ReturnType | undefined; + const timeout = setTimeout(() => { + sock.destroy(); + reject(new Error(`upgrade probe timed out; saw: ${raw.slice(0, 200)}`)); + }, 4_000); + sock.on("data", (d) => { + raw += d.toString("latin1"); + // Status answers complete when the head is in and the body has settled; + // 101 has no body, refusals get a beat to flush theirs. + if (raw.includes("101 Switching Protocols")) { + clearTimeout(timeout); + sock.destroy(); + resolve({ status: 101, raw }); + } else if (raw.includes("\r\n\r\n") && !settle) { + settle = setTimeout(() => { + clearTimeout(timeout); + sock.destroy(); + resolve({ status: Number(raw.match(/^HTTP\/1\.\d (\d{3})/)?.[1] ?? 0), raw }); + }, 150); + } + }); + sock.on("error", (err) => { + clearTimeout(timeout); + if (settle) clearTimeout(settle); + reject(err); + }); + }); +} + +/* ------------------------------------------------------------------ */ +/* the HTTP rewrite half */ +/* ------------------------------------------------------------------ */ + +describe("pv-http: the same-origin rewrite", () => { + it("forces Path on every Set-Cookie and preserves the other attributes", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/cookie`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + const cookies = res.headers.getSetCookie(); + expect(cookies).toHaveLength(2); + expect(cookies[0]).toContain("sid=9"); + expect(cookies[0]).toContain(`Path=/pv/${ref}/`); + expect(cookies[0]).not.toContain("Path=/deep"); + expect(cookies[0]).toContain("HttpOnly"); + expect(cookies[0]).toContain("Max-Age=60"); + expect(cookies[1]).toContain("theme=dark"); + expect(cookies[1]).toContain(`Path=/pv/${ref}/`); + expect(cookies[1]).toContain("Secure"); + }); + + it("never forwards the browser's Cookie; the jarred cookie rides a fresh lane's request", async () => { + const ref = await openSession(targetPort); + // Request one: the browser's own cookie must NOT reach the target, and + // the target's Set-Cookie lands in the jar. + const first = await proxyFetch(`/pv/${ref}/echo`, { headers: { cookie: "plane=supersecret" } }); + expect(first.status).toBe(200); + expect(((await first.json()) as TargetSeen).cookie).toBeNull(); + const setOne = await proxyFetch(`/pv/${ref}/cookie`); + expect(setOne.headers.getSetCookie().length).toBe(2); + // Request two on the SAME session: no Cookie header, but the jar carries + // the pairs. And it rode a FRESH lane: each of the four proxy requests + // dialed its own, so a lane quietly reused between requests fails here + // even if the jar itself works. + const second = await proxyFetch(`/pv/${ref}/echo`); + const seen = (await second.json()) as { cookie: string | null }; + expect(seen.cookie).toBe("sid=9; theme=dark"); + // A DIFFERENT session's proxy path carries nothing. + const ref2 = await openSession(targetPort); + const foreign = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as { cookie: string | null }; + expect(foreign.cookie).toBeNull(); + // echo, cookie, jar-echo, foreign-echo: four requests, four distinct lanes. + expect(fake.dialOrder).toHaveLength(4); + expect(new Set(fake.dialOrder).size).toBe(4); + }); + + it("a session reopened after closeForPane starts with an empty jar", async () => { + const ref = await openSession(targetPort); + await proxyFetch(`/pv/${ref}/cookie`); + await getPreviewBroker().closeForPane(PANE, "pane-gone"); + // The dead session is now unaddressable, and the fresh one has a clean jar. + expect((await proxyFetch(`/pv/${ref}/echo`)).status).toBe(404); + const ref2 = await openSession(targetPort); + const seen = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as { cookie: string | null }; + expect(seen.cookie).toBeNull(); + }); + + it("sets Host to the loopback literal the node dials", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`); + expect(((await res.json()) as TargetSeen).host).toBe(`127.0.0.1:${targetPort}`); + }); + + it("carries the method and the body through", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`, { method: "POST", body: "lane-payload" }); + const seen = (await res.json()) as TargetSeen; + expect(seen.method).toBe("POST"); + expect(seen.body).toBe("lane-payload"); + }); + + it("keeps two of three CSP directives and drops frame-ancestors from both flavors", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/csp`); + const csp = res.headers.get("content-security-policy") ?? ""; + const report = res.headers.get("content-security-policy-report-only") ?? ""; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("img-src *"); + expect(csp).not.toContain("frame-ancestors"); + expect(report).toContain("default-src 'self'"); + expect(report).toContain("script-src 'self'"); + expect(report).not.toContain("frame-ancestors"); + }); + + it("prefixes a relative Location with the session's proxy path", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/redirect`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`/pv/${ref}/foo?x=1`); + }); + + it("answers an unknown session ref with a 404 that leaks nothing", async () => { + const ref = await openSession(targetPort); + const ghost = ref.replace(/^.{6}/, "000000"); + const res = await proxyFetch(`/pv/${ghost}/anything/at/all`); + expect(res.status).toBe(404); + const body = await res.text(); + expect(body).not.toContain(NODE); + expect(body).not.toContain(PANE); + expect(body).not.toContain(ref); + expect(body.toLowerCase()).not.toContain("pane"); + }); + + it("answers a lane-cap refusal with a 503 that names preview-lane-over-cap", async () => { + const ref = await openSession(targetPort); + // Fill the pane's pool outside the proxy. The per-pane lanes are live + // and awaited; the queue waiters can only resolve once a live lane + // closes, so they are PARKED rather than awaited (awaiting one would + // await the very cap condition this test asks the proxy about). A + // parked rejection is the broker's reset word, which teardown may raise; + // catching it is the test's job, not a swallow. + const held: string[] = []; + for (let i = 0; i < PREVIEW_FORWARD_SESSIONS_PER_PANE; i += 1) { + held.push((await getPreviewBroker().openLaneForConnection(ref)).laneRef); + } + const parked: Promise[] = []; + for (let i = 0; i < PREVIEW_LANE_QUEUE_MAX; i += 1) { + parked.push( + getPreviewBroker() + .openLaneForConnection(ref) + .then( + ({ laneRef }) => void held.push(laneRef), + () => undefined, + ), + ); + } + const res = await proxyFetch(`/pv/${ref}/echo`); + expect(res.status).toBe(503); + expect(await res.text()).toContain("preview-lane-over-cap"); + // Closing a live lane drains one waiter into a fresh lane, which the + // sweep sees because `held` grows while it iterates. Two passes plus + // the waiter join, so every minted lane is closed and no double-close + // error escapes. + const sweep = async (): Promise => { + for (const laneRef of held) { + await getPreviewBroker() + .closeLaneForConnection(laneRef, "viewer-left") + .catch(() => undefined); + } + }; + await sweep(); + await Promise.all(parked); + await sweep(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the WS half: gates (refusals are HTTP, read off a raw socket) */ +/* ------------------------------------------------------------------ */ + +describe("pv-ws: the upgrade gates", () => { + let proxyPort = 0; + let stop: (() => void) | undefined; + + beforeAll(async () => { + const app = createApp(); + app.listen({ port: 0, hostname: "127.0.0.1" }); + proxyPort = app.server?.port ?? 0; + if (!proxyPort) throw new Error("the proxy did not report a bound port"); + stop = () => app.server?.stop(); + }); + afterAll(() => stop?.()); + + it("refuses a cross-site Origin and passes the plane origin, a registry origin, and a missing Origin", async () => { + const ref = await openSession(targetPort); + const planeOrigin = originRegistry().current()[0]; + expect(await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, "http://203.0.113.66:9999")).toMatchObject({ status: 403 }); + originRegistry().setPluginOrigins(PLUGIN, [DOC_ORIGIN]); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, planeOrigin)).status).toBe(101); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, DOC_ORIGIN)).status).toBe(101); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`)).status).toBe(101); + }); + + it("refuses an unknown session ref before the socket exists", async () => { + const ref = await openSession(targetPort); + const probe = await probeUpgrade(proxyPort, `/pv/${ref.replace(/^.{6}/, "000000")}/hmr`); + expect(probe.status).toBe(404); + }); + + it("refuses a lane-cap overflow upgrade with a 503 naming preview-lane-over-cap", async () => { + const ref = await openSession(targetPort); + // Same fill as the HTTP cap leg: live lanes awaited, queue waiters + // parked (see the comment there for why awaiting one is awaiting the + // cap itself). + const held: string[] = []; + for (let i = 0; i < PREVIEW_FORWARD_SESSIONS_PER_PANE; i += 1) { + held.push((await getPreviewBroker().openLaneForConnection(ref)).laneRef); + } + const parked: Promise[] = []; + for (let i = 0; i < PREVIEW_LANE_QUEUE_MAX; i += 1) { + parked.push( + getPreviewBroker() + .openLaneForConnection(ref) + .then( + ({ laneRef }) => void held.push(laneRef), + () => undefined, + ), + ); + } + const probe = await probeUpgrade(proxyPort, `/pv/${ref}/hmr`); + expect(probe.status).toBe(503); + expect(probe.raw).toContain("preview-lane-over-cap"); + const sweep = async (): Promise => { + for (const laneRef of held) { + await getPreviewBroker() + .closeLaneForConnection(laneRef, "viewer-left") + .catch(() => undefined); + } + }; + await sweep(); + await Promise.all(parked); + await sweep(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the WS half: tunnels */ +/* ------------------------------------------------------------------ */ + +describe("pv-ws: the tunnels", () => { + let proxyPort = 0; + let stop: (() => void) | undefined; + + beforeAll(async () => { + const app = createApp(); + app.listen({ port: 0, hostname: "127.0.0.1" }); + proxyPort = app.server?.port ?? 0; + if (!proxyPort) throw new Error("the proxy did not report a bound port"); + stop = () => app.server?.stop(); + }); + afterAll(() => stop?.()); + + it("round-trips binary and text byte-exactly in http mode, all through the lane", async () => { + const ref = await openSession(targetPort); + const payload = Buffer.from([0, 7, 128, 255, 61, 10]); + const before = fake.piped; + const got = await new Promise<{ binary: number[]; text: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + ws.binaryType = "arraybuffer"; + const out: { binary?: number[]; text?: string } = {}; + ws.onopen = () => { + ws.send(payload); + ws.send("plain text, not JSON"); + }; + ws.onmessage = (e) => { + if (e.data instanceof ArrayBuffer) out.binary = [...new Uint8Array(e.data)]; + else out.text = String(e.data); + if (out.binary && out.text) { + ws.close(); + resolve(out as { binary: number[]; text: string }); + } + }; + ws.onerror = (e) => reject(new Error(`tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error(`tunnel stalled; got ${JSON.stringify(out)}`)), 5_000); + }); + expect(got.binary).toEqual([...payload]); + expect(got.text).toBe("plain text, not JSON"); + // The bypass guard: the handshake AND both payloads rode the fake node. + // The in-proc target answers on plane loopback, so a client that had + // dialed around the lane would look identical while this sat at `before`. + expect(fake.piped).toBeGreaterThan(before); + }); + + it("pumps raw mode verbatim, answers as binary frames, and carries target-gone to the close event", async () => { + fake.reportScreenRfbPort = rfbPort; + const ref = await openSession(rfbPort); // the registered screen port => raw + expect(getPreviewBroker().sessionFor(ref)?.mode).toBe("raw"); + const hello = Buffer.from([0x52, 0x46, 0x42, 0x00]); // "RFB\x00" + const closed = await new Promise<{ code: number; reason: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/`); + ws.binaryType = "arraybuffer"; + ws.onopen = () => ws.send(hello); + ws.onmessage = (e) => { + // The raw target echoed REVERSED bytes: the payload must come back + // intact, not as an HTTP-reformatted anything. + const bytes = new Uint8Array(e.data as ArrayBuffer); + expect([...bytes]).toEqual([...hello].reverse()); + // Kill the target's side; the fake node reports target-gone. + for (const s of rfbAcceptList) s.destroy(); + }; + ws.onclose = (e) => resolve({ code: e.code, reason: e.reason }); + ws.onerror = (e) => reject(new Error(`raw tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error("raw tunnel stalled")), 5_000); + }); + expect(closed.code).toBe(1011); + expect(closed.reason).toBe("target-gone"); + // No HTTP upgrade ever reached the raw target: whatever it read starts + // with the RFB payload, never "GET ". + const seen = Buffer.concat(rfbLog).toString("latin1"); + expect(seen.startsWith("GET ")).toBe(false); + expect(seen).toContain("RFB"); + }); + + it("closes the lane viewer-left when the viewer's socket goes first", async () => { + const ref = await openSession(targetPort); + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + ws.onopen = () => ws.close(); + ws.onclose = () => resolve(); + ws.onerror = (e) => reject(new Error(String(e))); + }); + // The lane close rides the wire with the viewer-left reason. + await new Promise((res) => setTimeout(res, 200)); + expect(fake.closedLanes.some((c) => c.reason === "viewer-left")).toBe(true); + }); + + it("refuses slow links: bytes past preAckMaxBytes before the handshake closes slow-link", async () => { + // The seam: a small pre-ack budget so the boundary is drivable (the same + // doctrine as Task 5's laneBufferMaxBytes). Production default is + // PREVIEW_LANE_BUFFER_MAX_BYTES. + expect(PREVIEW_LANE_BUFFER_MAX_BYTES).toBe(1_048_576); + fake.reportScreenRfbPort = null; + const ref = await openSession(silentPort); + const app = new Elysia() + .use(errorHandlerPlugin) + .use(pvHttpRoute) + .use(previewWsRoute({ preAckMaxBytes: 64 })); + app.listen({ port: 0, hostname: "127.0.0.1" }); + const port = app.server?.port ?? 0; + if (!port) throw new Error("the slow-link proxy did not report a bound port"); + try { + const closed = await new Promise<{ code: number; reason: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/pv/${ref}/hmr`); + ws.binaryType = "arraybuffer"; + ws.onopen = () => { + // The silent target never answers the lane-side handshake: pump + // past the budget and wait for the named death. + const chunk = Buffer.alloc(32, 0x41); + for (let i = 0; i < 10; i += 1) ws.send(Buffer.from(chunk)); + }; + ws.onclose = (e) => resolve({ code: e.code, reason: e.reason }); + ws.onerror = (e) => reject(new Error(String(e))); + setTimeout(() => reject(new Error("slow-link never fired")), 5_000); + }); + expect(closed.code).toBe(1011); + expect(closed.reason).toBe("slow-link"); + } finally { + app.server?.stop(); + } + }); +}); diff --git a/apps/server/api/src/preview/lane-duplex.ts b/apps/server/api/src/preview/lane-duplex.ts new file mode 100644 index 000000000..adb1299bf --- /dev/null +++ b/apps/server/api/src/preview/lane-duplex.ts @@ -0,0 +1,78 @@ +import { Duplex } from "node:stream"; +import type { PreviewCloseReason } from "@internal/subshell-protocol"; + +/** One delivery from a broker lane: bytes, or the named reason the lane died. */ +export type LaneEvent = { bytes: Uint8Array } | { closed: PreviewCloseReason }; + +/** What a LaneDuplex needs to stand in for a TCP socket on one broker lane. */ +export interface LaneDuplexDeps { + /** Ship bytes toward the pane-side target (the proxy: `sendLaneBytes`). */ + writeOut(bytes: Uint8Array): void; + /** Receive lane deliveries; returns the unsubscriber. */ + onLane(cb: (event: LaneEvent) => void): () => void; +} + +/** + * A Node `Duplex` whose far end is a broker preview lane rather than a TCP + * socket, so a stream consumer can speak over a lane. It would also be the + * socket a `createConnection`-style transport plugged in, except Bun's + * `node:http` client silently IGNORES that option (measured on 1.4.2: the + * request dials `host:port` itself with the option set, so the duplex never + * sees a byte); the proxy therefore speaks its protocols over this class + * directly (`lane-http.ts`, `lane-ws-client.ts`). + * + * The broker's lane delivery is a firehose (a Set of subscribers, no + * pause/resume), so `push` is unconditional and the Duplex's internal buffer + * absorbs a slow consumer; the flow limit that matters sits on the node, + * which pumps a lane only as fast as its own socket drains. A lane `closed` + * ends the read side with `push(null)` (FIN semantics), never an `Error`: + * the reason WORD is what the proxy needs, and it reads it from + * `closedReason`, not from an exception that would flatten every death into + * one error. + */ +export class LaneDuplex extends Duplex { + /** The lane's close reason once the lane has closed; null while it lives. */ + closedReason: PreviewCloseReason | null = null; + + private readonly deps: LaneDuplexDeps; + private readonly unsubscribe: () => void; + + constructor(deps: LaneDuplexDeps) { + // Half-open stays closed: when the lane reports `closed` the read side + // ends, and the writable side ending with it (the default) is what makes + // the stream's `close` event fire, which is how `lane-wire.ts` and + // `streamBody` learn the far end is gone. + super(); + this.deps = deps; + this.unsubscribe = deps.onLane((event) => { + if ("closed" in event) { + this.closedReason = event.closed; + this.push(null); + return; + } + this.push(Buffer.from(event.bytes)); + }); + } + + // Public because `node:stream`'s types declare these overrides public; a + // `protected` one fails Liskov substitution even though nothing outside + // a stream should ever call them. + _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + try { + this.deps.writeOut(chunk); + callback(); + } catch (err) { + callback(err instanceof Error ? err : new Error(String(err))); + } + } + + _read(_size: number): void { + // The firehose above pushes whenever the lane delivers; `_read` exists + // only because Duplex requires one. + } + + _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this.unsubscribe(); + callback(error); + } +} diff --git a/apps/server/api/src/preview/lane-http.ts b/apps/server/api/src/preview/lane-http.ts new file mode 100644 index 000000000..0c4bb79e1 --- /dev/null +++ b/apps/server/api/src/preview/lane-http.ts @@ -0,0 +1,232 @@ +import type { Duplex } from "node:stream"; +import { LaneClosedError, LaneProtocolError, readLaneHead } from "@/preview/lane-wire.js"; + +/** + * One HTTP/1.1 request over a preview lane duplex, and its streamed answer + * (spec 2026-10-21 §6's HTTP half). + * + * This is hand-spoken because Bun's `node:http` client ignores + * `createConnection` (measured on 1.4.2 in three shapes: top-level option, + * `agent: false`, and through the `ws` client's socket override; the request + * dials `host:port` itself every time). A proxy whose transport is silently + * bypassed is worse than one that fails loudly, so the lane gets bytes + * written to it directly. The lane IS a TCP stream (the node pumps it to the + * target's loopback socket), so the wire format spoken here is exactly what + * a vhost-aware dev server expects to see. + */ + +export interface LaneRequestInit { + method: string; + /** Origin form including the query, already stripped of the proxy prefix. */ + path: string; + /** + * Header pairs, already transformed (no `host`, no `content-length`, no + * `transfer-encoding`, no `connection` family, no browser `cookie`: this + * function frames the body with its own `transfer-encoding: chunked` so + * streaming bodies cannot disagree with a content length the proxy cannot + * verify against the browser's stream. + */ + headers: [string, string][]; + /** The browser's body stream, chunk-encoded toward the target; null for none. */ + body: ReadableStream | null; +} + +export interface LaneResponse { + status: number; + statusText: string; + /** Raw target answer, NOT hop-by-hop filtered and NOT rewritten: pv-http does that. */ + headers: [string, string][]; + /** Decoded body (chunked folded out); null when the request was HEAD. */ + body: ReadableStream | null; +} + +/** Serialize the head, then pump the body chunk-encoded; the head is one write. */ +function writeRequest(duplex: Duplex, init: LaneRequestInit): void { + const lines = [`${init.method} ${init.path} HTTP/1.1`]; + for (const [name, value] of init.headers) lines.push(`${name}: ${value}`); + if (init.body) lines.push("transfer-encoding: chunked"); + duplex.write(Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1")); + const body = init.body; + if (!body) return; + const pump = async (): Promise => { + const reader = body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value.byteLength === 0) continue; + duplex.write(Buffer.from(`${value.byteLength.toString(16)}\r\n`, "latin1")); + duplex.write(Buffer.from(value)); + duplex.write(Buffer.from("\r\n", "latin1")); + } + } catch { + // An aborted browser body cannot be half-sent honestly; the target will + // see an unterminated chunk stream and its own timeout decides. Closing + // the lane (the caller's teardown) cuts it at TCP level too. + } + duplex.write(Buffer.from("0\r\n\r\n", "latin1")); + }; + void pump(); +} + +/** + * Drive the body-framing reader and resolve as soon as the head is known. + * + * Framing rules, in the order a conforming HTTP/1.1 reader applies them: a + * `chunked` transfer-encoding wins; else `content-length` bytes; else the + * body runs to EOF. A 204/304 has no body whatever the headers say, and a + * HEAD answer never has one. + */ +function streamBody( + duplex: Duplex, + headers: [string, string][], + tail: Buffer, + onDone: () => void, +): ReadableStream { + let mode: "chunked" | "length" | "eof"; + let remaining = 0; + const transfer = headers.find(([n]) => n === "transfer-encoding")?.[1]?.toLowerCase() ?? ""; + const lengthRaw = headers.find(([n]) => n === "content-length")?.[1]; + if (transfer.includes("chunked")) mode = "chunked"; + else if (lengthRaw !== undefined && /^\d+$/.test(lengthRaw)) { + mode = "length"; + remaining = Number(lengthRaw); + } else mode = "eof"; // the close-delimited body of a connection: close answer + + let acc: Buffer = tail; + let done = false; + let chunkSize: number | null = null; // current chunked part; null = awaiting size line + let controller: ReadableStreamDefaultController | undefined; + + const finish = (): void => { + if (done) return; + done = true; + duplex.removeListener("data", onData); + duplex.removeListener("close", onClose); + onDone(); + try { + controller?.close(); + } catch { + /* consumer already gone */ + } + }; + const emit = (bytes: Buffer): void => { + try { + controller?.enqueue(new Uint8Array(bytes)); + } catch { + /* consumer already gone; the lane close path will still run */ + } + }; + const onData = (incoming: Buffer): void => { + acc = acc.byteLength === 0 ? incoming : Buffer.concat([acc, incoming]); + for (;;) { + if (mode === "eof") { + // Read to EOF means there is no framing: hand everything to the + // consumer and let the lane close be the terminator. + if (acc.byteLength > 0) { + emit(acc); + acc = Buffer.alloc(0); + } + return; + } + if (mode === "length") { + if (remaining === 0) { + finish(); + return; + } + const take = Math.min(remaining, acc.byteLength); + if (take > 0) { + emit(acc.subarray(0, take)); + acc = acc.subarray(take); + remaining -= take; + } + if (remaining === 0) finish(); + return; + } + // chunked + if (chunkSize === null) { + const eol = acc.indexOf("\r\n"); + if (eol === -1) return; + const sizeLine = acc.subarray(0, eol).toString("latin1").split(";")[0] ?? ""; + const size = Number.parseInt(sizeLine.trim(), 16); + acc = acc.subarray(eol + 2); + if (Number.isNaN(size)) { + fail("malformed chunk size"); + return; + } + if (size === 0) { + // Trailers until the blank line; consuming them header-by-header + // would need a second parser, and every server we proxy sends bare + // `0\r\n\r\n`. Take the blank line and call it done; any bytes past + // it belong to a pipelined answer this one-shot lane will never read. + finish(); + return; + } + chunkSize = size; + continue; + } + if (acc.byteLength < chunkSize + 2) return; // wait for the chunk + its CRLF + emit(acc.subarray(0, chunkSize)); + acc = acc.subarray(chunkSize + 2); + chunkSize = null; + } + }; + const onClose = (): void => { + // A lane that dies mid-body truncates the stream (the consumer sees the + // close, not an exception: a POST may already have landed and the answer + // is a dead stream, not a retryable error). + acc = Buffer.alloc(0); + finish(); + }; + const fail = (message: string): void => { + if (done) return; + done = true; + duplex.removeListener("data", onData); + duplex.removeListener("close", onClose); + onDone(); + try { + controller?.error(new LaneProtocolError(message)); + } catch { + /* consumer already gone */ + } + }; + duplex.on("data", onData); + duplex.on("close", onClose); + const stream = new ReadableStream({ + start(c) { + controller = c; + }, + }); + if (mode === "length" && remaining === 0) finish(); + // The head reader handed us bytes past the head: drive the machine once so + // they are not stranded in `acc` until the next arrival. + else if (acc.byteLength > 0) onData(Buffer.alloc(0)); + return stream; +} + +/** + * Send one request on a lane duplex and resolve on the response head. + * `requestIsHead` answers a HEAD request with the head only: the target's + * framing would describe a body this response must not carry, and a 204/304 + * has no body whatever its headers say. `onBodyDone` runs once the body + * reader finishes, fails, or sees the lane die, and once immediately for the + * no-body cases: the caller owns lane release and must see exactly one call + * for every request. + */ +export async function sendLaneRequest( + duplex: Duplex, + init: LaneRequestInit, + requestIsHead: boolean, + onBodyDone: () => void, +): Promise { + writeRequest(duplex, init); + const head = await readLaneHead(duplex); + const status = Number(head.statusLine.slice(9, 12)); + const statusText = head.statusLine.slice(13).trim(); + const noBody = requestIsHead || status === 204 || status === 304; + const body = noBody ? null : streamBody(duplex, head.headers, head.tail, onBodyDone); + if (noBody) onBodyDone(); + return { status, statusText, headers: head.headers, body }; +} + +export { LaneClosedError, LaneProtocolError }; diff --git a/apps/server/api/src/preview/lane-wire.ts b/apps/server/api/src/preview/lane-wire.ts new file mode 100644 index 000000000..fc2957bef --- /dev/null +++ b/apps/server/api/src/preview/lane-wire.ts @@ -0,0 +1,125 @@ +import type { Duplex } from "node:stream"; +import type { PreviewCloseReason } from "@internal/subshell-protocol"; + +/** + * The shared HTTP/1.1 head reader for the lane clients (`lane-http.ts`, + * `lane-ws-client.ts`). + * + * On Bun 1.4.2 `node:http`'s `createConnection` option is IGNORED (measured: + * with `agent: false`, with an agent carrying `createConnection`, and through + * `ws`'s client, the request dials `host:port` itself every time, so + * `LaneDuplex` never sees a byte). The lane therefore gets hand-spoken + * HTTP/1.1: writing a request head is trivial; reading one back is this + * file. It resolves on the first complete head (status line + headers + the + * blank line) and hands the caller any bytes that arrived past the head, + * because a WebSocket upgrade response and its first frames can coalesce + * into one packet. Informational 1xx heads (the `103 Early Hints` a dev + * server may send) are consumed and the read continues. + */ +export interface LaneHead { + /** Raw status line, e.g. `HTTP/1.1 200 OK`. */ + statusLine: string; + /** Header name is lowercased; multi-values kept as separate entries. */ + headers: [string, string][]; + /** Bytes read past the head's blank line, ready to hand to a body reader. */ + tail: Buffer; +} + +/** The lane died before an answer head arrived. */ +export class LaneClosedError extends Error { + constructor(readonly reason: PreviewCloseReason | null) { + super(reason ? `the lane closed (${reason})` : "the lane closed"); + this.name = "LaneClosedError"; + } +} + +/** The far end wrote bytes that are not a usable HTTP/1.1 head. */ +export class LaneProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "LaneProtocolError"; + } +} + +/** Max head bytes before the answer is refused (dev-server heads are far under it). */ +const HEAD_MAX = 64 * 1024; + +/** + * Read one (or more, skipping 1xx) response head from a lane duplex. + * + * Rejects with `LaneClosedError` when the lane dies before the head lands, + * and `LaneProtocolError` for a status line that is not HTTP/1.x or a head + * past 64 KiB. Unsubscribes/tears listeners on any outcome; the caller owns + * the duplex. + */ +export function readLaneHead(duplex: Duplex): Promise { + return new Promise((resolve, reject) => { + let acc = Buffer.alloc(0); + const onData = (chunk: Buffer): void => { + acc = Buffer.concat([acc, chunk]); + if (acc.byteLength > HEAD_MAX) { + cleanup(); + reject(new LaneProtocolError("response head exceeds 64 KiB")); + return; + } + const end = acc.indexOf("\r\n\r\n"); + if (end === -1) return; + const headText = acc.subarray(0, end).toString("latin1"); + const tail = acc.subarray(end + 4); + const lines = headText.split("\r\n"); + const parsed: [string, string][] = []; + for (const line of lines.slice(1)) { + if (line === "") continue; + const colon = line.indexOf(":"); + if (colon === -1) { + cleanup(); + reject(new LaneProtocolError("malformed response header line")); + return; + } + const name = line.slice(0, colon).trim().toLowerCase(); + const value = line.slice(colon + 1).trim(); + // `obs-fold` continuations (a line starting with a space) fold into + // the previous value; no server we proxy emits them, but the + // alternative is misparsing a folded header as two headers. + if (line.startsWith(" ") && parsed.length > 0) { + parsed[parsed.length - 1][1] += ` ${value}`; + continue; + } + parsed.push([name, value]); + } + if (!/^HTTP\/1\.[01] \d{3}/.test(lines[0] ?? "")) { + cleanup(); + reject(new LaneProtocolError(`bad status line ${JSON.stringify(lines[0]?.slice(0, 40))}`)); + return; + } + const status = Number(lines[0].slice(9, 12)); + // 101 is NOT informational here: it is a WebSocket upgrade's success + // answer, and swallowing it would hang the redial forever. + if (status >= 100 && status < 200 && status !== 101) { + // Informational (103 Early Hints, 100 Continue): consume and keep reading. + acc = tail; + return; + } + cleanup(); + resolve({ statusLine: lines[0], headers: parsed, tail }); + }; + const onClosed = (): void => { + cleanup(); + reject( + new LaneClosedError((duplex as unknown as { closedReason?: PreviewCloseReason | null }).closedReason ?? null), + ); + }; + const cleanup = (): void => { + duplex.removeListener("data", onData); + duplex.removeListener("close", onClosed); + }; + duplex.on("data", onData); + duplex.on("close", onClosed); + // Bytes that already arrived (the common case on a local link where the + // head lands in the same tick as the request write) sit in the Duplex's + // internal buffer, unread while paused in paused mode. Reading here + // starts flowing; if the head is already buffered, the next read emits + // it synchronously. + duplex.resume(); + }); +} diff --git a/apps/server/api/src/preview/lane-ws-client.ts b/apps/server/api/src/preview/lane-ws-client.ts new file mode 100644 index 000000000..3dcf8470a --- /dev/null +++ b/apps/server/api/src/preview/lane-ws-client.ts @@ -0,0 +1,188 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { Duplex } from "node:stream"; +import { readLaneHead } from "@/preview/lane-wire.js"; +// Bun 1.4.2 ships a BUILT-IN module named `ws` that shadows the pinned +// dependency for any import of the bare name (measured: its `Sender` +// constructor throws "Not supported yet in Bun" on the first frame setup, +// and it is what silently ignored `createConnection` when the ws client was +// imported the normal way). The real package's files are reached by path so +// the shadow cannot intercept them; the ship file is the installed package's. +import ReceiverFile from "../../node_modules/ws/lib/receiver.js"; +import SenderFile from "../../node_modules/ws/lib/sender.js"; + +/** + * One WebSocket client spoken by hand over a lane duplex (spec 2026-10-21 + * §6's http-mode redial). + * + * The brief's plan was the `ws` client itself with its top-level + * `createConnection` aimed at the lane. That option IS kept by ws + * (`node_modules/ws/lib/websocket.js` lines 766-767), and Bun 1.4.2's + * `node:http` client ignores it anyway (measured: the request dials + * `host:port` itself and the duplex never sees a byte), so the full client + * would tunnel plane-local loopback while believing it had used the lane. + * What the client genuinely needs from ws is its frame codec, and ws ships + * that as standalone classes: the handshake head is written and read here + * (`lane-wire.ts`, which the HTTP half already trusts), and `ws`'s + * `Receiver` (a Writable fed lane bytes) and `Sender` (framing onto the + * duplex, masked because we are the client) do every frame after it. They + * are imported by file path because Bun's built-in `ws` shadows the bare + * name (see the import); `@types/ws` does not describe them, hence the + * narrow structural cast below. + * + * What the handshake carries and why: `Host` is the literal loopback the + * node dials (same vhost rule as `pv-http.ts`); the browser's `Origin` rides + * along because a dev server that checks WS origins checks it against the + * SAME value it checked for the page (the plane's own origin, which the + * upgrade gate already vetted); `Sec-WebSocket-Protocol` is offered as the + * browser offered it, and a target that picks one has its answer logged and + * otherwise ignored (the browser's own negotiation already completed with + * Bun, and every dialect in use here is single-protocol); `Cookie` comes + * from the session jar, never the browser. No `Sec-WebSocket-Extensions` + * offer: permessage-deflate stays entirely off the lane, and the browser + * link's own compression is Bun's business alone. + */ + +/** The RFC 6455 magic string the `Sec-WebSocket-Accept` hash folds in. */ +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +interface FrameReceiver { + on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void; + on(event: "ping", listener: (data: Buffer) => void): void; + on(event: "conclude", listener: (code: number, reason: Buffer) => void): void; + on(event: "error", listener: (err: Error) => void): void; + on(event: "close" | "finish", listener: () => void): void; + write(chunk: Buffer): boolean; + end(): void; +} + +interface FrameSender { + send( + data: Buffer, + options: { binary: boolean; fin: boolean; mask: boolean }, + cb?: (err?: Error | null) => void, + ): void; + close(code: number | undefined, data: Buffer | undefined, mask: boolean, cb?: (err?: Error | null) => void): void; + pong(data: Buffer, mask: boolean, cb?: (err?: Error | null) => void): void; +} + +/** The untyped-but-real framing classes; see the casts and the import comment. */ +const Receiver = ReceiverFile as unknown as new (options: { isServer: false }) => FrameReceiver; +const Sender = SenderFile as unknown as new (socket: Duplex, extensions: Record) => FrameSender; + +/** The live end of a redialed WebSocket: what `pv-ws.ts` pumps frames through. */ +export interface LaneWsHandle { + /** One browser frame onward, masked. Text keeps its text opcode. */ + send(bytes: Buffer, isText: boolean): void; + /** + * Best-effort close frame before the lane dies. `undefined` code lets ws + * pick 1005 (no status); a frame that cannot be written is irrelevant: the + * lane close right behind it is the real news. + */ + close(code: number | undefined, reason: string | undefined): void; +} + +export interface LaneWsInit { + /** The port the node dials; it rides the `Host` header only. */ + port: number; + /** Origin-form path + query, proxy prefix already stripped. */ + path: string; + /** The browser-derived headers worth forwarding (origin, subprotocols). */ + headers: [string, string][]; + /** The upgrade answer's `Set-Cookie` values, for the session jar (spec §6). */ + onCookies(cookies: string[]): void; + onMessage(data: Buffer, isBinary: boolean): void; + /** + * Once, after the 101, with the live handle: the callback is called + * before the promise resolves, and frames parked while the handshake rode + * the lane can only be flushed against a handle that already exists. + */ + onOpen(handle: LaneWsHandle): void; + /** + * Once: the target refused the handshake, hung up, or framed something + * broken. No reason word here on purpose: when the node volunteers a + * better one through the lane, that word wins (`pv-ws.ts`'s guard), and + * `target-gone` is what the caller says when nothing else does. + */ + onDeath(): void; +} + +/** Returned when the redial died at the handshake: every pump call is a no-op. */ +const inertHandle: LaneWsHandle = { + send() { + /* dead */ + }, + close() { + /* dead */ + }, +}; + +/** + * Dial the redial: write the upgrade head, await the answer on the lane, + * and hand both directions to ws's framing classes. Rejects only for a + * head the lane could not answer (a `LaneClosedError` from `lane-wire.ts`) + * or a head that is not parseable; every OTHER refusal (a 404, a plain + * 200 HTML page, a wrong accept) settles as `onDeath`, mirroring the ws + * client's `unexpected-response`-then-close shape. + */ +export async function openLaneWebSocket(duplex: Duplex, init: LaneWsInit): Promise { + const key = randomBytes(16).toString("base64"); + const lines = [ + `GET ${init.path} HTTP/1.1`, + `host: 127.0.0.1:${init.port}`, + "connection: upgrade", + "upgrade: websocket", + ]; + for (const [name, value] of init.headers) lines.push(`${name}: ${value}`); + lines.push(`sec-websocket-key: ${key}`, "sec-websocket-version: 13"); + duplex.write(Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1")); + + const head = await readLaneHead(duplex); + const status = Number(head.statusLine.slice(9, 12)); + if (status !== 101) { + init.onDeath(); + return inertHandle; + } + const accept = head.headers.find(([n]) => n === "sec-websocket-accept")?.[1]; + if ( + accept !== + createHash("sha1") + .update(key + WS_GUID) + .digest("base64") + ) { + init.onDeath(); + return inertHandle; + } + const cookies = head.headers.filter(([n]) => n === "set-cookie").map(([, v]) => v); + if (cookies.length > 0) init.onCookies(cookies); + + const sender = new Sender(duplex, {}); + const receiver = new Receiver({ isServer: false }); + receiver.on("message", (data, isBinary) => init.onMessage(data, isBinary)); + receiver.on("ping", (data) => sender.pong(data, true)); + // `conclude` is ws's "a close frame arrived": the target is done talking. + receiver.on("conclude", () => init.onDeath()); + receiver.on("error", () => init.onDeath()); + receiver.on("close", () => init.onDeath()); + // The head reader may have handed bytes that are already frames; feed + // them before attaching the pipe so their order survives. + if (head.tail.byteLength > 0) receiver.write(head.tail); + // The cast is the structural one from the top of the file: ws's + // Receiver IS a `stream.Writable` at runtime; the narrow interface only + // names the events and `write` we use, and `pipe` wants the stream. + duplex.pipe(receiver as unknown as NodeJS.WritableStream); + + const handle: LaneWsHandle = { + send(bytes, isText) { + sender.send(bytes, { binary: !isText, fin: true, mask: true }); + }, + close(code, reason) { + try { + sender.close(code, reason === undefined ? undefined : Buffer.from(reason, "utf8"), true, () => undefined); + } catch { + /* a frame that cannot be written is noise; the lane close is the news */ + } + }, + }; + init.onOpen(handle); + return handle; +} diff --git a/apps/server/api/src/preview/pv-http.ts b/apps/server/api/src/preview/pv-http.ts new file mode 100644 index 000000000..57502bee8 --- /dev/null +++ b/apps/server/api/src/preview/pv-http.ts @@ -0,0 +1,253 @@ +import { Elysia } from "elysia"; +import { HttpError } from "@/api/auth-guard.js"; +import { LaneDuplex } from "@/preview/lane-duplex.js"; +import { LaneClosedError, LaneProtocolError, sendLaneRequest } from "@/preview/lane-http.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; + +/** + * The same-origin `/pv//...` HTTP half of the pane-preview proxy + * (spec 2026-10-21 §6). + * + * One session, one response, one lane: every browser request under the + * session's proxy path opens its own forward lane (the browser reconnects + * per request; lanes are cheap and one-shot), carries the session's cookies + * from the jar instead of the browser's, and rewrites the answer so the + * target believes it lives under `/pv//` and the browser's cookie store + * stays inside that path. The session ref in the path is the whole + * credential (spec §10), so an unknown one is a plain 404 saying nothing + * else. + * + * The request itself is hand-framed over the lane (`lane-http.ts`): Bun's + * `node:http` client silently ignores `createConnection` (measured on 1.4.2 + * three ways), so a proxy built on it would answer with bytes from a + * plane-local dial while believing it had used the lane. The transforms, and + * why each one exists: + * + * - INBOUND: drop the browser's `Cookie` and attach the jar (spec §6: the + * jar reaches lane requests only, and every request the proxy makes is + * one); set `Host` to the literal `127.0.0.1:` the node dials (a + * vhost-aware dev server must not see the plane's hostname, and `Host` is + * exactly what a Host-checking dev server checks); drop `Connection`/ + * `Keep-Alive` (the lane is one-shot; a keep-alive promise to the target + * would outlive the lane). + * - OUTBOUND: drop hop-by-hop headers (they describe the lane's transport, + * not the proxied exchange); force `Set-Cookie` Path onto the session's + * proxy path (without it the target's own path guess, normally `/`, lets + * the cookie leak plane-wide, and it would never come back on the next + * request either); store only the name=value pair in the jar; prefix a + * relative `Location` with the proxy path; strip `frame-ancestors` from + * both CSP headers (the iframe IS the product; the rest of a target's CSP + * is its own business and survives untouched). + */ + +/** Header names describing the lane's transport rather than the proxied exchange. */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** Browser-context headers the jar/Host/one-shot rules replace or re-frame. */ +const INBOUND_DROP = new Set(["cookie", "host", "content-length", ...HOP_BY_HOP]); + +/** The 404 text. Deliberately about the link, never about the pane behind it. */ +const DEAD_LINK = "This preview link is no longer valid. Reopen the preview to get a fresh one."; + +/** + * Store the name=value pair of every `Set-Cookie` in the session jar, keyed + * by cookie name with last-write-wins. Attributes are stripped because the + * jar's values ARE what gets attached to later requests, and a value still + * wearing `Path=` or `Expires=` would ship to the target as a bogus cookie + * (spec §6). Name-only keying across paths is the deliberate v1 + * simplification. Shared with `pv-ws.ts` so the upgrade-set cookie (the + * redial's response) lands in the SAME jar: §6's jar sentence is unqualified + * about which lane sets it. + */ +export function storeCookiesInJar(jar: Map, setCookies: readonly string[]): void { + for (const raw of setCookies) { + const pair = raw.split(";")[0]?.trim(); + if (!pair) continue; + const eq = pair.indexOf("="); + if (eq < 1) continue; + jar.set(pair.slice(0, eq), pair); + } +} + +/** The jar as a `Cookie` header value, or "" when the jar is empty. */ +export function jarCookieHeader(jar: Map): string { + return [...jar.values()].join("; "); +} + +/** Force one `Set-Cookie` to ride the session's proxy path, attributes kept. */ +function rewriteSetCookiePath(raw: string, ref: string): string { + const [pair, ...attributes] = raw.split(";"); + const kept = attributes.filter((attr) => !/^\s*path\s*=/i.test(attr)); + return `${pair?.trim() ?? ""}; Path=/pv/${ref}/${kept.length > 0 ? `; ${kept.map((a) => a.trim()).join("; ")}` : ""}`; +} + +/** + * Drop only the `frame-ancestors` directive from a CSP value. Splitting on + * `;` treats a quoted semicolon inside a directive as a separator; the + * directive families that could carry one are not the ones iframe-embedding + * dev servers ship, so v1 takes the simple split and keeps everything else + * byte-identical. + */ +export function stripFrameAncestors(csp: string): string { + return csp + .split(";") + .map((directive) => directive.trim()) + .filter((directive) => !/^frame-ancestors(\s|$)/i.test(directive)) + .join("; "); +} + +/** Prefix a root-relative `Location` so the redirect stays inside the proxy. */ +export function rewriteLocation(location: string, ref: string): string { + if (!location.startsWith("/") || location.startsWith("//")) return location; + return `/pv/${ref}${location}`; +} + +/** Transform the raw lane answer's header pairs into the browser-facing `Headers`. */ +function outHeadersToWeb(pairs: [string, string][], jar: Map, ref: string): Headers { + const headers = new Headers(); + const setCookies = pairs.filter(([n]) => n === "set-cookie").map(([, v]) => v); + for (const [name, value] of pairs) { + if (HOP_BY_HOP.has(name) || name === "set-cookie") continue; + if (name === "content-security-policy" || name === "content-security-policy-report-only") { + headers.append(name, stripFrameAncestors(value)); + } else if (name === "location") { + headers.append(name, rewriteLocation(value, ref)); + } else { + headers.append(name, value); + } + } + if (setCookies.length > 0) { + storeCookiesInJar(jar, setCookies); + for (const cookie of setCookies) headers.append("set-cookie", rewriteSetCookiePath(cookie, ref)); + } + return headers; +} + +/** + * One proxied request: lane, hand-framed request, streamed answer, lane + * release. The lane closes exactly once, `viewer-left`, whatever the ending + * (complete body, client abort, dead lane, refusal), because every one of + * those is the proxy finishing its one-shot. A lane the NODE closed keeps + * its own reason word inside `LaneDuplex`, and the 502 quotes it. A lane + * that dies mid-body ends the stream: a POST may already have landed, and + * there is never a mid-stream retry. + */ +async function proxyRequest(request: Request, sessionRef: string, rest: string): Promise { + const broker = getPreviewBroker(); + const session = broker.sessionFor(sessionRef); + if (!session) throw new HttpError(404, DEAD_LINK); + + let laneRef: string; + try { + ({ laneRef } = await broker.openLaneForConnection(sessionRef)); + } catch (err) { + if (err instanceof PreviewRefusal) { + // The named refusal, verbatim: the SPA turns this word into "close a + // preview first". Never an anonymous 500, never a hang. + throw new HttpError( + 503, + `${err.code}: this pane is already carrying too many preview lanes. Try again once one closes.`, + ); + } + throw err; + } + + let laneReleased = false; + const releaseLane = (): void => { + if (laneReleased) return; + laneReleased = true; + void broker.closeLaneForConnection(laneRef, "viewer-left").catch(() => undefined); + }; + + const duplex = new LaneDuplex({ + writeOut: (bytes) => broker.sendLaneBytes(laneRef, bytes), + onLane: (cb) => broker.subscribeLane(laneRef, cb), + }); + // The browser giving up ends the lane stream; whatever the target answers + // after that has nowhere to go, and the body reader's finish/close path + // still runs `releaseLane`. + request.signal.addEventListener("abort", () => duplex.destroy()); + + const url = new URL(request.url); + const headers: [string, string][] = [ + ["host", `127.0.0.1:${session.port}`], + ["connection", "close"], + ]; + for (const [name, value] of request.headers) { + const lower = name.toLowerCase(); + if (INBOUND_DROP.has(lower)) continue; + headers.push([lower, value]); + } + const jarCookie = jarCookieHeader(session.cookieJar); + if (jarCookie) headers.push(["cookie", jarCookie]); + + const isHead = request.method === "HEAD"; + try { + const res = await sendLaneRequest( + duplex, + { + method: request.method, + path: `/${rest}${url.search}`, + headers, + // A request body rides chunk-encoded (the browser's own framing is + // dropped with `content-length` above, and an unknown-length fetch + // stream cannot promise a length to the target). GET/HEAD carry none. + body: isHead || request.method === "GET" ? null : (request.body ?? null), + }, + isHead, + () => { + duplex.destroy(); + releaseLane(); + }, + ); + const headersOut = outHeadersToWeb(res.headers, session.cookieJar, sessionRef); + if (res.body === null) { + duplex.destroy(); + releaseLane(); + } + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: headersOut, + }); + } catch (err) { + duplex.destroy(); + releaseLane(); + if (err instanceof LaneClosedError) { + throw new HttpError(502, `The preview target could not answer: ${err.message}.`); + } + if (err instanceof LaneProtocolError) { + throw new HttpError(502, `The preview target answered with something unparseable: ${err.message}.`); + } + throw err; + } +} + +/** + * The seven plain verbs for the session's proxy path (the WS upgrade for the + * same wildcard lives in `pv-ws.ts`; spike-verified that a GET route and a + * `.ws()` route coexist on one dynamic pattern). Mounted by `server.ts` + * BEFORE the static SPA plugin (that file's `installScriptRoute` comment: a + * root-level path mounted after it is 404'd by the file branch). + */ +const PATTERN = "/pv/:session/*"; +const pvHandler = ({ params, request }: { params: { session: string; "*": string }; request: Request }) => + proxyRequest(request, params.session, params["*"] ?? ""); + +export const pvHttpRoute = new Elysia({ name: "pv-http" }) + .get(PATTERN, pvHandler) + .post(PATTERN, pvHandler) + .put(PATTERN, pvHandler) + .patch(PATTERN, pvHandler) + .delete(PATTERN, pvHandler) + .head(PATTERN, pvHandler) + .options(PATTERN, pvHandler); diff --git a/apps/server/api/src/preview/pv-ws.ts b/apps/server/api/src/preview/pv-ws.ts new file mode 100644 index 000000000..655dfaf71 --- /dev/null +++ b/apps/server/api/src/preview/pv-ws.ts @@ -0,0 +1,296 @@ +import { PREVIEW_LANE_BUFFER_MAX_BYTES, type PreviewCloseReason } from "@internal/subshell-protocol"; +import { Elysia } from "elysia"; +import { HttpError } from "@/api/auth-guard.js"; +import { browserOriginAllowed } from "@/lib/browser-origin.js"; +import { LaneDuplex } from "@/preview/lane-duplex.js"; +import { type LaneWsHandle, openLaneWebSocket } from "@/preview/lane-ws-client.js"; +import { jarCookieHeader, storeCookiesInJar } from "@/preview/pv-http.js"; +import { getPreviewBroker, PreviewRefusal, type PreviewSession } from "@/services/preview-broker.js"; + +/** + * The WS half of the `/pv//...` proxy (pane-preview spec 2026-10-21 §6), + * mounted on the SAME wildcard the HTTP half serves (`pv-http.ts`): Elysia + * dispatches an upgrade GET to the `.ws()` route and plain GETs to the plain + * routes (spike-verified for one dynamic pattern across two modules). + * + * The session ref in the path authorized this upgrade already (spec §10), so + * any suffix is the tunnel root: noVNC dials its own path and Vite's HMR + * dials its own. Gates run at UPGRADE (before any socket exists, the /ws + * doctrine): `browserOriginAllowed` from `@/lib/browser-origin.js` (PR #424: + * a foreign browser Origin is refused, a missing Origin is a non-browser + * client and passes, registry origins pass; an exact plane-origin equality + * would break a dashboard reached over a published Tailscale/NetBird + * address), then the session lookup, then the broker's per-pane lane cap. A + * queue-overflow refusal answers `503` carrying `preview-lane-over-cap` as + * an HTTP status, never a hang or a socket that dies later. + * + * Two modes, because one pump cannot serve both consumers (decision 6): + * `raw` websockify-pumps frame payloads straight onto the lane for a raw TCP + * target (x11vnc would read an HTTP upgrade as a bogus RFB version), while + * `http` REDIALS a real WebSocket handshake aimed at the lane. The brief's + * shape here was the `ws` client's top-level `createConnection` option, + * which ws itself does keep (`node_modules/ws/lib/websocket.js` lines + * 766-767) but Bun 1.4.2's `node:http` client IGNORES (measured: the request + * dials `host:port` itself and the lane sees no byte), so the redial rides + * `lane-ws-client.ts` instead: the handshake spoken here, ws's own + * `Receiver`/`Sender` doing the frames. It carries the jar's cookies and + * captures the target's upgrade response `Set-Cookie` into the same jar. + * + * Close words reach the user, not only the audit: anything except the + * viewer's own leaving closes the browser socket `1011` with the + * `PreviewCloseReason` verbatim in the reason field (there is no "server + * policy" close code, and every word in the set fits the 123-byte limit), + * so Task 13's disconnect handling reads `revoked`/`pane-gone`/`target-gone` + * off the close event. + * + * A text-frame caveat worth stating because it is the one byte-exactness + * exception: Elysia's `.ws()` message parser JSON-parses text frames that + * LOOK like JSON before this handler sees them, so an inbound text frame + * with significant whitespace re-stringifies semantically intact but not + * byte-exact. Binary frames and non-JSON text (the dev-server dialects that + * matter: Vite HMR's own JSON, VNC's binary) ride exact. Being fully raw + * would mean a hand-rolled `server.upgrade` route, which is a design change + * from the `.ws()` mount the brief registers, not a line to quietly take. + */ + +/** One live browser↔lane tunnel, stashed on `ws.data` at upgrade (socket identity is not state). */ +interface PvTunnel { + sessionRef: string; + laneRef: string; + mode: "http" | "raw"; + port: number; + /** Suffix + query the browser dialed (http-mode redial path; raw ignores it). */ + rest: string; + search: string; + /** Browser headers the redial re-offers (origin, subprotocols); raw ignores it. */ + forward: [string, string][]; + session: PreviewSession; + /** The browser side, filled in `open` (the upgrade ran before it existed). */ + ws?: { close: (code?: number, reason?: string) => void }; + /** http mode: the redialed target end, filled when the 101 lands. */ + handle?: LaneWsHandle; + duplex?: LaneDuplex; + unsub: () => void; + /** http mode: the redial handshake has not completed yet. */ + pending: boolean; + /** browser frames waiting on that handshake, and their running byte total. */ + queue: { text: boolean; bytes: Buffer }[]; + queuedBytes: number; + closed: boolean; +} + +const DEAD_LINK = "This preview link is no longer valid. Reopen the preview to get a fresh one."; + +// The return type is left to inference: `.ws()` deepens the instance type, and an +// explicit `Elysia` annotation on the way out makes `server.ts` reject `.use()` +// on a variance the builder guarantees. +export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { + // The seam the tests drive small: the same doctrine as Task 5's + // `laneBufferMaxBytes`. Past this much browser bytes parked while the + // target-side handshake has not completed, the link is not a slow link, + // it is a dead one, and spec §6's no-phantom-success says say so. + const preAckMax = options.preAckMaxBytes ?? PREVIEW_LANE_BUFFER_MAX_BYTES; + + /** + * The single teardown: one owner, one reason word, both ends closed. + * + * `from` says who died: `browser` (their close or unmount: the lane gets + * `viewer-left`, the browser socket needs no explanation), `lane` (the + * node already named the death; re-closing the lane is not ours to do), + * `target` (the far end hung up or refused before the lane said anything; + * we own the lane and the honest word is `target-gone`). Everyone but the + * browser gets `1011` + the word verbatim: there is no "server policy" + * close code, and the word is the contract Task 13 renders. + */ + function teardown(t: PvTunnel, reason: PreviewCloseReason, from: "browser" | "lane" | "target"): void { + if (t.closed) return; + t.closed = true; + // A close frame before the lane dies: best-effort courtesy, a frame + // that cannot be written is noise under the lane close right behind it. + t.handle?.close(undefined, undefined); + t.unsub(); + t.duplex?.destroy(); + if (from !== "lane") { + void getPreviewBroker() + .closeLaneForConnection(t.laneRef, from === "browser" ? "viewer-left" : reason) + .catch(() => undefined); + } + try { + if (from === "browser") t.ws?.close(); + else if (reason === "viewer-left") t.ws?.close(1000, "viewer-left"); + else t.ws?.close(1011, reason); + } catch { + /* the browser socket is already gone; the lane close was the point */ + } + } + + return new Elysia({ name: "pv-ws" }).ws("/pv/:session/*", { + async upgrade(context) { + const { request, params } = context as { request: Request; params: Record }; + if (!browserOriginAllowed(request)) { + throw new HttpError(403, "This request came from an untrusted origin."); + } + const broker = getPreviewBroker(); + const session = broker.sessionFor(params.session); + if (!session) throw new HttpError(404, DEAD_LINK); + let laneRef: string; + try { + // AWAITED here, before the socket: the cap decision is the upgrade's + // answer, so the SPA sees a named 503 instead of a dying connection. + ({ laneRef } = await broker.openLaneForConnection(params.session)); + } catch (err) { + if (err instanceof PreviewRefusal) { + throw new HttpError( + 503, + `${err.code}: this pane is already carrying too many preview lanes. Try again once one closes.`, + ); + } + throw err; + } + const url = new URL(request.url); + // The two browser headers worth re-offering on the redial: a + // dev server that checks WS origins sees the same value it vetted + // against the page (the plane origin the gate just passed), and a + // dialect that names its subprotocols gets them offered. The + // answer-back pick is ignored, not negotiated again: the browser's + // own negotiation already completed with Bun. + const forward: [string, string][] = []; + const originHeader = request.headers.get("origin"); + if (originHeader) forward.push(["origin", originHeader]); + const protocols = request.headers.get("sec-websocket-protocol"); + if (protocols) forward.push(["sec-websocket-protocol", protocols]); + Object.assign(context as Record, { + pv: { + sessionRef: session.ref, + laneRef, + mode: session.mode, + port: session.port, + rest: params["*"] ?? "", + search: url.search, + forward, + session, + unsub: () => undefined, + pending: session.mode === "http", + queue: [], + queuedBytes: 0, + closed: false, + } satisfies PvTunnel, + }); + }, + open(ws) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + t.ws = ws; + const broker = getPreviewBroker(); + if (t.mode === "raw") { + // Raw mode: lane bytes out ARE binary frames, payloads verbatim, + // nothing HTTP about it (spec §5's blind pump, exactly right for + // RFB and exactly wrong for a target that is itself a WS server). + t.unsub = broker.subscribeLane(t.laneRef, (event) => { + if ("closed" in event) { + teardown(t, event.closed, "lane"); + return; + } + if (t.closed) return; + try { + ws.send(Buffer.from(event.bytes)); + } catch { + /* the browser socket is racing its own close */ + } + }); + return; + } + + // http mode subscribes the LaneDuplex FIRST, before anything else can + // lose a byte: a separate "watch for closed" subscriber opened ahead of + // the duplex would swallow out-bytes in the gap. The duplex owns the + // lane subscription; its close event (with `closedReason`) is the lane + // death signal. + const duplex = new LaneDuplex({ + writeOut: (bytes) => broker.sendLaneBytes(t.laneRef, bytes), + onLane: (cb) => broker.subscribeLane(t.laneRef, cb), + }); + t.duplex = duplex; + t.unsub = () => duplex.destroy(); + duplex.on("close", () => { + const word = duplex.closedReason; + if (word && !t.closed) teardown(t, word, "lane"); + }); + duplex.on("error", () => { + if (!t.closed) teardown(t, "target-gone", "target"); + }); + const targetDied = (): void => { + // The target hung up or refused the handshake. When the node + // volunteers a better word (`target-gone` via the lane), the guard + // keeps this path from overwriting it; when it never does (a refusal + // answering a WS dial), `target-gone` is still the honest word: + // nothing on the far end speaks WS. + if (!t.closed) teardown(t, "target-gone", "target"); + }; + const jarCookie = jarCookieHeader(t.session.cookieJar); + const redial = openLaneWebSocket(duplex, { + port: t.port, + path: `/${t.rest}${t.search}`, + headers: jarCookie ? [...t.forward, ["cookie", jarCookie]] : t.forward, + onCookies: (cookies) => { + // Spec §6's jar sentence is unqualified: an upgrade-set cookie + // rides the session like an HTTP-lane one. (No Path rewrite here: + // the browser never sees this handshake.) + storeCookiesInJar(t.session.cookieJar, cookies); + }, + onMessage: (data, isBinary) => { + if (t.closed) return; + try { + ws.send(isBinary ? data : data.toString("utf8")); + } catch { + /* browser racing its close; the close handler reaps the lane */ + } + }, + onOpen: (handle) => { + if (t.closed) return; + t.handle = handle; + t.pending = false; + for (const frame of t.queue.splice(0)) { + t.queuedBytes -= frame.bytes.byteLength; + handle.send(frame.bytes, frame.text); + } + }, + onDeath: targetDied, + }); + // A head the lane never answered (LaneClosedError from the wire + // reader) is a death like any other; the lane's own close event may + // well have run first, and the guards make the double-call harmless. + void redial.catch(targetDied); + }, + message(ws, message) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + if (t.closed) return; + const text = typeof message === "string"; + const bytes = text + ? Buffer.from(message as string) + : typeof message === "object" && message !== null && !Buffer.isBuffer(message) + ? // Elysia's JSON coercion (see the file header): a parsed object + // can only ride on re-stringified; semantics kept, bytes may + // differ. Plain Buffers and plain strings are untouched above. + Buffer.from(JSON.stringify(message)) + : Buffer.from((message as Buffer).buffer, (message as Buffer).byteOffset, (message as Buffer).byteLength); + if (t.mode === "raw") { + getPreviewBroker().sendLaneBytes(t.laneRef, bytes); + return; + } + if (!t.pending && t.handle) { + t.handle.send(bytes, text); + return; + } + // Pre-ack window: the redial handshake has not completed. Park, but + // never park past the budget: spec §6 refuses phantom success. The + // teardown carries the named death to the browser (1011 `slow-link`). + t.queue.push({ text, bytes }); + t.queuedBytes += bytes.byteLength; + if (t.queuedBytes > preAckMax) teardown(t, "slow-link", "target"); + }, + close(ws) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + teardown(t, "viewer-left", "browser"); + }, + }); +} diff --git a/apps/server/api/src/server.ts b/apps/server/api/src/server.ts index 40798b813..ba1e93463 100644 --- a/apps/server/api/src/server.ts +++ b/apps/server/api/src/server.ts @@ -9,6 +9,8 @@ import { authPlugin } from "@/plugins/auth.plugin.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { openApiPlugin } from "@/plugins/openapi.plugin.js"; import { selectStaticPlugin } from "@/plugins/static.plugin.js"; +import { pvHttpRoute } from "@/preview/pv-http.js"; +import { previewWsRoute } from "@/preview/pv-ws.js"; import { apiModels } from "@/schema/index.js"; import { originRegistry } from "@/services/trusted-origins.js"; import { logger } from "@/utils/logger.js"; @@ -61,11 +63,22 @@ export function createApp() { // top-level path the static plugin's file branch would otherwise 404 // (it is not a dist file). spec 2026-08-31 §8. .use(installScriptRoute) + // Root-level `/pv//...` like install.sh above, and for the same + // reason: mounted AFTER the static plugin, every proxy path the pane + // browser loads would hit the file branch and 404. The session ref in + // the path is the credential (spec 2026-10-21 §6/§10); no cookie or + // bearer gate stands in front of it, and REQUEST_LOG_IGNORE keeps the + // secret out of request lines. + .use(pvHttpRoute) .use(authPlugin) // Disk dist wins when present (dev and checkout deployments behave alike); // else the SPA embedded by scripts/embed-web.ts; else boot fails loudly. .use(selectStaticPlugin(FRONTEND_DIST, EMBEDDED)) .use(wsPlugin) + // The proxy's WS half rides the same wildcard as pvHttpRoute; Elysia + // dispatches upgrade GETs here and plain GETs to the HTTP routes + // (spike-verified for one dynamic pattern across two modules). + .use(previewWsRoute()) .use(routes); return app; diff --git a/bun.lock b/bun.lock index 1bd5372f9..09edec535 100644 --- a/bun.lock +++ b/bun.lock @@ -209,6 +209,7 @@ "serialize-error": "13.0.1", "tar-stream": "3.2.1", "web-push": "3.6.7", + "ws": "8.22.0", }, "devDependencies": { "@elysiajs/eden": "1.4.9", @@ -3453,7 +3454,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], @@ -3617,8 +3618,6 @@ "@expo/router-server/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@expo/ws-tunnel/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -3659,6 +3658,8 @@ "@react-native/dev-middleware/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@react-native/dev-middleware/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.87.1", "", {}, "sha512-L9yKwEJCq7e8RjhthMUht2CP/CPnBR134h9xHkJ0XOO15QOWBAOqSZltTZG/wiokidqePvK+exMsRinuFLsN9w=="], "@react-native/metro-config/metro-config": ["metro-config@0.87.0", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.87.0", "metro-cache": "0.87.0", "metro-core": "0.87.0", "metro-runtime": "0.87.0" } }, "sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA=="], @@ -3829,6 +3830,8 @@ "metro/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "metro/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "metro/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], @@ -3865,10 +3868,14 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "react-native/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "react-native/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "react-native-webview/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -4195,6 +4202,8 @@ "@react-native/metro-config/metro-config/metro/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "@react-native/metro-config/metro-config/metro/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "@react-native/metro-config/metro-config/metro/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "@react-native/metro-config/metro-config/metro-core/metro-resolver": ["metro-resolver@0.87.0", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg=="], From a88ce8e9cf4f8bbf6345c8fafa0491b5dc28b188 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 04:19:08 -0700 Subject: [PATCH 044/139] fix(server): carry coerced WS text frames and pin the upgrade-cookie jar Elysia's ws parser hands non-JSON-looking text frames over as coerced primitives (123, true, null); the tunnel's else branch threw inside the async dispatch and the frame was destroyed silently. The handler now sorts shapes: strings and non-JSON text ride byte-exact as text, primitives ride their string form as text, buffer views ride byte-exact as binary, parsed JSON objects ride a compact re-stringification. Also: the obs-fold check runs before the colon check so a real continuation line folds instead of being refused, the raw-mode row pins the pump counter (bypass guard), and a new row proves an upgrade-response Set-Cookie rides the session jar onto the next HTTP request while a different session carries nothing. --- .../src/preview/__tests__/pv-proxy.test.ts | 88 ++++++++++++++++++- apps/server/api/src/preview/lane-wire.ts | 22 +++-- apps/server/api/src/preview/pv-ws.ts | 52 +++++++---- 3 files changed, 138 insertions(+), 24 deletions(-) diff --git a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts index 46a6e8adc..581acf5f5 100644 --- a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts +++ b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts @@ -169,7 +169,16 @@ beforeAll(async () => { async fetch(req, server) { const url = new URL(req.url); if (url.pathname === "/hmr" || url.pathname.startsWith("/hmr")) { - if (server.upgrade(req, { data: { path: url.pathname } })) return undefined; + // The /hmr-cookie path answers the 101 with a Set-Cookie: the + // upgrade-answer jar leg needs a target that sets one. + const upgraded = + url.pathname === "/hmr-cookie" + ? server.upgrade(req, { + data: { path: url.pathname }, + headers: { "set-cookie": "hsid=upgraded; Path=/deep; HttpOnly" }, + }) + : server.upgrade(req, { data: { path: url.pathname } }); + if (upgraded) return undefined; return new Response("upgrade refused", { status: 400 }); } lastSeen = { @@ -608,9 +617,82 @@ describe("pv-ws: the tunnels", () => { expect(fake.piped).toBeGreaterThan(before); }); + it("carries Elysia-coerced text frames as text: primitives ride their string form, JSON re-stringifies", async () => { + const ref = await openSession(targetPort); + const frames = [ + '{ plain: "text" }', + 'spaced {"a": 1} json', + "123", + "true", + "null", + '{ "type": "update", "updates": [] }', + ]; + const got = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + const back: string[] = []; + ws.onopen = () => { + for (const f of frames) ws.send(f); + }; + ws.onmessage = (e) => { + back.push(String(e.data)); + if (back.length === frames.length) { + ws.close(); + resolve(back); + } + }; + ws.onerror = (e) => reject(new Error(`coerced-frame tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error(`coerced-frame tunnel stalled; got ${JSON.stringify(back)}`)), 5_000); + }); + // Text that merely LOOKS JSONed but is not valid JSON stays a string at + // the handler and rides byte-exact, whitespace and all: the brace-object + // with unquoted keys and the word-wrapped JSON fragment both arrive + // exactly as written. + expect(got[0]).toBe('{ plain: "text" }'); + expect(got[1]).toBe('spaced {"a": 1} json'); + // Elysia's parser hands "123"/"true"/"null" over as the number/boolean/ + // null the JSON coercion made them. They must still arrive as the TEXT + // the browser wrote: the target sees bare digits and words. + expect(got[2]).toBe("123"); + expect(got[3]).toBe("true"); + expect(got[4]).toBe("null"); + // A frame that parses as real JSON arrives re-parsed; the promise is + // semantics, so pin the compact re-stringification. A parser change + // that starts delivering raw text still passes; one that eats the + // frame does not. + expect(got[5]).toBe(JSON.stringify({ type: "update", updates: [] })); + }); + + it("rides an upgrade-response Set-Cookie into the jar and onto the next HTTP request", async () => { + const ref = await openSession(targetPort); + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr-cookie`); + // The browser `onopen` is the PLANE's answer, not the lane's: the + // redial handshake is still in flight. Closing here would tear the + // duplex down before the 101 (and its cookie) lands. One echoed + // frame proves the redial completed, which is also the moment the + // jar took the cookie. + ws.onopen = () => ws.send("ping"); + ws.onmessage = () => { + ws.onclose = () => resolve(); + ws.close(); + }; + ws.onerror = (e) => reject(new Error(`cookie upgrade failed: ${String(e)}`)); + setTimeout(() => reject(new Error("cookie upgrade stalled")), 5_000); + }); + // Spec §6's jar sentence is unqualified about WHICH lane sets a cookie: + // the WS redial's upgrade answer rides the session like an HTTP answer's. + const seen = (await (await proxyFetch(`/pv/${ref}/echo`)).json()) as TargetSeen; + expect(seen.cookie).toContain("hsid=upgraded"); + // A different session's proxy path carries nothing. + const ref2 = await openSession(targetPort); + const other = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as TargetSeen; + expect(other.cookie).toBeNull(); + }); + it("pumps raw mode verbatim, answers as binary frames, and carries target-gone to the close event", async () => { fake.reportScreenRfbPort = rfbPort; const ref = await openSession(rfbPort); // the registered screen port => raw + const pipedBefore = fake.piped; expect(getPreviewBroker().sessionFor(ref)?.mode).toBe("raw"); const hello = Buffer.from([0x52, 0x46, 0x42, 0x00]); // "RFB\x00" const closed = await new Promise<{ code: number; reason: string }>((resolve, reject) => { @@ -636,6 +718,10 @@ describe("pv-ws: the tunnels", () => { const seen = Buffer.concat(rfbLog).toString("latin1"); expect(seen.startsWith("GET ")).toBe(false); expect(seen).toContain("RFB"); + // The bypass guard, same doctrine as the http-mode leg: the rfb target + // answers on plane loopback, so only the pump counter proves the bytes + // rode the fake node rather than a direct dial. + expect(fake.piped).toBeGreaterThan(pipedBefore); }); it("closes the lane viewer-left when the viewer's socket goes first", async () => { diff --git a/apps/server/api/src/preview/lane-wire.ts b/apps/server/api/src/preview/lane-wire.ts index fc2957bef..9e0ec05ca 100644 --- a/apps/server/api/src/preview/lane-wire.ts +++ b/apps/server/api/src/preview/lane-wire.ts @@ -70,6 +70,21 @@ export function readLaneHead(duplex: Duplex): Promise { const parsed: [string, string][] = []; for (const line of lines.slice(1)) { if (line === "") continue; + // `obs-fold` continuations (RFC 7230: a line starting with a space + // or tab) fold into the previous value. No proxied dev server emits + // them, but the parser handles them anyway, and the check runs + // BEFORE the colon check because a real continuation line carries no + // colon at all: checked after, it would be refused as malformed and + // the fold branch could never fire. + if (/^[ \t]/.test(line)) { + if (parsed.length === 0) { + cleanup(); + reject(new LaneProtocolError("folded header with no header to fold into")); + return; + } + parsed[parsed.length - 1][1] += ` ${line.trim()}`; + continue; + } const colon = line.indexOf(":"); if (colon === -1) { cleanup(); @@ -78,13 +93,6 @@ export function readLaneHead(duplex: Duplex): Promise { } const name = line.slice(0, colon).trim().toLowerCase(); const value = line.slice(colon + 1).trim(); - // `obs-fold` continuations (a line starting with a space) fold into - // the previous value; no server we proxy emits them, but the - // alternative is misparsing a folded header as two headers. - if (line.startsWith(" ") && parsed.length > 0) { - parsed[parsed.length - 1][1] += ` ${value}`; - continue; - } parsed.push([name, value]); } if (!/^HTTP\/1\.[01] \d{3}/.test(lines[0] ?? "")) { diff --git a/apps/server/api/src/preview/pv-ws.ts b/apps/server/api/src/preview/pv-ws.ts index 655dfaf71..21a97da33 100644 --- a/apps/server/api/src/preview/pv-ws.ts +++ b/apps/server/api/src/preview/pv-ws.ts @@ -44,13 +44,15 @@ import { getPreviewBroker, PreviewRefusal, type PreviewSession } from "@/service * off the close event. * * A text-frame caveat worth stating because it is the one byte-exactness - * exception: Elysia's `.ws()` message parser JSON-parses text frames that - * LOOK like JSON before this handler sees them, so an inbound text frame - * with significant whitespace re-stringifies semantically intact but not - * byte-exact. Binary frames and non-JSON text (the dev-server dialects that - * matter: Vite HMR's own JSON, VNC's binary) ride exact. Being fully raw - * would mean a hand-rolled `server.upgrade` route, which is a design change - * from the `.ws()` mount the brief registers, not a line to quietly take. + * exception, and the shapes Elysia's `.ws()` parser leaves behind are the + * ones `message` below has to sort out: a text frame that looks like JSON + * arrives parsed, so one with significant whitespace rides re-stringified + * (semantics kept, whitespace not); a text frame like `123`, `true`, or + * `null` arrives as the coerced primitive and rides on as its string form; + * plain strings, binary frames, and the dev-server dialects that matter + * (Vite HMR's compact JSON, VNC's binary) ride exact. Being fully raw would + * mean a hand-rolled `server.upgrade` route, which is a design change from + * the `.ws()` mount the brief registers, not a line to quietly take. */ /** One live browser↔lane tunnel, stashed on `ws.data` at upgrade (socket identity is not state). */ @@ -264,15 +266,33 @@ export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { message(ws, message) { const t = (ws.data as unknown as { pv: PvTunnel }).pv; if (t.closed) return; - const text = typeof message === "string"; - const bytes = text - ? Buffer.from(message as string) - : typeof message === "object" && message !== null && !Buffer.isBuffer(message) - ? // Elysia's JSON coercion (see the file header): a parsed object - // can only ride on re-stringified; semantics kept, bytes may - // differ. Plain Buffers and plain strings are untouched above. - Buffer.from(JSON.stringify(message)) - : Buffer.from((message as Buffer).buffer, (message as Buffer).byteOffset, (message as Buffer).byteLength); + // Elysia's parser hands the frame over in whatever shape its coercion + // left it, and the shape decides both the bytes and the frame type + // back out. A string rides verbatim. A coerced PRIMITIVE (the parser + // turns "123" into 123, "true" into true, "null" into null) rides as + // text in its string form: the target receives the bare digits or + // word it would receive unwrapped, which is reversible. A parsed JSON + // object can only ride re-stringified: semantics kept, whitespace + // not. Anything buffer-shaped rides byte-exact as binary. + let bytes: Buffer; + let text: boolean; + if (typeof message === "string") { + bytes = Buffer.from(message); + text = true; + } else if (message === null || typeof message !== "object") { + bytes = Buffer.from(String(message)); + text = true; + } else if (Buffer.isBuffer(message)) { + bytes = Buffer.from(message.buffer, message.byteOffset, message.byteLength); + text = false; + } else if ("buffer" in message && "byteOffset" in message) { + const view = message as ArrayBufferView; + bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength); + text = false; + } else { + bytes = Buffer.from(JSON.stringify(message)); + text = true; + } if (t.mode === "raw") { getPreviewBroker().sendLaneBytes(t.laneRef, bytes); return; From 7545a40cc65e1d6d2faade81ca6bff777e73bffe Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 04:27:43 -0700 Subject: [PATCH 045/139] fix(server): duck-check ArrayBuffer views, not JSON key names The shape sort in the tunnel's message handler recognized buffer views by the pair of key names buffer/byteOffset, so a text frame whose whole JSON content happens to pair those names parsed to a plain object, was cast to a view, and Buffer.from threw on the JSON values: the frame was destroyed the same way the primitive coercion used to be. ArrayBuffer.isView covers Buffer and every TypedArray/DataView and never matches a plain object. The coerced-frame row now carries that pathological object as a seventh frame. --- apps/server/api/src/preview/__tests__/pv-proxy.test.ts | 6 ++++++ apps/server/api/src/preview/pv-ws.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts index 581acf5f5..0f17a5d48 100644 --- a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts +++ b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts @@ -626,6 +626,7 @@ describe("pv-ws: the tunnels", () => { "true", "null", '{ "type": "update", "updates": [] }', + '{ "buffer": 1, "byteOffset": 0 }', ]; const got = await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); @@ -660,6 +661,11 @@ describe("pv-ws: the tunnels", () => { // that starts delivering raw text still passes; one that eats the // frame does not. expect(got[5]).toBe(JSON.stringify({ type: "update", updates: [] })); + // A JSON object whose content happens to pair the key names `buffer` and + // `byteOffset` is still a plain object, not a buffer view: it rides + // re-stringified text. A duck-check of those key names would read the + // JSON values as view offsets, throw, and destroy the frame. + expect(got[6]).toBe(JSON.stringify({ buffer: 1, byteOffset: 0 })); }); it("rides an upgrade-response Set-Cookie into the jar and onto the next HTTP request", async () => { diff --git a/apps/server/api/src/preview/pv-ws.ts b/apps/server/api/src/preview/pv-ws.ts index 21a97da33..e8e18cba7 100644 --- a/apps/server/api/src/preview/pv-ws.ts +++ b/apps/server/api/src/preview/pv-ws.ts @@ -285,7 +285,11 @@ export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { } else if (Buffer.isBuffer(message)) { bytes = Buffer.from(message.buffer, message.byteOffset, message.byteLength); text = false; - } else if ("buffer" in message && "byteOffset" in message) { + } else if (ArrayBuffer.isView(message)) { + // isView, not a duck-check of the key names: a text frame whose whole + // JSON content happens to pair `buffer` and `byteOffset` parses to a + // plain object, and the duck would read the JSON values as view + // offsets and destroy the frame. const view = message as ArrayBufferView; bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength); text = false; From 2a897f978782c6c71a614a274f3a0e2149fb86ef Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 04:45:02 -0700 Subject: [PATCH 046/139] feat(server): live watch subscriptions through the authorization control --- .../src/ws/__tests__/live-consistency.test.ts | 247 +++++++++++++++- .../api/src/ws/__tests__/live-ws.test.ts | 272 +++++++++++++++++- apps/server/api/src/ws/live-registry.ts | 29 ++ apps/server/api/src/ws/live-ws.ts | 159 +++++++++- apps/server/api/src/ws/ws.plugin.ts | 9 +- 5 files changed, 700 insertions(+), 16 deletions(-) diff --git a/apps/server/api/src/ws/__tests__/live-consistency.test.ts b/apps/server/api/src/ws/__tests__/live-consistency.test.ts index bce9c8681..16fe0c593 100644 --- a/apps/server/api/src/ws/__tests__/live-consistency.test.ts +++ b/apps/server/api/src/ws/__tests__/live-consistency.test.ts @@ -1,12 +1,16 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; import { listSubshellsRoute } from "@/api/subshells/list-subshells.route.js"; import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; -import { handleLiveClose, handleLiveOpen, type LiveWsSocket, liveWsDeps } from "@/ws/live-ws.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { sendPreviewStreamSocket } from "@/ws/live-registry.js"; +import { handleLiveClose, handleLiveMessage, handleLiveOpen, type LiveWsSocket, liveWsDeps } from "@/ws/live-ws.js"; import { issueWsToken } from "@/ws/ws-token.js"; /** @@ -130,3 +134,242 @@ describe("the live feed's snapshot matches the REST list (one cache, two produce expect(snapshot.map((s) => s.id).sort()).toEqual(rest.map((s) => s.id).sort()); }); }); + +/** + * The preview half of THE control (§11.14): for `watch` subscriptions, the + * sockets that receive `preview-stream` for a pane must be EXACTLY the view-+ + * holders that sent `watch`. Same doctrine as the snapshot rows above, with + * the sharpest edge yet: an admin WITHOUT a grant gets nothing (the 2026-10-09 + * ruling, asserted here at the stream fan-out, not just at the list), and the + * owner's own second socket that never watched gets nothing. + * + * Nothing here stands on a stub: the sockets open through the real + * `handleLiveOpen` with the real `liveWsDeps()`, the access answers come from + * real share rows, the revocation rows call the very + * `closeUnauthorizedForPane` verb the shares route calls, and the frames ride + * the real broker mechanics with the shipped serializer installed. Only the + * node link is faked (there is no node), and even that records its commands. + * + * Before the revocation rows, note this file carried NO revocation case + * (its two snapshot rows are pure feed-vs-REST equality). The socket-alive + * precedent is `live-ws.ts`'s resync path: a client that received a row it + * cannot render ASKS for the list again and its socket keeps running. These + * rows match THAT behavior for the stream half: frames stop, the socket + * lives, the canvas goes dark; nothing closes. + */ + +const pvPassword = "live-preview-pass-1"; +const pvOwnerEmail = `pv-owner-${crypto.randomUUID()}@subshell.local`; +const pvEditEmail = `pv-edit-${crypto.randomUUID()}@subshell.local`; +const pvViewEmail = `pv-view-${crypto.randomUUID()}@subshell.local`; +const pvAdminEmail = `pv-admin-${crypto.randomUUID()}@subshell.local`; + +const PV_NODE = "n-pv-fake"; +let pvPaneId = ""; +let pvOwnerId = ""; +let pvEditId = ""; +let pvViewId = ""; +let pvAdminId = ""; + +/** The pane's bridge ref after the per-test open; every row streams from it. */ +let pvBridgeRef = ""; +const pvFeeds: { ws: LiveWsSocket; closed: { code?: number; reason?: string }[]; sent: string[] }[] = []; + +/** Opens the feed as `userId` through the REAL deps and awaits the snapshot. */ +async function openFeed(userId: string) { + const token = issueWsToken(userId); + const sent: string[] = []; + const closed: { code?: number; reason?: string }[] = []; + const ws: LiveWsSocket = { + data: { query: { token } }, + send(data: string) { + sent.push(data); + return 1; + }, + close(code?: number, reason?: string) { + closed.push({ code, reason }); + }, + }; + pvFeeds.push({ ws, closed, sent }); + await handleLiveOpen(ws, liveWsDeps()); + return { ws, closed, sent }; +} + +function streamCount(feed: { sent: string[] }): number { + return feed.sent.filter((s) => (JSON.parse(s) as { type: string }).type === "preview-stream").length; +} + +function watchPane(ws: LiveWsSocket, paneId: string): Promise { + return handleLiveMessage(ws, JSON.stringify({ type: "watch", id: paneId }), liveWsDeps()); +} + +function pvStreamEvent(seq: number): Extract { + return { + type: "preview_stream", + ref: pvBridgeRef, + seq, + w: 80, + h: 25, + ts: 1_780_000_000_000 + seq, + blob: `pv-${seq}`, + }; +} + +/** Shares writes go through the very repository the shares route uses. */ +const pvShares = new SubshellSharesRepository(db); + +describe("preview-stream reaches EXACTLY the view-+ holders that sent watch (the §11.14 stream half)", () => { + beforeAll(async () => { + await setupAuthTables(); + const users = new UsersRepository(db); + pvOwnerId = await users.createUser({ + email: pvOwnerEmail, + name: pvOwnerEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvEditId = await users.createUser({ + email: pvEditEmail, + name: pvEditEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvViewId = await users.createUser({ + email: pvViewEmail, + name: pvViewEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvAdminId = await users.createUser({ + email: pvAdminEmail, + name: pvAdminEmail, + passwordHash: await hashPassword(pvPassword), + role: "admin", + }); + pvPaneId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: pvPaneId, + userId: pvOwnerId, + presetId: "p", + harnessId: "claude-code", + name: "pv-pane", + workingDir: "/tmp", + tmuxSocket: null, + }); + await pvShares.replaceForSubshell( + pvPaneId, + [ + { granteeUserId: pvEditId, permission: "edit" }, + { granteeUserId: pvViewId, permission: "view" }, + ], + pvOwnerId, + ); + }); + + beforeEach(async () => { + // Fake the link, keep the fan-out real: the installed sender is the + // shipped serializer, so the broker fans out exactly as production + // would, JSON and all. (Passing it explicitly also makes the rows + // order-independent against the process-global boot slot in a serial + // run.) The broker's access seam stays PRODUCTION here: the revocation + // rows must recompute against the same share rows the routes rewrote. + resetForTests({ + send: async (_nodeId: string, cmd: NodeCommandBody): Promise => { + if (cmd.type === "preview_open") return { ref: cmd.ref, subscribers: 1 }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + return {}; + }, + sendToSockets: sendPreviewStreamSocket, + }); + await getPreviewBroker().startWatch({ viewerUserId: pvOwnerId, paneId: pvPaneId, nodeId: PV_NODE, port: 9222 }); + const bridge = getPreviewBroker().sessionForPane(pvPaneId); + if (!bridge) throw new Error("preview matrix: the bridge did not stand"); + pvBridgeRef = bridge.ref; + pvFeeds.length = 0; + }); + + afterAll(async () => { + for (const feed of pvFeeds) handleLiveClose(feed.ws); + shutdownPreviewBrokerForTests(); + await db.deleteFrom("subshellShares").where("subshellId", "=", pvPaneId).execute(); + await db.deleteFrom("subshells").where("id", "=", pvPaneId).execute(); + await deleteUserByEmailOrId(pvOwnerEmail); + await deleteUserByEmailOrId(pvEditEmail); + await deleteUserByEmailOrId(pvViewEmail); + await deleteUserByEmailOrId(pvAdminEmail); + }); + + it("after watch, the sockets that receive frames are EXACTLY the view-+ holders that watched", async () => { + const owner = await openFeed(pvOwnerId); + const ownerSibling = await openFeed(pvOwnerId); // second tab of the same holder, never watches + const editor = await openFeed(pvEditId); + const viewer = await openFeed(pvViewId); + const admin = await openFeed(pvAdminId); // role grants nothing on a foreign pane + + await watchPane(owner.ws, pvPaneId); + await watchPane(editor.ws, pvPaneId); + await watchPane(viewer.ws, pvPaneId); + // ownerSibling and admin send nothing. + + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + + const recipients = [owner, ownerSibling, editor, viewer, admin].filter((f) => streamCount(f) === 1); + expect(recipients.length).toBe(3); + expect(streamCount(owner)).toBe(1); // owner counts as view-+ + expect(streamCount(editor)).toBe(1); + expect(streamCount(viewer)).toBe(1); + expect(streamCount(ownerSibling)).toBe(0); // the owner's socket that never watched: nothing + expect(streamCount(admin)).toBe(0); // an admin without a grant: nothing + // And what arrived is the frame, blob untouched, id addressed by pane + // (the bridge ref never crosses to the browser). + expect( + owner.sent.map((s) => JSON.parse(s) as Record).find((f) => f.type === "preview-stream"), + ).toEqual({ type: "preview-stream", id: pvPaneId, seq: 1, w: 80, h: 25, blob: "pv-1" }); + }); + + it("a watch from an admin with no grant attaches nothing and closes nothing", async () => { + const admin = await openFeed(pvAdminId); + // The admin's ROLE must not open the stream: watch resolves through the + // grants axis like every other pane door, and there is no grant. + await watchPane(admin.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect(streamCount(admin)).toBe(0); + expect(admin.closed).toEqual([]); // invisible reads as silence, never as a close + }); + + it("stops frames to a socket whose grant is removed mid-stream while the socket itself lives", async () => { + const owner = await openFeed(pvOwnerId); + const viewer = await openFeed(pvViewId); + await watchPane(owner.ws, pvPaneId); + await watchPane(viewer.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect([streamCount(owner), streamCount(viewer)]).toEqual([1, 1]); + + // The shares cut, exactly as the route performs it: rewrite the rows, + // then re-ask the broker's recompute. + await pvShares.replaceForSubshell(pvPaneId, [{ granteeUserId: pvEditId, permission: "edit" }], pvOwnerId); + await getPreviewBroker().closeUnauthorizedForPane(pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(2)); + + expect(streamCount(viewer)).toBe(1); // seq 2 never arrives + expect(viewer.closed).toEqual([]); // the socket stays live (the resync precedent: the client re-asks, nothing closes it) + expect(streamCount(owner)).toBe(2); // untouched holders keep streaming + }); + + it("keeps stream frames flowing through an edit-to-view downgrade (watch is what view buys)", async () => { + const editor = await openFeed(pvEditId); + await watchPane(editor.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect(streamCount(editor)).toBe(1); + + await pvShares.replaceForSubshell(pvPaneId, [{ granteeUserId: pvEditId, permission: "view" }], pvOwnerId); + await getPreviewBroker().closeUnauthorizedForPane(pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(2)); + + // The stream half: `view` still buys the screencast, so the SAME socket + // keeps receiving. What the downgrade does close is the EDIT tier + // (forward lanes) - the split Task 8's broker test owns. + expect(streamCount(editor)).toBe(2); + expect(editor.closed).toEqual([]); + }); +}); diff --git a/apps/server/api/src/ws/__tests__/live-ws.test.ts b/apps/server/api/src/ws/__tests__/live-ws.test.ts index 581f5a85a..e4538a097 100644 --- a/apps/server/api/src/ws/__tests__/live-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/live-ws.test.ts @@ -1,4 +1,13 @@ import { describe, expect, it } from "bun:test"; +import { + type NodeCommandBody, + type NodeEvent, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewStreamFrame, +} from "@internal/subshell-protocol"; +import { getPreviewBroker, resetForTests } from "@/services/preview-broker.js"; +import { sendPreviewStreamSocket, sendToSockets } from "@/ws/live-registry.js"; import { EVERYONE_TOPIC, userTopic } from "@/ws/live-topics.js"; import { handleLiveClose, @@ -20,14 +29,19 @@ import { * the socket, which is how the first version of this endpoint leaked an * interval per closed socket while its test stayed green. */ -function fakeSocket(query: Record = {}) { +function fakeSocket(query: Record = {}, raw: unknown = { getBufferedAmount: () => 0 }) { const sent: string[] = []; const closed: { code?: number; reason?: string }[] = []; const subscribed: string[] = []; const data: LiveWsSocket["data"] = { query }; let readyState = 1; // OPEN + // `raw` is the SAME instance in both wrappers, as in production: the socket + // the watch adapter hands the broker reads its byte debt through it, so a + // double that fakes the raw socket per-wrapper would miss exactly the + // property the adapter exists to carry. const wrapper = (): LiveWsSocket => ({ data, + raw, get readyState() { return readyState; }, @@ -68,6 +82,10 @@ function deps(over: Partial = {}): LiveWsDeps { // factory hands out. Individual cases flip it. accountDisabled: async () => false, passwordChangeRequired: async () => false, + // The `watch` gate asks one question; the healthy answer by default is + // this socket's owner watching their own pane. Rows that probe the gate + // flip it (a `view` grant, a foreign `none`). + accessFor: async () => "owner", ...over, }; } @@ -371,3 +389,255 @@ describe("/ws/live bounded work and resync", () => { expect(frames().filter((f) => f.type === "preview")).toEqual([]); }); }); + +/* ------------------------------------------------------------------ */ +/* watch/unwatch: the screencast fan-out wired through the broker */ +/* ------------------------------------------------------------------ */ + +/** + * Rebuilds the broker over fake node seams with the REAL fan-out sender + * installed (`sendPreviewStreamSocket`, the very function `live-ws.ts` wires + * at module init). Passing the seam explicitly keeps every row below + * deterministic no matter which sender won the process-global boot slot in + * a serial run: what these rows exercise is the real broker mechanics and + * the real serialization, driven through the real handler. + */ +function fakePreviewNode(): { commands: NodeCommandBody[] } { + const commands: NodeCommandBody[] = []; + resetForTests({ + send: async (_nodeId: string, cmd: NodeCommandBody): Promise => { + commands.push(cmd); + if (cmd.type === "preview_open") return { ref: cmd.ref, subscribers: 1 }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + return {}; + }, + sendToSockets: sendPreviewStreamSocket, + }); + return { commands }; +} + +/** The pane's live screencast bridge, built the way the REST watch path builds it. */ +async function openBridge(paneId: string, nodeId = "n-fake"): Promise { + const broker = getPreviewBroker(); + await broker.startWatch({ viewerUserId: "u-holder", paneId, nodeId, port: 9222 }); + const ref = broker.sessionForPane(paneId)?.ref; + if (!ref) throw new Error(`bridge for ${paneId} did not stand`); + return ref; +} + +function streamEvent(ref: string, seq: number): Extract { + return { type: "preview_stream", ref, seq, w: 64, h: 32, ts: 1_780_000_000_000 + seq, blob: `blob-${seq}` }; +} + +function streamFrames(sent: string[]): Record[] { + return sent.map((s) => JSON.parse(s) as Record).filter((f) => f.type === "preview-stream"); +} + +describe("/ws/live watch subscriptions", () => { + it("a watch from a view grant attaches the socket; frames arrive with the blob untouched, and only there", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + // Two sockets of the SAME holder (both redeem to u1): only the first + // sends `watch`, so only the first may receive. + const watcher = fakeSocket({ token: "good" }); + const sibling = fakeSocket({ token: "good" }); + const d = deps({ accessFor: async () => "view" }); + await handleLiveOpen(watcher.ws, d); + await handleLiveOpen(sibling.ws, d); + await handleLiveMessage(watcher.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(streamFrames(watcher.sent)).toEqual([ + { type: "preview-stream", id: "p-1", seq: 1, w: 64, h: 32, blob: "blob-1" }, + ]); + expect(streamFrames(sibling.sent)).toEqual([]); + handleLiveClose(watcher.closeWs); + handleLiveClose(sibling.closeWs); + }); + + it("a watch the access check fails attaches nothing and never closes the socket", async () => { + fakePreviewNode(); + const ref = await openBridge("p-foreign"); + const asked: string[] = []; + const d = deps({ + accessFor: async (_u, paneId) => { + asked.push(paneId); + return "none"; + }, + }); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + // Spy straight at the broker: the check must be the last thing standing + // between a stranger and the stream set. + const broker = getPreviewBroker(); + const realAttach = broker.attachStream; + const attached: string[] = []; + broker.attachStream = (paneId, socketKey, holderUserId, socket) => { + attached.push(paneId); + return realAttach(paneId, socketKey, holderUserId, socket); + }; + + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-foreign" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(asked).toEqual(["p-foreign"]); // the gate was asked + expect(attached).toEqual([]); // and refused: nothing reached the broker + expect(streamFrames(sent)).toEqual([]); + expect(closed).toEqual([]); // invisible reads as silence, never as a refusal + handleLiveClose(closeWs); + }); + + it("unwatch stops delivery and leaves the socket open", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + const d = deps(); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(sent)).toHaveLength(1); + + await handleLiveMessage(ws, JSON.stringify({ type: "unwatch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 2)); + + expect(streamFrames(sent)).toHaveLength(1); // seq 2 never arrived + expect(closed).toEqual([]); + handleLiveClose(closeWs); + }); + + /** + * THE swapped-wrapper pin. Elysia hands `close` a fresh `ElysiaWS` that + * shares `data` with the one `open` got (the file-header measurement), so + * a stream registry keyed by the SOCKET OBJECT would find nothing here and + * leak the entry for the pane's life. This double builds a fresh wrapper + * per call exactly like the adapter does: closing through `closeWs` (never + * the `ws` that attached) must still detach. + */ + it("closing through the swapped wrapper detaches every pane; the holder's other socket is unaffected", async () => { + fakePreviewNode(); + const refA = await openBridge("p-a"); + const refB = await openBridge("p-b"); + const d = deps(); + const a = fakeSocket({ token: "good" }); + const b = fakeSocket({ token: "good" }); + await handleLiveOpen(a.ws, d); + await handleLiveOpen(b.ws, d); + for (const pane of ["p-a", "p-b"]) { + await handleLiveMessage(a.ws, JSON.stringify({ type: "watch", id: pane }), d); + await handleLiveMessage(b.ws, JSON.stringify({ type: "watch", id: pane }), d); + } + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refA, 1)); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refB, 1)); + expect(streamFrames(a.sent)).toHaveLength(2); + expect(streamFrames(b.sent)).toHaveLength(2); + + // Close socket a through the wrapper it never attached with. + handleLiveClose(a.closeWs); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refA, 2)); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refB, 2)); + expect(streamFrames(a.sent)).toHaveLength(2); // both panes detached by one close + expect(streamFrames(b.sent)).toHaveLength(4); // sibling untouched, both panes still flowing + handleLiveClose(b.closeWs); + }); + + it("byte debt on one socket detaches THAT socket through the raw-socket adapter; bridge and lane stay untouched", async () => { + const node = fakePreviewNode(); + const ref = await openBridge("p-1"); + const d = deps(); + // The lagging socket's raw reports debt above the cap; the handler's + // adapter must read the METHOD off `raw` (the wrapper carries neither + // property nor method - Task 8's measurement is why this exists at all). + const slow = fakeSocket({ token: "good" }, { getBufferedAmount: () => PREVIEW_STREAM_DEBT_MAX + 1 }); + const fast = fakeSocket({ token: "good" }); + await handleLiveOpen(slow.ws, d); + await handleLiveOpen(fast.ws, d); + await handleLiveMessage(slow.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + await handleLiveMessage(fast.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(slow.sent)).toEqual([]); // dropped before its frame was sent + expect(streamFrames(fast.sent)).toHaveLength(1); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 2)); + expect(streamFrames(slow.sent)).toEqual([]); // detached, not merely skipped + expect(streamFrames(fast.sent)).toHaveLength(2); + + // The drop is viewer-side only: the bridge still stands and the wire saw + // no close of anything (spec §9 drops slow viewers, never the bridge). + expect(getPreviewBroker().sessionForPane("p-1")).not.toBeNull(); + expect(node.commands.filter((c) => c.type === "preview_close")).toEqual([]); + handleLiveClose(slow.closeWs); + handleLiveClose(fast.closeWs); + }); + + /** + * The ninth socket's experience is the dark canvas: the attach cap refuses + * with a VALUE plus an audit row (both Task 8's), and this row pins the + * handler's half: the refusal is swallowed. Not thrown, not a close, not a + * registration. + */ + it("a watch the attach cap refuses leaves the socket open and unregistered", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + const broker = getPreviewBroker(); + const quiet: string[] = []; + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker.attachStream("p-1", `fill-${i}`, "u-holder", { send: (d) => quiet.push(d) })).toEqual({ ok: true }); + } + const d = deps(); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + + await expect(handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d)).resolves.toBeUndefined(); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(streamFrames(sent)).toEqual([]); // unregistered: the pane's frames skip it + expect(closed).toEqual([]); // and it stays open on the feed + expect(quiet).toHaveLength(PREVIEW_WATCH_SUBSCRIBERS_MAX); // the eight it displaced keep streaming + handleLiveClose(closeWs); + }); + + it("a watch that lands after the socket closed during the access check attaches nothing", async () => { + // `liveStop` is the close marker: a close that lands while the gate's + // await is in flight must not leave a dead socket in the stream set. + fakePreviewNode(); + const ref = await openBridge("p-1"); + let release: () => void = () => {}; + const gate = new Promise((r) => { + release = r; + }); + const d = deps({ + accessFor: async () => { + await gate; + return "view"; + }, + }); + const { ws, closeWs, sent } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + const pending = handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + handleLiveClose(closeWs); + release(); + await pending; + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(sent)).toEqual([]); + }); +}); + +describe("live-registry preview fan-out primitives", () => { + it("one send per socket, JSON-serialized in both shapes", () => { + const frame: PreviewStreamFrame = { type: "preview-stream", id: "p", seq: 7, w: 4, h: 2, blob: "zz" }; + const a: string[] = []; + const b: string[] = []; + const sa = { send: (d: string) => a.push(d) }; + const sb = { send: (d: string) => b.push(d) }; + + sendPreviewStreamSocket(sa, frame); + sendToSockets([sa, sb], frame); + + expect(a).toEqual([JSON.stringify(frame), JSON.stringify(frame)]); + expect(b).toEqual([JSON.stringify(frame)]); + }); +}); diff --git a/apps/server/api/src/ws/live-registry.ts b/apps/server/api/src/ws/live-registry.ts index b146d15c4..813f89d17 100644 --- a/apps/server/api/src/ws/live-registry.ts +++ b/apps/server/api/src/ws/live-registry.ts @@ -1,3 +1,5 @@ +import type { PreviewStreamFrame } from "@internal/subshell-protocol"; +import type { PreviewStreamSocket } from "@/services/preview-broker.js"; import type { LiveWsSocket } from "@/ws/live-ws.js"; /** @@ -72,3 +74,30 @@ export function dropLiveSocketsFor(userId: string, reason = "role changed"): num export function resetLiveRegistryForTests(): void { byUser.clear(); } + +/** + * Serialize one preview frame and hand it to one socket. + * + * This is the delivery primitive the broker's fan-out calls per recipient, and + * the one place a `preview-stream` frame becomes bytes. It lives beside the + * registry that already owns "who is connected" so the serializer never needs + * to know about the broker and the broker never needs to know about sockets: + * `live-ws.ts` installs it at module init, and tests pass it to the broker's + * seam explicitly. The frame is stringified as-is; the broker already built it + * from a pane's screen, so the blob crosses untouched. + */ +export function sendPreviewStreamSocket(socket: PreviewStreamSocket, frame: PreviewStreamFrame): void { + socket.send(JSON.stringify(frame)); +} + +/** + * Send one frame to every socket in `sockets`. + * + * Production wires the per-socket function above (the broker checks each + * recipient's byte debt between sends and drops slow ones mid-fan-out), so + * this loop is for callers that already hold a settled audience rather than a + * reason to re-check it. + */ +export function sendToSockets(sockets: Iterable, frame: PreviewStreamFrame): void { + for (const socket of sockets) sendPreviewStreamSocket(socket, frame); +} diff --git a/apps/server/api/src/ws/live-ws.ts b/apps/server/api/src/ws/live-ws.ts index 06356c7c6..583c66032 100644 --- a/apps/server/api/src/ws/live-ws.ts +++ b/apps/server/api/src/ws/live-ws.ts @@ -1,13 +1,26 @@ import { type LiveServerFrame, parseLiveClientFrame } from "@internal/subshell-protocol"; import { getRequestlessContext } from "@/lib/context.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; import { accountDisabled } from "@/services/account-status.js"; import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; +import { configurePreviewStreamSender, getPreviewBroker } from "@/services/preview-broker.js"; import type { SubshellsService } from "@/services/subshells.service.js"; import { logger } from "@/utils/logger.js"; -import { registerLiveSocket, unregisterLiveSocket } from "@/ws/live-registry.js"; +import { registerLiveSocket, sendPreviewStreamSocket, unregisterLiveSocket } from "@/ws/live-registry.js"; import { topicsForViewer } from "@/ws/live-topics.js"; import { consumeWsToken, type WsTokenIdentity } from "@/ws/ws-token.js"; +/** + * The broker's ONE crossing into the socket layer, installed at module load + * so no `watch` can attach a stream before its serializer exists. + * + * The assignment is pure: it constructs nothing, so import stays cheap and + * the compiled binary's non-boot subcommands are unaffected; the broker + * itself remains lazy behind `getPreviewBroker()`, called only from the + * handlers below. + */ +configurePreviewStreamSender(sendPreviewStreamSocket); + /** * Exactly what the sharing-aware list answers with, derived from the method * itself rather than restated. @@ -49,7 +62,25 @@ export interface LiveWsSocket { liveViewerId?: string; /** True while a previews request is being served — see {@link handleLiveMessage}. */ liveCapturing?: boolean; + /** + * The socket-lifetime key the preview stream set holds this socket + * under. `liveViewerId` answers WHO (a user, shared by all their tabs); + * this answers WHICH SOCKET, because a `watch` subscription belongs to + * one connection: a reconnect is legitimately a new viewer (the same + * reasoning `subshell-ws.ts` carries for its viewer id). It rides + * `data` rather than being the socket object because the object is + * swapped per callback, see this interface's header. + */ + streamViewerId?: string; }; + /** + * The underlying socket. Elysia keeps backpressure (`getBufferedAmount`) + * on the RAW side only, measured in `remote-subshell-ws.ts`; the preview + * adapter reads the method off this exactly as that file's + * `RawWithBackpressure` does. Optional so hand-rolled doubles may omit + * it, which the adapter reads as zero debt. + */ + raw?: unknown; send(data: string): unknown; /** * Joins a pub/sub topic (Bun's own, forwarded by Elysia). Optional so a @@ -89,6 +120,15 @@ export interface LiveWsDeps { accountDisabled(userId: string): Promise; /** Human viewers must finish temporary-password recovery before receiving the feed. */ passwordChangeRequired(userId: string): Promise; + /** + * The viewer's access to ONE pane, the whole question a `watch` or + * `unwatch` asks before touching the preview stream set. A seam because + * the answer needs the database, and it is the SAME question the REST + * preview gate asks (`preview-gate` → `loadSubshellAccess`), not a second + * reading of it: one resolver keeps the two doors of one pane answering + * identically, which is the whole point of the §11.14 control. + */ + accessFor(userId: string, paneId: string): Promise; } /** @@ -145,6 +185,11 @@ export async function handleLiveOpen(ws: LiveWsSocket, deps: LiveWsDeps): Promis // Who this socket belongs to, for the messages it may send later. Stashed // rather than re-derived: the token is single-use and already spent. ws.data.liveViewerId = userId; + // And WHICH socket this is, for the preview stream set a later `watch` + // joins. Minted here, before any await: the message handler can arrive on + // a fresh socket immediately, and a key that appears only after some check + // would silently make the first watch unrevocable by close. + ws.data.streamViewerId = crypto.randomUUID(); // Findable so a revocation can reach it: the disable drop is the // load-bearing one (a disabled account must not keep streaming, and cannot // re-mint), and a role change still closes the socket so the reconnect @@ -225,6 +270,15 @@ export async function handleLiveOpen(ws: LiveWsSocket, deps: LiveWsDeps): Promis */ export function handleLiveClose(ws: LiveWsSocket): void { ws.data?.liveStop?.(); + // Stream subscriptions die with the connection, keyed by the + // socket-lifetime id that `open` minted. The key is read from `data` for + // the same reason the stopper is: the wrapper `close` receives is not the + // one that attached. Detaching through the broker rather than a local set + // is the point: the broker owns the pane-side bookkeeping (per-pane + // counts, idle-bridge closing) and a close that only edited a local list + // would strand those. Safe when this socket watched nothing. + const streamKey = ws.data?.streamViewerId; + if (streamKey) getPreviewBroker().detachSocketsFor(streamKey); } /** @@ -246,6 +300,19 @@ export function liveWsDeps(): LiveWsDeps { previewsFor: (userId, ids) => getRequestlessContext().services.subshells.previewsFor(userId, ids), accountDisabled: (userId) => accountDisabled(getRequestlessContext().db, userId), passwordChangeRequired: (userId) => backupPasswordChangeRequired(getRequestlessContext().db, userId), + // The ordinary gate, exactly as `attach-resolve` and the REST preview + // gate call it: shares count (a pane's own key never reaches this + // socket anyway - a scoped token is refused at redemption), roles carry + // no arm here since 2026-10-09. + accessFor: async (userId, paneId) => { + const { repos } = getRequestlessContext(); + const { access } = await loadSubshellAccess( + { subshells: repos.subshells, shares: repos.subshellShares }, + userId, + paneId, + ); + return access; + }, }; } @@ -261,15 +328,19 @@ export function liveWsDeps(): LiveWsDeps { export const MAX_PREVIEW_REQUEST = 60; /** - * Handles a client frame. Today the only thing this handler acts on is a - * screens ask; `watch`/`unwatch` are grammar ahead of wiring, parsed by the - * shared parser and ignored here until the screencast fan-out lands. + * Handles a client frame: a screens ask is answered here (pulled), and a + * `watch`/`unwatch` joins or leaves the broker's pushed screencast fan-out + * (see {@link handlePreviewSubscription}). Both paths resolve the pane + * through the ordinary access gate, so the socket can never carry a row or a + * stream its owner could not fetch over REST. * * Previews are PULLED rather than pushed (spec 2026-09-19 §4.4): the cards are * the one surface that renders them, so a page showing none costs nothing, and - * a card that wants a fresher screen after a change asks again. That keeps the - * fan-out free of per-socket state — the server holds no watch list, it just - * answers. + * a card that wants a fresher screen after a change asks again. That answer + * path keeps no per-socket state: the server holds no screens list, it just + * answers. The STREAM path is where subscription state lives, and it lives + * in the preview broker rather than here: this handler is the broker's one + * door. * * The ids are filtered to what this viewer may see by the ordinary visible-set * read; an id they cannot see is simply absent from the answer, never refused, @@ -299,9 +370,13 @@ export async function handleLiveMessage(ws: LiveWsSocket, raw: unknown, deps: Li return; } - // `watch`/`unwatch` parse (the feed's grammar is shared, and Task 8 will - // fan screencasts out from here), but nothing is wired to them yet: a - // subscription asks for a stream this handler cannot send, so it is a no-op. + // The screencast subscription verbs. Both gate on the same access question + // and both answer a refusal with silence: no frame back, no close, no + // signal a stranger can read as "this pane exists". + if (frame.type === "watch" || frame.type === "unwatch") { + await handlePreviewSubscription(ws, userId, frame.type, frame.id, deps); + return; + } if (frame.type !== "previews") return; const wanted = frame.ids.slice(0, MAX_PREVIEW_REQUEST); @@ -333,6 +408,70 @@ export async function handleLiveMessage(ws: LiveWsSocket, raw: unknown, deps: Li } } +/** + * The raw-socket backpressure shape, restated narrow exactly as + * `remote-subshell-ws.ts` does: the wrapper carries neither the property nor + * a method, so the byte-debt the broker checks before every send has to come + * off `raw`. + */ +type RawWithBackpressure = { getBufferedAmount?: () => number }; + +/** + * Joins or leaves one pane's screencast fan-out on behalf of this socket. + * + * The gate is the ordinary one (`view` or better through the shared + * resolver, shares on, roles carrying no arm since 2026-10-09), and every + * refusal path is silence: the caller's socket keeps streaming its feed, and + * nothing written here ever closes it. `unwatch` runs the same gate for the + * same grammar, though its refusal is meaningless to enforce (detaching what + * is not attached is a no-op); the check just keeps the pair uniform. + * + * After the gate, the broker owns everything: the per-pane subscriber cap + * (its refusal is a RETURNED VALUE plus its own audit row; the socket's + * experience is a dark canvas, so the value is swallowed here), the + * slow-viewer byte-debt drop, and the revocation sweep. What crosses to the + * broker is the narrowest socket it accepts (a send-through plus the raw + * debt read), keyed by the socket-lifetime id so a close can always find + * this entry even after its wrapper was swapped. + */ +async function handlePreviewSubscription( + ws: LiveWsSocket, + userId: string, + verb: "watch" | "unwatch", + paneId: string, + deps: LiveWsDeps, +): Promise { + let access: Access; + try { + access = await deps.accessFor(userId, paneId); + } catch (err) { + // Fail CLOSED: an unanswered access question is no access. The socket + // stays open on its feed; the next frame will ask again on its own. + logger.withError(err).warn("live ws: could not resolve pane access for a watch; treating as none"); + return; + } + if (!accessAtLeast(access, "view")) return; + // The gate suspends, and a close may land in the window: `liveStop` is + // cleared by `handleLiveClose`, so its absence means this socket is dead + // and must not join (or leave) a set it can no longer be detached from. + // Same marker the previews loop below leans on. + if (!ws.data.liveStop) return; + const streamKey = ws.data.streamViewerId; + if (!streamKey) return; + const broker = getPreviewBroker(); + if (verb === "unwatch") { + broker.detachStream(paneId, streamKey); + return; + } + broker.attachStream(paneId, streamKey, userId, { + send: (data) => ws.send(data), + getBufferedAmount: () => (ws.raw as RawWithBackpressure | undefined)?.getBufferedAmount?.() ?? 0, + }); + // The return value is the cap's refusal or acceptance, and both end this + // handler's work identically; the audit trail a refusal writes belongs to + // the broker, which owns the cap. +} + /** One send, typed against the shared envelope so a frame the browser cannot parse is a type error. */ function sendFrame(ws: LiveWsSocket, frame: LiveFrame): void { ws.send(JSON.stringify(frame)); diff --git a/apps/server/api/src/ws/ws.plugin.ts b/apps/server/api/src/ws/ws.plugin.ts index 2e21e6111..4531cd1bf 100644 --- a/apps/server/api/src/ws/ws.plugin.ts +++ b/apps/server/api/src/ws/ws.plugin.ts @@ -127,8 +127,10 @@ wsPlugin.ws("/ws/node", { * upgrade-time identity lookup. Its upgrade hook enforces the browser origin * boundary before the token is redeemed in `open`. * - * The client sends nothing on this socket yet, so there is no `message` - * handler to write; `watch-previews` arrives with the preview work. + * The client sends `previews` (pulled screens), `watch`/`unwatch` (the pushed + * screencast fan-out, wired through the preview broker), and `resync` when a + * broadcast carries a row it has never seen; `handleLiveMessage` owns all + * three, and every pane it touches passes the ordinary access gate first. */ wsPlugin.ws("/ws/live", { upgrade({ request }) { @@ -143,7 +145,8 @@ wsPlugin.ws("/ws/live", { }); }, message(ws, message) { - // The one thing a client may ask for is screens (spec 2026-09-19 §4.4). + // Screens, stream subscriptions, and resync (spec 2026-09-19 §4.4 for the + // first and last; the watch pair is the §11.14 fan-out). // Elysia JSON-parses frames beginning with `{`, so this arrives as either // a string or an object; the handler accepts both and ignores the rest. void handleLiveMessage(ws as unknown as LiveWsSocket, message, liveWsDeps()).catch((err: unknown) => { From ba6eff53b8eb2f9859a83e3adeb2dc69ebb7bd58 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 05:01:21 -0700 Subject: [PATCH 047/139] test(server): tell the truth in two preview watch comments The slow-link row cannot tell detached from skipped-again while the debt stays over cap (the broker's viewer_drop row owns the deletion), and the boot-slot row's inheritance depends on module-load order in serial runs: the first-call-wins slot is filled by whichever module loads first. The review's two Minors, said plainly. --- .../api/src/services/__tests__/preview-broker.test.ts | 8 ++++++-- apps/server/api/src/ws/__tests__/live-ws.test.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/api/src/services/__tests__/preview-broker.test.ts b/apps/server/api/src/services/__tests__/preview-broker.test.ts index da35978fa..e9af477d3 100644 --- a/apps/server/api/src/services/__tests__/preview-broker.test.ts +++ b/apps/server/api/src/services/__tests__/preview-broker.test.ts @@ -793,8 +793,12 @@ describe("stream sockets: the attach cap, the keying, the byte debt", () => { it("configurePreviewStreamSender fills the fan-out slot for a broker built without a fake sender", async () => { const seen: unknown[] = []; - // Boot order, as Task 11 will call it: the slot is filled first, and the - // broker built after it inherits the sender. + // Boot order, as Task 11 calls it: the slot is filled first, and the + // broker built after it inherits the sender. The slot is first-call-wins: + // in a serial run the winner is decided by module-load order (this file's + // spy fills it before live-ws.ts's module init can), and a directory move + // would flip it. Under the parallel regime every file gets its own + // process, so there is no contest there. configurePreviewStreamSender((_socket, frame) => { seen.push(frame); }); diff --git a/apps/server/api/src/ws/__tests__/live-ws.test.ts b/apps/server/api/src/ws/__tests__/live-ws.test.ts index e4538a097..5a613ddce 100644 --- a/apps/server/api/src/ws/__tests__/live-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/live-ws.test.ts @@ -561,7 +561,10 @@ describe("/ws/live watch subscriptions", () => { expect(streamFrames(fast.sent)).toHaveLength(1); getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 2)); - expect(streamFrames(slow.sent)).toEqual([]); // detached, not merely skipped + // Still silent. This row cannot tell "detached" from "skipped again" + // while the debt stays over cap; the deletion itself is pinned by the + // broker's viewer_drop row in preview-broker.test.ts. + expect(streamFrames(slow.sent)).toEqual([]); expect(streamFrames(fast.sent)).toHaveLength(2); // The drop is viewer-side only: the bridge still stands and the wire saw From 744e626184ead0d23223038b82145aae126ae4b6 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Fri, 23 Oct 2026 05:33:06 -0700 Subject: [PATCH 048/139] feat(web): Preview section with Open panel, Watch canvas, and the node switch --- .../__tests__/node-preview-card.test.tsx | 125 ++++++++ .../components/nodes/node-preview-card.tsx | 59 ++++ .../__tests__/preview-section.test.tsx | 297 ++++++++++++++++++ .../web/src/components/preview/open-panel.tsx | 50 +++ .../web/src/components/preview/port-list.tsx | 142 +++++++++ .../components/preview/preview-section.tsx | 184 +++++++++++ .../components/preview/screen-launch-hint.tsx | 23 ++ .../src/components/preview/watch-viewer.tsx | 140 +++++++++ .../subshell-picker/launch-form-rules.ts | 11 + .../subshell-picker/new-subshell-form.tsx | 27 ++ .../web/src/components/trust-indicators.tsx | 6 +- .../__tests__/use-create-subshell.test.tsx | 39 +++ .../__tests__/use-subshell-preview.test.tsx | 292 +++++++++++++++++ .../web/src/hooks/use-create-subshell.ts | 12 + .../web/src/hooks/use-live-subshells-feed.tsx | 55 +++- apps/server/web/src/hooks/use-nodes.ts | 24 ++ .../web/src/hooks/use-subshell-preview.ts | 227 +++++++++++++ .../web/src/lib/__tests__/preview-bus.test.ts | 51 +++ apps/server/web/src/lib/preview-bus.ts | 52 +++ apps/server/web/src/lib/trust-notices.ts | 10 +- apps/server/web/src/routes/nodes_.$id.tsx | 5 + apps/server/web/src/routes/setup.tsx | 1 + apps/server/web/src/routes/subshells_.$id.tsx | 10 + packages/node-admin/src/types/node.ts | 8 + 24 files changed, 1842 insertions(+), 8 deletions(-) create mode 100644 apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx create mode 100644 apps/server/web/src/components/nodes/node-preview-card.tsx create mode 100644 apps/server/web/src/components/preview/__tests__/preview-section.test.tsx create mode 100644 apps/server/web/src/components/preview/open-panel.tsx create mode 100644 apps/server/web/src/components/preview/port-list.tsx create mode 100644 apps/server/web/src/components/preview/preview-section.tsx create mode 100644 apps/server/web/src/components/preview/screen-launch-hint.tsx create mode 100644 apps/server/web/src/components/preview/watch-viewer.tsx create mode 100644 apps/server/web/src/hooks/__tests__/use-subshell-preview.test.tsx create mode 100644 apps/server/web/src/hooks/use-subshell-preview.ts create mode 100644 apps/server/web/src/lib/__tests__/preview-bus.test.ts create mode 100644 apps/server/web/src/lib/preview-bus.ts diff --git a/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx new file mode 100644 index 000000000..96f066294 --- /dev/null +++ b/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NodePreviewCard } from "@/components/nodes/node-preview-card"; + +/** + * The Preview card on the node page (task-12 Step 4, pinned by Step 1): the + * same gate and tooltip posture as the SSH card beside it - owner-only flip, + * honest remedy line for a non-owner, and a single PUT of `{on}` at the Task + * 3 route. The fetch mock is the route, and the node arrives as a prop. + */ + +type Call = { method: string; url: string; body?: Record }; + +function node(over: Partial = {}): NodeDetail { + return { + id: "node1", + name: "box", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "box", + status: "online", + lastSeenAt: null, + agentVersion: "1.0.0", + protocolVersion: 12, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + sshWideForwards: false, + previewEnabled: false, + held: null, + ...over, + } as NodeDetail; +} + +function mount(n: NodeDetail) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ + method, + url: url.pathname, + body: init?.body ? (JSON.parse(String(init.body)) as Call["body"]) : undefined, + }); + return Promise.resolve(new Response(JSON.stringify({ ...n, previewEnabled: true }))); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const view = render( + + + , + ); + return { + view, + calls, + restore: () => (globalThis.fetch = original), + }; +} + +afterEach(() => cleanup()); + +describe("the Preview switch", () => { + it("reflects the stored flag and asks the route when flipped", async () => { + const h = mount(node({ previewEnabled: false })); + try { + const toggle = screen.getByRole("switch", { name: /allow preview/i }); + // Cast-and-read, this repo's convention (Base UI wears aria-checked and + // aria-disabled; there is no jest-dom dependency here). + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.matches(":disabled, [aria-disabled=true]")).toBe(false); + fireEvent.click(toggle); + await waitFor(() => + expect(h.calls.find((c) => c.method === "PUT" && c.url === "/api/nodes/node1/preview-enabled")).toBeTruthy(), + ); + expect(h.calls.find((c) => c.method === "PUT" && c.url === "/api/nodes/node1/preview-enabled")?.body).toEqual({ + on: true, + }); + } finally { + h.restore(); + } + }); + + it("a checked switch mirrors the stored on-state", () => { + const h = mount(node({ previewEnabled: true })); + try { + expect(screen.getByRole("switch", { name: /allow preview/i }).getAttribute("aria-checked")).toBe("true"); + } finally { + h.restore(); + } + }); + + it("a non-owner sees it disabled with the owner remedy", () => { + const h = mount(node({ canManage: false, access: "edit" })); + try { + expect(screen.getByRole("switch", { name: /allow preview/i }).matches(":disabled, [aria-disabled=true]")).toBe( + true, + ); + expect(screen.getByText(/only this node/i)).toBeTruthy(); + } finally { + h.restore(); + } + }); + + it("the local host answers with the admin remedy line", () => { + const h = mount(node({ kind: "local", canManage: false })); + try { + expect(screen.getByText(/an admin can change this setting/i)).toBeTruthy(); + } finally { + h.restore(); + } + }); +}); diff --git a/apps/server/web/src/components/nodes/node-preview-card.tsx b/apps/server/web/src/components/nodes/node-preview-card.tsx new file mode 100644 index 000000000..5d6f7cdba --- /dev/null +++ b/apps/server/web/src/components/nodes/node-preview-card.tsx @@ -0,0 +1,59 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + errMessage, + Label, + type NodeDetail, + Switch, +} from "@internal/node-admin"; +import { useSetPreviewEnabled } from "@/hooks/use-nodes"; + +/** + * The node page's Preview switch, cloned from the SSH card's gate posture: + * the same owner-only flip (`canManage`, seeded-`local` aside), the same + * remedy lines for a non-owner, and the one `PUT /:id/preview-enabled` call. + * The flag is the plane's record, so the switch works on an offline node too + * - the machine learns the value at its next connect - and the copy says what + * turning it on lets happen, not that it happens now. + */ +export function NodePreviewCard({ node }: { node: NodeDetail }) { + const update = useSetPreviewEnabled(); + return ( + + + Preview + + +
+ update.mutate({ id: node.id, on })} + /> + +
+ {node.canManage ? ( +

+ {node.previewEnabled + ? "Pane content on this machine can be watched: forwarded ports, live browser frames, and virtual screens. Turning this off stops new previews; the machine keeps running its panes." + : "Turning this on lets authorized viewers watch pane content on this machine: forwarded ports, live browser frames, and virtual screens. Every act still goes through the pane's own access rules."} +

+ ) : ( +

+ {node.kind === "local" + ? "An admin can change this setting." + : "Only this node's owner can change this setting."} +

+ )} + {update.isError && ( +

+ {errMessage(update.error, "Couldn't update the preview setting.")} +

+ )} +
+
+ ); +} diff --git a/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx b/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx new file mode 100644 index 000000000..ca38e428a --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { PreviewSection } from "@/components/preview/preview-section"; +import type { PreviewInfo, UseSubshellPreview } from "@/hooks/use-subshell-preview"; +import { publishPreviewFrame } from "@/lib/preview-bus"; +import type { SubshellAccess, SubshellView } from "@/types/subshell"; + +/** + * The Preview section's render contract (spec §7, task-12 brief Step 1). + * The section is presentational: it takes the hook's controller as a prop, so + * these tests fake the controller and drive only what the surface decides - + * which row shows which verb, what a gated control says about itself, when + * the canvas is stale, and what never renders on a phone. + */ + +const VIEW_REASON = "Opening a forwarded port needs edit access to this subshell."; +const SCREEN_VIEW_REASON = "Screen preview needs edit access to this subshell."; +const SWITCH_OFF_REASON = "Preview is switched off on this node. Its owner can turn it on in node settings."; + +function controller(over: Partial = {}): UseSubshellPreview { + return { + info: undefined, + infoLoading: false, + error: null, + watching: false, + watchError: null, + openPort: async () => ({ proxyBase: "/pv/ref1/" }), + startWatch: async () => {}, + stopWatch: async () => {}, + refresh: () => {}, + ...over, + }; +} + +function info(over: Partial = {}): PreviewInfo { + return { + ports: [ + { proto: "tcp", port: 3000, pid: 11 }, + { proto: "tcp6", port: 9333, pid: 12 }, + ], + cdpPort: 9333, + screen: null, + nodeEnabled: true, + ...over, + }; +} + +function row(over: Partial = {}): SubshellView { + return { + id: "pane1", + name: "pane", + status: "running", + alive: true, + nodeId: "node1", + lastOutputAt: null, + waitingSince: null, + unseenPush: false, + access: "owner", + ssh: false, + ...over, + } as unknown as SubshellView; +} + +function mount( + over: { + access?: SubshellAccess; + info?: PreviewInfo; + subshell?: SubshellView; + controller?: Partial; + isPhone?: boolean; + bannerVisibleMs?: number; + } = {}, +) { + const c = controller({ ...(over.info === undefined ? {} : { info: over.info }), ...over.controller }); + const view = render( + , + ); + return { view, c }; +} + +afterEach(() => { + cleanup(); + localStorage.removeItem("subshell.trustNoticesSeen"); + // A test may have swapped the clock; always leave the real one behind. + const holder = globalThis as { __restoreNow?: () => void }; + if (holder.__restoreNow) { + holder.__restoreNow(); + delete holder.__restoreNow; + } +}); + +describe("gated controls explain themselves", () => { + it("a view grantee sees Open and Screen present but disabled, each with the real reason", () => { + mount({ + access: "view", + info: info({ screen: { display: ":90", rfbPort: 5900 } }), + }); + // Two port rows carry two Open controls; the gate is the row-independent + // fact, so the first one states it. + // Cast-and-read, this repo's convention: no jest-dom dependency; the + // disabled-with-reason trio lives on `aria-disabled` + `aria-description` + // (ExplainedButton). + const open = screen.getAllByRole("button", { name: "Open" })[0]; + expect(open.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(open.getAttribute("aria-description")).toBe(VIEW_REASON); + const watch = screen.getByRole("button", { name: "Watch" }); + expect(watch.matches(":disabled, [aria-disabled=true]")).toBe(false); + const showScreen = screen.getByRole("button", { name: "Screen" }); + expect(showScreen.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(showScreen.getAttribute("aria-description")).toBe(SCREEN_VIEW_REASON); + }); + + it("a node with the switch off answers with the switch's remedy, not a silent empty list", () => { + mount({ info: info({ ports: [], cdpPort: null, nodeEnabled: false, reason: "preview-disabled" }) }); + expect(screen.getByText(SWITCH_OFF_REASON)).toBeTruthy(); + }); +}); + +describe("Open (edit unlocks it)", () => { + it("edit opens the panel hosting the proxy iframe and a new-tab link with the proxy href", async () => { + const { view } = mount({ access: "edit", info: info() }); + fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]); + await waitFor(() => expect(view.container.querySelector("iframe")).not.toBeNull()); + expect(view.container.querySelector("iframe")?.getAttribute("src")).toBe("/pv/ref1/"); + const link = screen.getByRole("link", { name: /open in new tab/i }); + expect(link.getAttribute("href")).toBe("/pv/ref1/"); + }); + + it("the panel closes again", async () => { + const { view } = mount({ access: "edit", info: info() }); + fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]); + await waitFor(() => expect(view.container.querySelector("iframe")).not.toBeNull()); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(view.container.querySelector("iframe")).toBeNull(); + }); +}); + +describe("the Watch canvas", () => { + function canvas(container: HTMLElement) { + return container.querySelector("[data-preview-canvas]"); + } + + it("swaps to the newest frame and ignores a lower seq", async () => { + const { view } = mount({ info: info(), controller: { watching: true } }); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 2, w: 10, h: 8, blob: "AA-BB-CC" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("2")); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 3, w: 10, h: 8, blob: "DD-EE" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("3")); + expect(canvas(view.container)?.getAttribute("data-bytes")).toBe("5"); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 1, w: 10, h: 8, blob: "OLD-BLOB-X" }); + expect(canvas(view.container)?.getAttribute("data-seq")).toBe("3"); + expect(canvas(view.container)?.getAttribute("data-bytes")).toBe("5"); + }); + + it("ignores frames for other panes", async () => { + const { view } = mount({ info: info(), controller: { watching: true } }); + publishPreviewFrame({ type: "preview-stream", id: "other-pane", seq: 9, w: 1, h: 1, blob: "X" }); + // Give any mis-wired subscription a chance to land before calling absence an assert. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(canvas(view.container)?.getAttribute("data-seq")).not.toBe("9"); + }); + + it("a fake clock past 5 s with no frame reads stale, and the fps/age badge freezes with it", async () => { + const realNow = Date.now; + let fake = 100_000; + Date.now = () => fake; + (globalThis as { __restoreNow?: () => void }).__restoreNow = () => { + Date.now = realNow; + }; + const { view } = mount({ info: info(), controller: { watching: true }, isPhone: false }); + // A tiny tick so the badge recomputes against the fake clock in test time. + const badge = () => view.container.querySelector("[data-preview-badge]"); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 1, w: 10, h: 8, blob: "AA-BB" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("1")); + const frozen = badge()?.textContent; + expect(frozen).toContain("fps"); + expect(frozen).toContain("ago"); + fake = 105_001; + await waitFor(() => expect(screen.getByText(/still there\?/i).textContent).toMatch(/still there\?/i), { + timeout: 2000, + }); + // Frozen while stale: the clock line holds its last live reading (the + // first text node; the stale span appended beside it is the notice, not + // the clock). + expect(badge()?.firstChild?.textContent).toBe(frozen); + // The canvas itself stays: a stale page, not a vanished one. + expect(canvas(view.container)?.getAttribute("data-seq")).toBe("1"); + }); + + it("cdpPort vanishing mid-watch says the browser is gone beside the stale canvas", () => { + const { view } = mount({ + access: "view", + info: info({ ports: [], cdpPort: null }), + controller: { watching: true }, + }); + expect(screen.getByText(/browser .*gone/i)).toBeTruthy(); + expect(canvas(view.container)).not.toBeNull(); + }); + + it("a refused watch renders the named REST error, and no canvas claims frames", () => { + mount({ + info: info(), + controller: { watchError: "watch-port-conflict: the bridge is already streaming another port" }, + }); + expect(screen.getByRole("alert").textContent).toContain("watch-port-conflict"); + }); +}); + +describe("Watch verbs drive both halves through the controller", () => { + it("pressing Watch starts it on the browser row's port", async () => { + let asked: number | undefined; + mount({ + info: info(), + controller: { + startWatch: async (port) => { + asked = port; + }, + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Watch" })); + await waitFor(() => expect(asked).toBe(9333)); + }); + + it("while watching, the control stops the watch", async () => { + let stopped = 0; + mount({ info: info(), controller: { watching: true, stopWatch: async () => void stopped++ } }); + fireEvent.click(screen.getByRole("button", { name: /stop/i })); + await waitFor(() => expect(stopped).toBe(1)); + }); +}); + +describe("the screen row", () => { + it("a live screen shows the launch hint with its DISPLAY for edit, and no dead Screen control", () => { + mount({ access: "edit", info: info({ ports: [], cdpPort: null, screen: { display: ":90", rfbPort: 5901 } }) }); + expect(screen.getByText(/DISPLAY=:/).textContent).toContain(":90"); + expect(screen.queryByRole("button", { name: "Screen" })).toBeNull(); + }); + + it("screenLost reads as the loss line, with no Screen control", () => { + mount({ info: info({ ports: [], cdpPort: null, screenLost: "x11vnc" }) }); + expect(screen.getByText("Display stopped: x11vnc exited")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Screen" })).toBeNull(); + }); +}); + +describe("phone layouts trade the panel, never Watch", () => { + it("Open on a phone never renders the panel; it hands the proxy to a new tab. Watch still paints", async () => { + const opened: string[] = []; + const originalOpen = window.open; + window.open = ((url?: string) => { + if (url) opened.push(url); + return null; + }) as typeof window.open; + try { + const { view } = mount({ + access: "edit", + info: info(), + controller: { watching: true }, + isPhone: true, + }); + fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]); + await waitFor(() => expect(opened).toEqual(["/pv/ref1/"])); + expect(view.container.querySelector("iframe")).toBeNull(); + expect(view.container.querySelector("[data-preview-canvas]")).not.toBeNull(); + } finally { + window.open = originalOpen; + } + }); +}); + +describe("the shared-pane banner variant", () => { + it("raises once per device on first Preview open, and stays down after", async () => { + const shared = row({ shareCount: 2, sharedWithEveryone: false }); + const { view } = mount({ info: info(), subshell: shared, bannerVisibleMs: 30 }); + // The PREVIEW variant's own copy: it says this surface shows more than the terminal. + expect(screen.getByText(/live view/i)).toBeTruthy(); + // Retire it (auto-fade + mark-seen runs at visibleMs + the fade). + await waitFor(() => expect(screen.queryByText(/shared this subshell with 2 other people/i)).toBeNull(), { + timeout: 2000, + }); + view.unmount(); + render(); + expect(screen.queryByText(/shared this subshell with 2 other people/i)).toBeNull(); + }); + + it("says nothing when the pane is private", () => { + mount({ info: info(), subshell: row({ shareCount: 0 }) }); + expect(screen.queryByText(/shared/i)).toBeNull(); + }); +}); diff --git a/apps/server/web/src/components/preview/open-panel.tsx b/apps/server/web/src/components/preview/open-panel.tsx new file mode 100644 index 000000000..5feefc689 --- /dev/null +++ b/apps/server/web/src/components/preview/open-panel.tsx @@ -0,0 +1,50 @@ +import { Button } from "@internal/node-admin"; +import { ExternalLink, X } from "lucide-react"; +import type { JSX } from "react"; + +/** + * The collapsible side panel one forwarded lane lives in (spec §7): the + * `/pv//` iframe itself, plus the escape hatch. The ref in `proxyBase` + * is the proxy's only secret, minted per open under the caller's own access, + * so this panel is just a viewer of a path that already exists. + */ + +export interface OpenPanelProps { + /** The forwarded port, for the title. */ + port: number; + /** The proxy path from `POST /preview/open`, ending in a slash. */ + proxyBase: string; + /** Collapse the panel. The lane itself is the plane's to expire. */ + onClose: () => void; +} + +export function OpenPanel({ port, proxyBase, onClose }: OpenPanelProps): JSX.Element { + return ( +
+
+ Port {port} + +
+