diff --git a/.changeset/apps-screens-label.md b/.changeset/apps-screens-label.md
new file mode 100644
index 000000000..2a8b363d0
--- /dev/null
+++ b/.changeset/apps-screens-label.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/docs": patch
+"@internal/website": patch
+---
+
+Name the dashboard entry point Apps & screens and its picker Open app or screen, with matching setup guidance and documentation. Keep API and MCP identifiers stable. Simplify the website demonstration and support private development hostnames through local environment settings.
diff --git a/.changeset/compact-dialog-titles.md b/.changeset/compact-dialog-titles.md
new file mode 100644
index 000000000..f3a33b378
--- /dev/null
+++ b/.changeset/compact-dialog-titles.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Reduce standard dialog titles to 18px and align the close button with the title. Reduce dialog padding to 18px on all sides.
diff --git a/.changeset/compact-rows-one-grammar.md b/.changeset/compact-rows-one-grammar.md
new file mode 100644
index 000000000..6e2d9f45a
--- /dev/null
+++ b/.changeset/compact-rows-one-grammar.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Use 13px text, 2px vertical padding, and 28px minimum rows for action-menu items.
diff --git a/.changeset/display-provider-abstraction.md b/.changeset/display-provider-abstraction.md
new file mode 100644
index 000000000..a7cc30b22
--- /dev/null
+++ b/.changeset/display-provider-abstraction.md
@@ -0,0 +1,9 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Separate virtual display providers from viewer connections and launch settings. Allow node owners to enable multiple display providers and choose a default, with Xvfb + VNC as the initial option. Persist per-subshell provider choices for restart and preserve display requests in presets. Check requirements for the selected provider and preserve existing displays when the selection changes.
+
+Order provider allocation and cleanup per subshell, recheck permission after asynchronous prerequisites, and verify VNC listener ownership during allocation and recovery. Reserve viewer ports before probing to prevent concurrent displays from selecting the same port.
diff --git a/.changeset/explain-disabled-mcp-displays.md b/.changeset/explain-disabled-mcp-displays.md
new file mode 100644
index 000000000..5531542e0
--- /dev/null
+++ b/.changeset/explain-disabled-mcp-displays.md
@@ -0,0 +1,6 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+---
+
+Explain when node settings disable a virtual display requested through MCP, and verify independent preview permissions for dashboard and agent access.
diff --git a/.changeset/fix-action-menu-viewport-clamp.md b/.changeset/fix-action-menu-viewport-clamp.md
new file mode 100644
index 000000000..da8387352
--- /dev/null
+++ b/.changeset/fix-action-menu-viewport-clamp.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Fix action menus running past the bottom of the screen. A menu taller than the space available (the subshell actions menu on a phone or short window) is now clamped to the viewport and scrolls within it, instead of its last items falling off the bottom where nothing could reach them.
diff --git a/.changeset/node-feature-documentation-links.md b/.changeset/node-feature-documentation-links.md
new file mode 100644
index 000000000..ddca3ef63
--- /dev/null
+++ b/.changeset/node-feature-documentation-links.md
@@ -0,0 +1,5 @@
+---
+"@internal/docs": patch
+---
+
+Document independent node preview permissions in node management, architecture, MCP, and protocol references.
diff --git a/.changeset/node-view-prerequisites.md b/.changeset/node-view-prerequisites.md
new file mode 100644
index 000000000..5a4adf5a2
--- /dev/null
+++ b/.changeset/node-view-prerequisites.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Show Apps & screens prerequisite checks in node settings, including missing virtual display programs, setup guidance, and a Recheck action. Explain platform limitations and make the disabled state visible beside the node's switch.
diff --git a/.changeset/pane-maximize-menu.md b/.changeset/pane-maximize-menu.md
new file mode 100644
index 000000000..9547efc8b
--- /dev/null
+++ b/.changeset/pane-maximize-menu.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Remove the terminal tab bar’s standalone maximize control and action menu. Keep Restore available for maximized groups.
diff --git a/.changeset/pane-preview.md b/.changeset/pane-preview.md
new file mode 100644
index 000000000..05e1b51b7
--- /dev/null
+++ b/.changeset/pane-preview.md
@@ -0,0 +1,13 @@
+---
+"@internal/server": major
+"@internal/node": major
+"@internal/docs": patch
+---
+
+Add web previews, browser watching, saved web services, and virtual displays to subshell workspaces. Open each view in its own draggable tab beside the terminal, move it between splits, or use full screen. Closing a preview leaves its terminal and sibling views running.
+
+Node settings independently control web previews and virtual displays. Enabling web previews makes them available to the node’s subshells. Nodes can enable multiple display providers and choose a default. Virtual displays are requested at launch, use the default or a selected enabled provider, and check prerequisites before starting. Xvfb + VNC is the initial provider and requires Linux with Xvfb, x11vnc, and xauth. Presets can save a provider choice. Each subshell keeps its resolved provider and display size on restart, even when the node default changes. Disabling a provider prevents new displays and restarts without stopping existing displays. The server’s built-in node uses the same runtime as enrolled nodes.
+
+Expose feature permissions, display-provider selection, prerequisites, and view controls through MCP. The UI explains disabled features and links to setup documentation. Switching nodes clears incompatible agent and display selections, resets the working directory, and explains the corrections. Preview choices use compact rows with plain port numbers, and phone previews preserve the desktop workspace layout.
+
+Isolate web-preview scripts from dashboard credentials and browser storage, restrict viewers to authorized resources, and revoke views when access changes or subshells stop. Keep target cookies on the server, bound transport buffers and image dimensions, verify display process ownership before cleanup, and redact preview capability references from diagnostics.
diff --git a/.changeset/preview-allocation-authorization.md b/.changeset/preview-allocation-authorization.md
new file mode 100644
index 000000000..6369beade
--- /dev/null
+++ b/.changeset/preview-allocation-authorization.md
@@ -0,0 +1,9 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Close a preview permission-downgrade race, bound image decoding and retained
+resources, and check transport backpressure before encryption. Verify display
+process identity before cleanup and document the additional security review.
diff --git a/.changeset/preview-guide-media.md b/.changeset/preview-guide-media.md
new file mode 100644
index 000000000..1085daec9
--- /dev/null
+++ b/.changeset/preview-guide-media.md
@@ -0,0 +1,5 @@
+---
+"@internal/docs": patch
+---
+
+Document website previews, virtual displays, browser isolation, private preview links, and the preview architecture and protocol. Add screenshots captured from an isolated sample project.
diff --git a/.changeset/preview-macos-guide.md b/.changeset/preview-macos-guide.md
new file mode 100644
index 000000000..5a30dd9e0
--- /dev/null
+++ b/.changeset/preview-macos-guide.md
@@ -0,0 +1,6 @@
+---
+"@internal/docs": patch
+---
+
+Explain how Mac users can use Preview with a Linux node, where to install
+virtual-display prerequisites, and which macOS features are unavailable.
diff --git a/.changeset/preview-mcp-capabilities.md b/.changeset/preview-mcp-capabilities.md
new file mode 100644
index 000000000..13d653868
--- /dev/null
+++ b/.changeset/preview-mcp-capabilities.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Add MCP Preview discovery, private connections, browser watching, and virtual-display launches. Include harness health, SSH/Preview settings, and live display prerequisite checks in node discovery, with owner-only access preserved. Document the workflow and capability-link security boundary.
diff --git a/.changeset/preview-review-fixes.md b/.changeset/preview-review-fixes.md
new file mode 100644
index 000000000..9a4e475bf
--- /dev/null
+++ b/.changeset/preview-review-fixes.md
@@ -0,0 +1,6 @@
+---
+"@internal/server": patch
+"@internal/docs": patch
+---
+
+Require an authorized browser-watch registration before delivering frames and revoke watches that no longer meet view-only browser restrictions. Preserve virtual-display dimensions across restarts and refuse disabled displays before stopping a live subshell. Buffer early target greetings within bounded lane limits so virtual-display connections cannot lose their handshake.
diff --git a/.changeset/preview-security-followup.md b/.changeset/preview-security-followup.md
new file mode 100644
index 000000000..6216a6554
--- /dev/null
+++ b/.changeset/preview-security-followup.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Cancel pending preview opens when access is revoked, and recheck authorization before publishing them. Keep CDP discovery on its requested loopback endpoint. Bound proxy response buffers, fragmented WebSocket messages, and target cookies, and prevent preview targets from installing dashboard reporting policies.
diff --git a/.changeset/preview-subscription-revocation.md b/.changeset/preview-subscription-revocation.md
new file mode 100644
index 000000000..35c1e0537
--- /dev/null
+++ b/.changeset/preview-subscription-revocation.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Recheck live-screen subscriptions when revocation overtakes their permission check. Limit queued writes to preview targets that stop reading, and clean up node resources after uncertain preview-open replies.
diff --git a/.changeset/rename-web-previews.md b/.changeset/rename-web-previews.md
new file mode 100644
index 000000000..032d41615
--- /dev/null
+++ b/.changeset/rename-web-previews.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Rename apps and browsers to Web previews throughout the dashboard, API and MCP permission fields, node protocol, and documentation.
diff --git a/.changeset/saved-browser-services.md b/.changeset/saved-browser-services.md
new file mode 100644
index 000000000..f9c5ce366
--- /dev/null
+++ b/.changeset/saved-browser-services.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": minor
+"@internal/node": minor
+"@internal/docs": patch
+---
+
+Save HTTP or HTTPS services in Apps & screens, including services started outside a subshell. Open each in its own workspace tab, and remove saved connections without stopping their services. Manual connections work without a virtual display, including on macOS nodes. Node protocol 24 adds explicit destinations and certificate-verified HTTPS.
diff --git a/.changeset/separate-node-preview-permissions.md b/.changeset/separate-node-preview-permissions.md
new file mode 100644
index 000000000..6d05c9106
--- /dev/null
+++ b/.changeset/separate-node-preview-permissions.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": minor
+"@internal/node": minor
+"@internal/docs": patch
+---
+
+Separate node permissions for apps and browsers from virtual displays. Apps and browser features apply to every subshell when enabled; virtual displays still require an explicit launch choice. Preserve existing grants, show feature-specific readiness, and enforce both switches on the server and node.
diff --git a/.changeset/sidebar-picker-sizing.md b/.changeset/sidebar-picker-sizing.md
new file mode 100644
index 000000000..13eaba0bf
--- /dev/null
+++ b/.changeset/sidebar-picker-sizing.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Match sidebar workspace and grouping dropdown rows to their compact triggers, using 28px rows and 13px text.
diff --git a/.changeset/sticky-launch-actions.md b/.changeset/sticky-launch-actions.md
new file mode 100644
index 000000000..d373a4361
--- /dev/null
+++ b/.changeset/sticky-launch-actions.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+---
+
+Keep Cancel and Start subshell visible while scrolling the new subshell form. Cancel and Close discard the draft before the dialog is reopened.
+
+Opening a new subshell no longer automatically reapplies the previous subshell’s preset.
diff --git a/.changeset/subshell-view-api-names.md b/.changeset/subshell-view-api-names.md
new file mode 100644
index 000000000..55f563aab
--- /dev/null
+++ b/.changeset/subshell-view-api-names.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+---
+
+Name the unreleased Apps & screens API resources views and expose MCP tools list_subshell_views, open_subshell_view, and watch_subshell_browser. Rename node readiness and enablement discovery, align viewer controls and permission labels, and document the persisted and protocol identifiers retained for compatibility.
diff --git a/.changeset/update-zod-validation.md b/.changeset/update-zod-validation.md
new file mode 100644
index 000000000..ab2b4a1aa
--- /dev/null
+++ b/.changeset/update-zod-validation.md
@@ -0,0 +1,8 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": patch
+"@internal/website": patch
+---
+
+Update Zod to 4.6.7 and override Expo CLI’s older Zod dependency to remove the vulnerable version from the dependency tree.
diff --git a/.changeset/view-review-lifecycle.md b/.changeset/view-review-lifecycle.md
new file mode 100644
index 000000000..e99919712
--- /dev/null
+++ b/.changeset/view-review-lifecycle.md
@@ -0,0 +1,6 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+---
+
+Prevent late browser-watch requests from reactivating closed or changed views, preserve selected virtual displays during same-node rechecks, and clarify disabled settings and setup guidance. Reject conflicting MCP app/display arguments before creating a connection.
diff --git a/.changeset/virtual-display-explanation.md b/.changeset/virtual-display-explanation.md
new file mode 100644
index 000000000..2fc75b852
--- /dev/null
+++ b/.changeset/virtual-display-explanation.md
@@ -0,0 +1,5 @@
+---
+"@internal/server": patch
+---
+
+Explain when a virtual display is useful and its resource cost beside the launch checkbox.
diff --git a/.changeset/virtual-display-setup-link.md b/.changeset/virtual-display-setup-link.md
new file mode 100644
index 000000000..4d0063deb
--- /dev/null
+++ b/.changeset/virtual-display-setup-link.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/docs": patch
+---
+
+Link missing virtual-display prerequisites to the setup guide instead of
+listing installation instructions in the launch form.
diff --git a/.changeset/website-preview-demo.md b/.changeset/website-preview-demo.md
new file mode 100644
index 000000000..0f3ff1348
--- /dev/null
+++ b/.changeset/website-preview-demo.md
@@ -0,0 +1,5 @@
+---
+"@internal/website": patch
+---
+
+Add a website preview demonstration directly below the mobile video, showing a real coding agent fixing project filters and checking the result beside its terminal.
diff --git a/apps/client/desktop/ui/src/styles.css b/apps/client/desktop/ui/src/styles.css
index 6147621f5..e39d81db4 100644
--- a/apps/client/desktop/ui/src/styles.css
+++ b/apps/client/desktop/ui/src/styles.css
@@ -159,6 +159,8 @@
not quietly resolve to it. `detail` (13px) is the floor. */
--text-display: 30px;
--text-display--line-height: 1.2;
+ --text-dialog-title: 18px;
+ --text-dialog-title--line-height: 1.2;
--text-heading: 20px;
--text-heading--line-height: 1.2;
--text-label: 15px;
diff --git a/apps/client/mobile/AGENTS.md b/apps/client/mobile/AGENTS.md
index 5b1c05887..1bd1d8272 100644
--- a/apps/client/mobile/AGENTS.md
+++ b/apps/client/mobile/AGENTS.md
@@ -67,6 +67,21 @@ exact-pin rule.
native build works at the repo's pins. Don't "fix" them by guessing; the real
constraint is the one below.
+**Forge carries a security patch.** `patches/node-forge@1.4.0.patch` applies
+the nested DigestAlgorithm validation proposed in
+[digitalbazaar/forge#1152](https://github.com/digitalbazaar/forge/pull/1152).
+No patched release is published yet. The dependency security tests exercise
+Expo’s installed copies under Node, rejecting malformed signatures and
+checking valid signatures and certificates. Replace the patch and remove its
+audit exception when upstream publishes a fixed release.
+
+**Expo CLI uses the root Zod override.** `@expo/cli>zod` pins 4.6.7 because
+Expo 57 still requests Zod 3, which is covered by GHSA-mrhx-c7xc-m3w5.
+Its plugin manifest, plugin output, and MCP command schemas were checked with
+Zod 4, including invalid inputs and JSON Schema conversion. Keep this scoped
+override until Expo requests a patched version itself; when updating the CLI,
+check those schemas as well as the mobile export.
+
**⚠️ Never let a `*` peer resolve freely.** `expo-router` declares
`react-native-reanimated: "*"`, so bun installed reanimated 4.6.0, which needs
`react-native-worklets@0.12.x`, and 0.12 removed
diff --git a/apps/docs/content/docs/automation/rest-api.mdx b/apps/docs/content/docs/automation/rest-api.mdx
index f696863b7..7136f252e 100644
--- a/apps/docs/content/docs/automation/rest-api.mdx
+++ b/apps/docs/content/docs/automation/rest-api.mdx
@@ -82,3 +82,24 @@ Check both HTTP status and the structured response. After an ambiguous create ti
## Next steps
Read [Build an API integration](/automation/integrations) and [Access model](/concepts/access).
+
+## Saved app services
+
+Use these endpoints for services started outside a subshell. A virtual display
+isn't required; the subshell must be running to open the connection.
+
+| Action | Endpoint | Body |
+| --- | --- | --- |
+| List saved connections | `GET /api/subshells/:id/services` | None |
+| Save a connection | `POST /api/subshells/:id/services` | `port`, `protocol` (`http` or `https`), optional `name` and `host` |
+| Open a saved connection | `POST /api/subshells/:id/views/open` | `{ "serviceId": "saved-service-uuid" }` |
+| Remove a saved connection | `DELETE /api/subshells/:id/services/:serviceId` | None |
+
+Host defaults to `localhost` on the subshell's node. HTTPS certificates must be
+trusted by that node. Reading requires subshell view access; saving, opening,
+and removing require edit access and `subshells:execute` for a scoped system key.
+Machine credentials retain their owner-only scope. Removing a saved connection
+revokes its open capabilities without stopping the service. The returned
+`proxyBase` is a private bearer capability; don't put it in logs or public links.
+
+See [Web previews & screens](/guides/preview#add-a-service-started-elsewhere) for the UI flow.
diff --git a/apps/docs/content/docs/concepts/architecture.mdx b/apps/docs/content/docs/concepts/architecture.mdx
index 177211421..c95074595 100644
--- a/apps/docs/content/docs/concepts/architecture.mdx
+++ b/apps/docs/content/docs/concepts/architecture.mdx
@@ -44,6 +44,67 @@ MCP runs beside the agent as a stdio child, using the pane's scoped identity. It
The server relays ciphertext, but terminal output and channel metadata have different security properties.
+## Web previews and virtual displays
+
+Web previews & screens brings a subshell's dev server, browser frames, or virtual display into
+the workspace. Your browser connects to the server; it doesn't need direct
+network access to the node's ports.
+
+```mermaid
+%% The dashboard authenticates to the server. App HTTP and WebSocket traffic use a capability URL and a node-side tunnel; CDP JPEG frames return through the authorized live feed. The built-in node uses the same runtime in process.
+flowchart LR
+ B[Dashboard browser] -->|Open preview with user access| S[Server preview broker]
+ B -->|HTTP or WS capability URL| P[Server preview proxy]
+ P <-->|Encrypted preview lanes| N[Node runtime]
+ N <-->|HTTP/S service or loopback RFB| A[Dev server or virtual display]
+ N -->|CDP JPEG frames| S
+ S -->|Authorized live feed| B
+ S -->|In-process adapter| L[Built-in node runtime]
+```
+
+The server checks subshell access and the node's separate web-preview or virtual-display permission before opening
+a view. App forwarding and screen input need edit access. Watching browser
+frames needs view access; view-only shares are restricted to the browser
+discovered for their subshell.
+
+The REST resource is `/api/subshells/:id/views`. Clients open a connection at
+`/views/open` and start or stop browser watching at `/views/browser/watch`.
+Node responses expose `webPreviewsEnabled` for web previews and `virtualDisplayEnabled` for virtual displays.
+Node configuration uses `/views-enabled` with `feature: "web-previews"` or `feature: "display"`,
+and display prerequisites use `/display-readiness`. The corresponding MCP tools
+are `list_subshell_views`, `open_subshell_view`, and `watch_subshell_browser`.
+See the [MCP tool reference](/mcp/tools#list_subshell_views) for their arguments.
+
+For web previews, the server creates a private capability URL and forwards each
+HTTP or WebSocket connection through a lane to loopback or the saved service destination on the execution
+machine. Proxied documents have an opaque browser origin so website scripts
+don't inherit dashboard authority. Target cookies stay in a server-side jar.
+The server's built-in node uses the same discovery, forwarding, CDP, and display
+runtime through an in-process adapter.
+
+Browser watches use Chrome DevTools Protocol to emit JPEG frames. Virtual
+displays use a shared provider interface for requirements, allocation, launch
+settings, recovery, and cleanup. Node owners enable providers and choose a
+default; each subshell can request an enabled provider and saves that choice
+for restarts. Both the server and the node enforce the selection.
+
+The only bundled provider today is **Xvfb + VNC** on Linux. It creates a private
+Xvfb display and loopback x11vnc listener; a separate VNC viewer adapter renders
+and controls the RFB stream with noVNC. Other providers can reuse that viewer
+if they expose VNC. Providers are bundled with Subshell, rather than installed
+through the harness-plugin manager. Local launch credentials stay on the node;
+the server receives only the display identifier and viewer connection. These are separate from the agent's
+terminal stream and from encrypted agent-to-agent channels. The trusted server
+can read preview bytes and frames.
+
+Each app port, browser watch, and display is a separate workspace tab with a
+parent subshell. Desktop previews initially split to the right. The phone tab
+strip leaves the desktop split layout unchanged. Saved layouts retain only
+previews whose parent pane is still authorized and present.
+
+See [Web previews & screens](/guides/preview), [Preview transport](/reference/node-protocol#preview-transport),
+and [Preview security](/concepts/security#preview-security).
+
## See also
[MCP and agent communication](/mcp), [Access model](/concepts/access), and [Security model](/concepts/security).
diff --git a/apps/docs/content/docs/concepts/security.mdx b/apps/docs/content/docs/concepts/security.mdx
index 84defc775..63ee7f93a 100644
--- a/apps/docs/content/docs/concepts/security.mdx
+++ b/apps/docs/content/docs/concepts/security.mdx
@@ -69,6 +69,44 @@ A view grant can expose credentials printed in terminal output. An edit grant le
Sharing a node is a separate decision: even a view grant on a node permits the recipient to launch their own sessions there. The execution account and machine can access those processes and their files. Sharing a node doesn't automatically share every session on it. See [Access model](/concepts/access), [Share a subshell](/guides/sharing), and [Share a node](/nodes/sharing).
+### Preview security
+
+Web previews and virtual displays have separate opt-in settings on each node.
+The node owner also controls which display providers are allowed. A successful
+requirements check doesn't enable a feature or grant permission. App forwarding
+and virtual-screen input need edit access to the subshell. View access permits browser-frame
+watching, restricted to the browser discovered for that shared subshell.
+
+Forwarded websites run in an opaque-origin browser sandbox, including when
+opened in a separate browser tab. Their scripts can't read the dashboard DOM,
+storage, or authenticated API responses. Target cookies stay in a per-preview
+server-side jar rather than being installed on the dashboard origin. Browser
+storage, service workers, and some website login flows need the node's normal
+browser context; use **Virtual display** for those checks.
+
+The preview URL is a bearer access link. Anyone who obtains it can use it while
+it remains valid. Keep it out of public issues, shared browser history, and
+reverse-proxy access logs. Closing the tab releases its connections but doesn't
+immediately revoke a copied link. Forward sessions expire after 15 minutes
+without traffic; grant removal, account disable, subshell death, and node loss
+also cut off affected access. Switching Web previews & screens off prevents new operations,
+but isn't an emergency close for existing connections.
+
+The server sees forwarded website bytes and browser frames. The node link is
+encrypted, but this is not end-to-end encryption from your browser to the app.
+Automatically discovered apps use loopback. Saved services can reach an explicit host from the node, with edit access and node opt-in required. HTTPS connections validate the destination certificate on the node. Saving a service does not expose a new public listener.
+Edit access retains the execution account's authority over its local services;
+port discovery isn't an OS isolation boundary.
+
+Linux virtual displays use a private Xauthority file and a loopback-only RFB
+listener. That RFB listener has no separate password, so other local users able
+to reach it can control the screen. Don't enable virtual displays on a host
+shared with untrusted local OS users. GUI apps and agents retain their normal
+execution-account permissions.
+
+Follow [Web previews & screens](/guides/preview) for setup and the
+[preview architecture](/concepts/architecture#web-previews-and-virtual-displays) for the data path.
+
### Machine credentials
Credentials serve different purposes:
diff --git a/apps/docs/content/docs/guides/meta.json b/apps/docs/content/docs/guides/meta.json
index 6d632f517..9e39f1310 100644
--- a/apps/docs/content/docs/guides/meta.json
+++ b/apps/docs/content/docs/guides/meta.json
@@ -5,6 +5,7 @@
"navigation",
"launch",
"ssh",
+ "preview",
"lifecycle",
"workspaces",
"clone",
diff --git a/apps/docs/content/docs/guides/preview.mdx b/apps/docs/content/docs/guides/preview.mdx
new file mode 100644
index 000000000..82ee03d14
--- /dev/null
+++ b/apps/docs/content/docs/guides/preview.mdx
@@ -0,0 +1,248 @@
+---
+title: "Web previews & screens"
+description: "Check websites beside your coding agent, open dev-server previews, and control a subshell's virtual display."
+---
+
+Web previews & screens lets you check a website or graphical app alongside the agent building it.
+
+## Enable web previews and displays on the machine
+
+In the dashboard sidebar, open **Nodes** and select the machine running your
+subshell. Its owner can change these settings; an administrator manages them
+for the server's built-in node.
+
+- **Allow web previews** enables web previews, saved services, and
+ browser watching for every subshell on that node. No per-subshell option or
+ virtual display is required.
+- **Allow virtual displays** lets a subshell request a virtual screen at launch.
+ Enabling this node setting alone starts no display processes. Each subshell
+ that requests a display uses extra memory and CPU.
+
+Both settings are off by default for new nodes and can be changed independently.
+
+You need edit access to open an app or control a virtual display. View access
+lets you discover available views and watch browser frames. Switching either feature off prevents new operations; it doesn't stop your subshell or immediately close
+an existing connection.
+
+## Use Web previews & screens from a Mac
+
+You can view and control previews from your Mac in a browser or **Subshell
+Client** while the subshell runs on a Linux node. The server's built-in node
+also works when the server runs on Linux.
+
+1. Open your server's dashboard on the Mac and
+ [enable web previews and displays](#enable-web-previews-and-displays-on-the-machine) for the Linux node.
+2. Launch your subshell on that node and start the project's dev server there.
+3. Follow [Check a website](#check-a-website) to open its web preview. For an
+ interactive browser or GUI app, follow
+ [Use a virtual display on Linux](#use-a-virtual-display-on-linux).
+
+Install Xvfb, x11vnc, xauth, and the browser on the Linux node when you need a
+virtual display. Your Mac doesn't need those programs or its own node to view
+that screen.
+
+If the subshell runs directly on macOS, the picker cannot automatically discover
+its app ports or browser. Use **Add service** to connect to a web service by port on macOS. Virtual displays require Linux, and Web previews & screens does not
+capture native Mac app windows. Installing the Linux display programs on a Mac
+doesn't add that support.
+
+## Check a website
+
+1. Start your project's dev server inside a subshell. Keep it listening on
+ loopback; you don't need to expose its port to your network.
+2. Choose **Web previews & screens** below the terminal, or use the Web previews & screens icon in the
+ subshell header. A window icon means the subshell has a virtual display;
+ otherwise the header uses a globe.
+3. In **Open web preview or screen**, choose the **Web preview** row for your dev server's port.
+4. Check the site in the preview beside the terminal. Try navigation, forms,
+ filters, and other interactions, then give your agent specific feedback.
+
+
+
+ Choose the dev-server port to check the website, or Virtual display to control a browser running on the node. Select the image to enlarge it.
+
+
+Each app, browser watch, or display opens in its own workspace tab. On desktop,
+a new preview splits to the right of its terminal. Reopening the same view
+focuses its existing tab. Each open preview also appears beneath its subshell
+in the sidebar.
+
+
+
+ Check the actual interaction beside the agent's result. The Brand filter now shows one matching project. Select the image to enlarge it.
+
+
+## Add a service started elsewhere
+
+A virtual display isn't required for web services. The **Web previews & screens** button
+below the terminal and the globe icon in the subshell header open the same dialog,
+even when the subshell has no display.
+
+1. Open **Web previews & screens**, then choose **Add service** under **Saved services**.
+2. Enter the service's **Port** and choose **HTTP** or **HTTPS**. You can add a name
+ to make it easier to recognize.
+3. Leave **Host** under **Advanced** as `localhost` for a service on the subshell's
+ node. For a service on another machine, enter a hostname or IP that the node can
+ reach. Hostnames resolve on the node, not on the device viewing the dashboard.
+4. Choose **Save service**, then select the saved row to open its workspace tab.
+
+
+
+ Save a connection to a web service without creating a virtual display.
+
+
+This also works for web services running on a macOS node, where automatic port
+discovery isn't available. The subshell must be running and its node must have
+**Allow web previews** enabled. HTTPS requires a certificate the node trusts and whose
+name matches the host; Subshell doesn't bypass certificate checks.
+
+Saved connections stay with the subshell across reloads and closed tabs. Use the
+remove button beside a saved row to delete the connection and disconnect its open
+views. Removing a connection doesn't stop the service. You need edit access to
+add, open, or remove connections; people with view access can see the list.
+
+## Arrange and refresh views
+
+Drag preview tabs to keep several apps or screens visible together. Closing a
+preview leaves its subshell and other previews running. Hidden previews release
+their viewer connections and reopen when you select them again.
+
+The viewer toolbar has refresh and a **More view actions** menu:
+
+- **Maximize pane** enlarges that workspace pane. **Restore pane** appears
+ directly in the toolbar while maximized.
+- **Full screen** uses your browser's fullscreen mode. **Exit full screen**
+ appears directly in the toolbar while active.
+- **Open in browser tab** opens a web preview in a separate browser tab.
+- **Close view** closes the current viewer.
+
+Use the subshell header's Web previews & screens icon to open another view. When there are
+more than six choices, the picker adds search and a scrollable app list with
+ports in numeric order. Browser and virtual-display choices stay above that list.
+
+On a phone, previews use the workspace's tab strip without changing your desktop
+split layout. The standalone subshell page provides a link for opening an app
+in a separate browser tab.
+
+## Use a virtual display on Linux
+
+A virtual display gives GUI programs in a subshell their own screen, without a
+physical monitor. Launch a browser there when you need to check browser storage,
+login flows, or another graphical app.
+
+1. Install **Xvfb**, **x11vnc**, and **xauth** on the Linux machine running the
+ subshell. In **Nodes**, select that machine and turn on **Allow virtual displays**.
+ Enable **Allow Xvfb + VNC** and select it as the **Default display provider**.
+ This is currently the only bundled provider, and it requires Linux.
+ Each provider has its own enable switch and requirements check.
+2. When launching a subshell, enable **Virtual display**. The form checks the
+ selected node, which may be different from the device viewing the dashboard.
+ If more than one provider is enabled, choose one under
+ **Display provider**, or keep **Node default**. Missing requirements prevent
+ launch until you resolve them or turn off Virtual display.
+ Missing programs show a **Set up virtual display** link to this guide.
+ After installing the prerequisites, use **Recheck**. The button disappears
+ once the requirements pass.
+3. Launch your browser or GUI app from that subshell so it inherits the display
+ settings. Install the browser on the node separately if it isn't available.
+4. Use the window icon in the subshell header, then choose **Virtual display**.
+ You can see and control the screen in its own workspace tab.
+
+
+
+ The virtual display shows the browser running on the node. Keyboard and pointer input require edit access. Select the image to enlarge it.
+
+
+Changing the node’s default affects new subshells that use **Node default**. Each
+subshell saves the provider it actually uses, so restarting keeps the same
+provider and display size. Disabling a provider leaves existing displays
+running, but prevents new displays and restarts that need it. A manual restart
+is refused before stopping the current subshell.
+
+Presets can save a specific provider or follow the node default. Loading a
+preset keeps its display choice visible even when the node cannot support it.
+Choose another enabled provider or turn off Virtual display to continue.
+If you switch nodes and the new node disables the chosen provider, doesn’t
+support it, or lacks its requirements, the form turns Virtual display off and
+explains why. A pending or failed check keeps your choice and blocks launch
+until it can be checked.
+Enabling providers in node settings starts no display processes; resources
+are allocated when you launch a subshell with Virtual display.
+
+A virtual display isn't a sandbox for the programs you run. They keep the
+execution account's permissions. Don't enable it on a host shared with untrusted
+local OS users; see [Preview security](/concepts/security#preview-security).
+
+## Watch browser frames
+
+If a subshell runs Chrome or Chromium with remote debugging enabled on loopback,
+the picker can show **Browser**. Select it to watch live frames of that browser.
+This view is a screencast; use **Virtual display** when you need keyboard and
+pointer control.
+
+A view-only share can watch the browser discovered for that subshell. It cannot
+select another browser port on the node. Close the preview tab when you're done.
+
+## Launch a preview from another subshell
+
+An agent can use MCP to start a helper on another enrolled node owned by the
+same account. The calling agent can run on a Mac; the virtual display runs on
+the Linux node. Shared nodes and the server's built-in node aren't available
+through the subshell token's node discovery tools.
+
+1. Call `list_nodes` to choose an online node with `canLaunch: true`, virtual displays
+ enabled, and an installed, working terminal harness. Call
+ `get_node` with its ID to check the default provider, or include `provider`
+ to check another enabled choice. Confirm `virtualDisplay.enabled` is true
+ and its status is `ready`. If anything is
+ missing, follow [Linux setup](#use-a-virtual-display-on-linux) on that node.
+2. Call `create_subshell` with the node ID and a display:
+
+ ```json
+ { "harness": "terminal", "node": "NODE_ID", "name": "Website check", "screen": { "width": 1280, "height": 800, "provider": "xvfb-x11vnc" } }
+ ```
+
+3. Use `exec_in_terminal` with the returned subshell ID to start your development
+ server and browser. Programs started there inherit `DISPLAY` and
+ `XAUTHORITY`. Keep long-running programs in the background so the command
+ can finish. For example, if Chromium is installed and your site is already
+ serving port 3000 on that node:
+
+ ```json
+ { "subshell_id": "SUBSHELL_ID", "command": "chromium http://127.0.0.1:3000 > browser.log 2>&1 &" }
+ ```
+
+4. Call `list_subshell_views` with `subshell_id` to discover its views. In the dashboard,
+ select the helper's Web previews & screens action and open its app or virtual display.
+ Each opens in its own workspace tab. `open_subshell_view` can create a private
+ connection path for a client, but it doesn't open a dashboard tab or return
+ a screenshot.
+5. Collect the results, then call `delete_subshell` to remove the temporary
+ helper and its history. Stop any `watch_subshell_browser` interest with `on: false`
+ when you finish watching.
+
+See the [MCP tool reference](/mcp/tools#get_node) for arguments and readiness
+fields, including harness health and SSH settings.
+
+## Browser compatibility and private links
+
+App previews use an isolated browser document so the website's JavaScript can't
+read your dashboard's DOM, storage, or authenticated API responses. Target
+cookies stay in the preview's server-side cookie jar. Sites that need browser
+storage, service workers, JavaScript-managed cookies, or full browser login
+flows should be checked in the node's browser through **Virtual display**.
+
+The proxy preserves your app's paths rather than rewriting HTML or JavaScript.
+If a dev server assumes it's hosted at `/`, configure relative asset URLs or a
+matching base path. A target's strict content policy can also prevent embedding;
+the virtual display gives you the browser's normal website context.
+
+Preview rewrites the target's `Host` to loopback. An app that still refuses it
+needs its own host or origin configuration checked; Subshell doesn't rewrite
+all application security checks.
+
+Treat a preview URL as a private access link. Anyone who receives it can use it
+while its session remains valid, and closing a tab doesn't immediately revoke
+a copied link. The server can read forwarded bytes and browser frames. See
+[Preview security](/concepts/security#preview-security) for the trust boundaries
+and [Architecture](/concepts/architecture#web-previews-and-virtual-displays) for the data path.
diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx
index 895deb38b..26b4d7993 100644
--- a/apps/docs/content/docs/mcp/tools.mdx
+++ b/apps/docs/content/docs/mcp/tools.mdx
@@ -19,7 +19,11 @@ Successful MCP responses contain JSON encoded in a text content block. Failures
| --- | --- |
| `list_subshells` | List visible sessions and output previews. |
| `get_subshell` | Inspect one session. |
-| `list_nodes` | List owned machines and launch availability. |
+| `list_nodes` | List owned machines, harness health, and SSH and Web previews & screens settings. |
+| `get_node` | Inspect an owned machine and check display prerequisites. |
+| `list_subshell_views` | Discover a subshell's apps, browser, and virtual display. |
+| `open_subshell_view` | Create a private app or screen connection. |
+| `watch_subshell_browser` | Start or stop browser screencast interest. |
| `list_presets` | List saved agent presets and harness catalog entries. |
| `create_subshell` | Launch from a saved preset, or open a plain terminal without one. |
| `restart_subshell` | Revive a session in place. |
@@ -64,7 +68,31 @@ Arguments: `{}`. Returns an array of owned nodes with `id`, `name`, `kind`, `sta
`crossCommNotes` contains user-authored context about the node. `recommendedStartingDirs` lists suggested absolute paths. These suggestions don't grant or restrict directory access; launch permissions still apply. Read the guidance before choosing a node or working directory.
-Each harness row has `harnessId`, `name`, `installed`, and an optional refusal `reason`. `inventoryStale` identifies cached detection. Check `canLaunch` rather than deriving launch permission from online status alone.
+Each harness row has `harnessId`, `name`, and `installed`, plus `version`, refusal `reason`, `broken`, `restartRequired`, and `checkedAt` when reported. A `broken` plugin can't launch even if its binary is installed. `restartRequired` concerns the server's loaded plugin, not the harness binary. `inventoryStale` identifies cached detection. Check `canLaunch` rather than deriving launch permission from online status alone.
+
+`webPreviewsEnabled` reports permission for web previews; `virtualDisplayEnabled` reports permission for virtual displays. `virtualDisplayProviders` lists enabled provider IDs, and `virtualDisplayProvider` names the default. These fields, `sshEnabled`, and `sshWideForwards` report configuration, not live readiness. `protocolVersion`, `agentVersion`, and `held` describe the daemon connection when reported. Missing fields from older servers mean unknown, not disabled. Subshell SSH still requires a browser session; these tools don't grant SSH access or change node settings.
+
+## get_node
+
+Pass `{ "node": "NODE_ID" }`, or an unambiguous node name. Returns the same fields as `list_nodes`, plus `virtualDisplay` readiness: `ready`, `missing-programs`, `unsupported`, or `unavailable`. A completed probe includes `supported`, unmet `missing` requirements, the checked `provider`, and `enabled` for that provider’s node permission. Pass `provider` to check a specific choice; otherwise the node default is checked. Pass `check_display: false` to skip the live probe and return `unchecked`.
+
+A ready display check means its requirements are met. Virtual displays and the chosen provider must also be enabled, the node must permit launches, and launch performs its own checks. Only the token owner's enrolled nodes are available; shares, admin status, and the server's built-in node don't widen this access.
+
+## list_subshell_views
+
+Pass `{ "subshell_id": "SUBSHELL_ID" }`. Returns discovered `ports`, `cdpPort`, `screen`, `nodeEnabled`, and availability reasons when present. A subshell's `virtualDisplay` field in `list_subshells` and `get_subshell` also identifies its allocated display when reported.
+
+## open_subshell_view
+
+For an app, pass `{ "subshell_id": "SUBSHELL_ID", "kind": "app", "port": 3000 }`. For a virtual display, pass `{ "subshell_id": "SUBSHELL_ID", "kind": "screen" }`; the tool uses that display's actual port.
+
+Returns `proxyBase`, a private capability path relative to the server URL, plus `kind`, `port`, and usage guidance. An app path serves HTTP; a screen path carries the viewer's WebSocket protocol and isn't a web page. The call doesn't launch a program or open a workspace tab. Use the dashboard's Web previews & screens picker to open tabs.
+
+Treat the complete link as a secret. Anyone holding it can use the connection while it's valid, and closing a tab doesn't revoke it. Delete temporary helper subshells when finished; see [Web previews & screens](/guides/preview#browser-compatibility-and-private-links).
+
+## watch_subshell_browser
+
+Pass `{ "subshell_id": "SUBSHELL_ID", "on": true }`, optionally with a CDP `port`, to start browser screencast interest. Pass `on: false` to release it when finished. Frames go to authorized dashboard live subscriptions, not the MCP response. This doesn't provide mouse or keyboard input. The server applies the same ownership, Web previews & screens enablement, and port checks as it does for dashboard requests.
## list_presets
@@ -86,6 +114,7 @@ The tool has two shapes. From a preset (the default) it launches saved settings;
| `node` | Optional string | Node ID or unambiguous display name override. |
| `working_dir` | Optional string | Absolute directory on that node. Omitted, a presetless terminal starts in the node's home. |
| `prompt` | Optional string | Task text added to or replacing the preset prompt, or typed once a presetless shell settles. |
+| `screen` | Optional object or null | Allocate a virtual display with integer `width` and `height`, each 320 to 4096, and an optional enabled `provider` ID. Omitting the provider uses the node default. Omitting `screen` inherits the preset’s display; `null` opts out. Check `get_node` first. This doesn’t enable providers or install programs. |
| `prompt_mode` | Optional enum | `append` by default, or `replace`. Needs a preset. |
Returns `{ id, promptDelivered }`. Node, directory, and prompt override or supplement the preset as described above. The final composed prompt is limited to 20,000 characters.
diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx
index 2a1a45a50..90dfbb392 100644
--- a/apps/docs/content/docs/nodes/manage.mdx
+++ b/apps/docs/content/docs/nodes/manage.mdx
@@ -23,6 +23,26 @@ On the node's **Overview** page, find **Cross-agent communication**. Only the no
Adding or removing a directory also saves any pending notes. Agents read both fields through MCP discovery. Directory suggestions don't grant or restrict access; the node's [Directory restrictions](/nodes/directories) still apply.
+## Apps, browsers, and virtual displays
+
+In the server dashboard, open **Settings** → **Nodes**, select a machine, and open **Overview**. Its owner can change these settings; a server admin manages them for the server's built-in node.
+
+- **Allow web previews** lets authorized viewers open web apps and saved services, and watch browsers in any subshell on that node. See [Web previews & screens](/guides/preview#check-a-website).
+- **Allow virtual displays** lets subshells request a screen for graphical apps on a machine without a monitor. Each display uses extra memory and CPU; web previews don't need one. See [Use a virtual display on Linux](/guides/preview#use-a-virtual-display-on-linux) for requirements and setup.
+
+Under **Allow virtual displays**, enable the providers you want this node to
+use and choose a **Default display provider**. Each enabled provider can be
+selected when starting a subshell. **Xvfb + VNC** is currently the only bundled
+provider and requires Linux. The card checks requirements on the selected node,
+which may be a different machine from the one displaying the dashboard.
+
+Changing the default doesn't change existing subshells: their restarts retain
+the saved provider. Disabling a provider prevents new allocations and restarts
+that need it, while existing displays keep running. Enabling a provider starts
+no processes until a subshell requests a display.
+
+The switches work independently. Both are off by default. The cards explain when a feature is disabled or required display programs are missing. Turning either setting off blocks new operations without stopping existing subshells or immediately closing existing connections.
+
## Names and ownership
Node names are display labels. Renaming a node doesn't change its identity, and launch pickers use the current display name.
diff --git a/apps/docs/content/docs/presets/launch-defaults.mdx b/apps/docs/content/docs/presets/launch-defaults.mdx
index 0e4a9965e..10c54fb72 100644
--- a/apps/docs/content/docs/presets/launch-defaults.mdx
+++ b/apps/docs/content/docs/presets/launch-defaults.mdx
@@ -32,9 +32,26 @@ with file paths and line numbers. Do not modify files.
Prompt text guides the agent; it isn't a security boundary. Agent flags and permissions still govern what the process can do.
+## Save a virtual display choice
+
+Enable **Virtual display** to include a screen when launching from this preset.
+Under **Display provider**, keep **Node default** or choose a specific provider.
+Saving the preset doesn't enable displays on its machine or start any processes.
+At launch, the chosen node must allow that provider and meet its requirements.
+See [Web previews & screens](/guides/preview#use-a-virtual-display-on-linux).
+
+The launch form copies this choice along with the other defaults. You can select
+another enabled provider or turn off Virtual display before starting. Once a
+subshell starts, it keeps its actual provider on restart even if you later change
+the preset or the node default.
+
## Review before launching
-Human launch forms prefill these defaults and let you change them. Review the working directory when choosing another machine; the same path may not exist there.
+Human launch forms prefill these defaults and let you change them. Switching
+nodes replaces the working directory with that node’s recent path or home. If
+the selected agent can’t run there, the form clears it and the preset
+selection, then asks you to choose another agent. Your prompt text stays in the
+form. Virtual display is turned off if the new node can’t provide it.
You can save a default for a machine that is temporarily offline. Launch still checks availability, maintenance, agent detection, permissions, and directory rules. Saving defaults doesn't bypass those checks.
diff --git a/apps/docs/content/docs/reference/node-protocol.mdx b/apps/docs/content/docs/reference/node-protocol.mdx
index 13f7a3d01..0db52a1cd 100644
--- a/apps/docs/content/docs/reference/node-protocol.mdx
+++ b/apps/docs/content/docs/reference/node-protocol.mdx
@@ -173,7 +173,7 @@ The server applies two distinct gates to readiness:
1. The node release must meet the server's minimum supported release version.
2. Its protocol version must match the server's protocol exactly.
-The implemented protocol is **20**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window.
+The implemented protocol is **23**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window.
A refused node can remain connected in a **held** state for updates while staying unavailable for launches and ordinary management commands. Only `update` can use that held connection. Older nodes on the legacy path receive that update command in its frozen plaintext recovery format; they aren't admitted for normal plaintext operation. Invalid credentials and disabled owners are authentication failures, not update holds.
@@ -199,6 +199,65 @@ The daemon uses full-jitter exponential reconnect backoff with a one-second init
Key rotation replaces the node credential and clears the server-side encryption pin. Store the new node credential on the execution machine with `subshell configure --key`; a running daemon reloads it and reconnects. Automatic re-pairing doesn't supply the replacement credential. A lost node-side encryption identity against an already-pinned server record may require explicit credential rotation and repair. See [Repoint a node](/nodes/repoint#store-a-rotated-key) and [Node troubleshooting](/troubleshooting/nodes).
+## Preview transport
+
+Web previews & screens uses protocol 24's existing signed-command and encrypted-frame paths.
+It does not open an inbound public port on the node. The server's built-in node
+runs the same preview primitives in process rather than sending node commands.
+
+| Message | Purpose |
+| --- | --- |
+| `set_preview_enabled` | Apply server-owned preview permissions, the default `displayProvider`, the allowed `displayProviders` set, and the change timestamp. `on` enables the transport; optional `webPreviewsOn` and `displayOn` distinguish web-preview access from virtual displays. When omitted, each inherits `on` for compatibility. |
+| `preview_display_check` | Check an optional `provider` or the node default for support and unmet requirements before launch; this never allocates a display. |
+| `preview_ports_get` | Report listeners under a subshell's process tree, its CDP port, and display metadata. |
+| `preview_open` | Start a loopback TCP forward or CDP screencast for a subshell, port, reference, and bounded lifetime. Forward lanes may include `service: { host, protocol }` for a saved HTTP(S) destination. |
+| `preview_data` | Carry capped base64 bytes toward the node (`in`) or server (`out`). |
+| `preview_stream` | Carry sequenced JPEG browser frames and dimensions. |
+| `preview_close` / `preview_closed` | Request or report teardown with a named reason. |
+
+Display metadata contains an opaque `display` identifier and a `viewer` connection,
+currently `{ protocol: "vnc", port }`. Provider launch credentials stay on the
+node. An unknown provider is unsupported; it never silently selects another.
+Provider IDs match `^[a-z][a-z0-9-]{0,63}$`; the allowed set contains at most
+32 unique IDs. An empty set permits no allocations even when the feature is on.
+
+The launch command accepts `screen: { width, height, provider? }`, with integer
+dimensions from 320 through 4,096 pixels per axis. The server resolves an omitted
+provider from the node default and persists it before launch. Restarts reuse
+that saved choice and recheck current permission. Both the server and enrolled
+node refuse a disabled provider before allocation. Readiness can succeed while
+the feature or provider is disabled; it reports prerequisites, not permission.
+
+A forward command targets loopback unless it carries a validated saved-service destination. Frame references
+are bound to the authenticated node on receipt. The server's HTTP/WS proxy
+uses separate per-connection lanes beneath a browser capability URL, and browser
+watch frames are delivered only to authorized live-feed subscriptions.
+CDP discovery refuses HTTP redirects, caps discovery JSON at 256 KiB, and
+requires the returned WebSocket to stay on the requested loopback port.
+
+Raw preview payloads are capped at 256 KiB per frame, lane buffering and queued
+TCP writes to the target and preview traffic queued on the encrypted link at
+1 MiB each,
+active forward lanes at four per subshell, and active lanes plus bridges at
+16 per node. The queue admits eight waiting lanes per subshell. Browser watches
+run at 1–5 fps, with five buffered frames and at most eight subscribers.
+Screencast dimensions are capped at 8,192 per axis and 16,777,216 pixels;
+the browser also checks encoded image headers before decoding them. The server
+retains at most 64 forwarding capabilities and admits at most 32 pending
+preview opens per node, independently of active lane capacity.
+
+Forward sessions expire after 15 minutes without traffic. Individual lanes and
+bridges have a four-hour maximum lifetime. Close reasons include `viewer-left`,
+`pane-gone`, `node-dropped`, `slow-link`, `over-cap`, `idle`, `revoked`, `not-cdp`,
+and `target-gone`. Switching Web previews & screens off prevents new operations rather than
+immediately draining existing connections.
+If an open reply times out or is malformed, the server attempts to close the
+allocated reference; it doesn't assume the node failed to create the resource.
+
+See [Preview architecture](/concepts/architecture#web-previews-and-virtual-displays) and
+[Preview security](/concepts/security#preview-security) for the browser isolation,
+access, capability-link, and local-machine boundaries.
+
## Security boundaries and implementation reference
The server and enrolled execution account are trusted endpoints. A server compromise can authorize commands across the fleet; a compromised node account can read its processes, accessible credentials, and transcripts. Command signing and encrypted transport don't provide an agent sandbox. Browser transport, enrollment, and storage need their own protection.
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 8d0d046e8..f72ace8af 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -25,7 +25,7 @@
"next": "16.4.3",
"react": "19.2.8",
"react-dom": "19.2.8",
- "zod": "4.6.6"
+ "zod": "4.6.7"
},
"devDependencies": {
"@internal/tsconfig": "workspace:*",
diff --git a/apps/docs/public/screenshots/add-service.png b/apps/docs/public/screenshots/add-service.png
new file mode 100644
index 000000000..fc8572c80
Binary files /dev/null and b/apps/docs/public/screenshots/add-service.png differ
diff --git a/apps/docs/public/screenshots/preview-display.png b/apps/docs/public/screenshots/preview-display.png
new file mode 100644
index 000000000..a6920dc1e
Binary files /dev/null and b/apps/docs/public/screenshots/preview-display.png differ
diff --git a/apps/docs/public/screenshots/preview-picker.png b/apps/docs/public/screenshots/preview-picker.png
new file mode 100644
index 000000000..9cc95df0c
Binary files /dev/null and b/apps/docs/public/screenshots/preview-picker.png differ
diff --git a/apps/docs/public/screenshots/preview-workspace.png b/apps/docs/public/screenshots/preview-workspace.png
new file mode 100644
index 000000000..a8f637211
Binary files /dev/null and b/apps/docs/public/screenshots/preview-workspace.png differ
diff --git a/apps/node/agent/AGENTS.md b/apps/node/agent/AGENTS.md
index 5c7887283..ec6233e61 100644
--- a/apps/node/agent/AGENTS.md
+++ b/apps/node/agent/AGENTS.md
@@ -235,6 +235,48 @@ launch argv **and** the pane's tmux hook table. Both are accepted (§11.2,
refused, not escaped: tmux re-parses hook values when it fires and `#`
expands (§11.14).
+## Preview (`src/preview/`, `src/commands/preview-*.ts`, pane-preview spec 2026-10-21)
+
+The node's half of pane preview, and its executors join the command
+inventory: `preview_ports_get` (Linux-only: it reads `/proc`, and non-Linux
+answers `preview-ports-unsupported` BEFORE touching the filesystem; the
+`screen` half rides the display registry), `preview_open` (one command, two
+branches by `kind`: `forward` registers a lane synchronously and its ack
+precedes any socket activity, exactly `ssh-relay-exec.ts`'s off-the-chain A
+contract, because the executor chain serializes every command; `screencast`
+awaits `startBridge`), `preview_close`, `set_preview_enabled` (the switch
+mirror, which SUCCEEDS everywhere including mac), and the display gate inside
+`launch` (3c/5a: platform, then switch, then the registry; the allocation
+precedes tmux so the refusal fails the launch with its named reason).
+
+- **Automatic views and CDP stay on loopback.** Protocol 24 also permits a
+ validated `service: { host, protocol }` on forward lanes for manually saved
+ browser services. DNS and certificate-verified HTTPS run on this node.
+ Explicit hosts never fall back to another host; localhost retains the IPv4/IPv6
+ retry. Screencasts reject the service field and still require a loopback CDP
+ WebSocket on the requested port.
+- **Xvfb, x11vnc and xauth are optional runtime dependencies.** A
+ non-Linux machine refuses by platform (`preview-display-unsupported`); a
+ Linux machine missing one of the binaries refuses
+ `preview-display-missing-binaries` naming the binary, and a pair that never
+ comes up inside the budget refuses `preview-display-not-ready`; a
+ switched-off node answers the one shared voice
+ (`preview-node-off: turn on Preview for this node`). Nothing installs them,
+ and nothing degrades
+ silently: a dropped `screen` at launch would be the bug, not the error.
+ `xauth add` (not `generate`) mints the cookie before Xvfb exists, and the
+ pane's `DISPLAY`/`XAUTHORITY` ride the env chain pinned by
+ `packages/pane-runtime`'s launch test.
+- **Budgets are shared, so one mechanism cannot starve the other.** The open
+ executor sums the node-wide lane count with the bridge registry's count
+ against `PREVIEW_SESSIONS_PER_NODE` before either starts, plus a per-pane
+ lane cap; frame bytes are capped (`PREVIEW_FRAME_MAX_BYTES`) and slow
+ viewers shed bytes, not the machine. A healthy screencast bridge logs
+ NOTHING (drops are the only lines); the display sweep (boot + hourly)
+ reaps registered displays absent from a COMPLETE pane census past a
+ one-minute floor, unknown-not-dead, and the plane's pane-close path is
+ the normal teardown: the sweep is the backstop for deleted rows.
+
## The loopback dashboard (`src/dashboard/`, spec 2026-09-19)
`subshell run` binds **127.0.0.1:3090** and serves the node its own admin
@@ -425,3 +467,33 @@ CLI main behind `import.meta.main` so tests import the pure publish/build logic
without building or spawning; `src/service.ts` needs no such guard: it is
side-effect-free by DI design (everything arrives through `ServiceDeps`; the CLI
entry lives in `cli.ts`).
+
+Preview's machine-local implementations live in
+`packages/pane-runtime/src/preview/` so the control-plane host and enrolled
+nodes use the same discovery, forward, CDP, and display lifecycle code. The
+agent's `preview/` modules preserve its logging and file-backed opt-in gate.
+Build the shared runtime before testing changes through these wrappers.
+CDP discovery refuses redirects and bounds JSON before parsing. Its returned
+WebSocket URL must retain the requested loopback port; pending handshakes count
+toward capacity and must cancel their discovery fetch when closed or timed out.
+Forwarding must bound the target socket's queued writes as well as outgoing
+preview frames; a target that stops reading cannot grow the daemon's heap.
+
+
+Preview transport checks its WebSocket queue before sealing; an undeliverable
+forward frame closes the lane instead of dropping TCP bytes. The display
+runtime verifies saved PIDs against the executable and this display's
+Xauthority argument before rehydration or signalling. CDP frames use numeric
+session acknowledgements and bounded dimensions before emitting images.
+
+Virtual display providers implement the shared lifecycle contract in
+`packages/pane-runtime/src/preview/display-provider.ts`. The signed preview
+settings mirror carries the allowed providers and their default. The launch
+request names its saved provider, and the runtime checks both the feature flag
+and allowed set before allocation, including after asynchronous readiness.
+Existing allocations remain owned by their original provider. Per-subshell
+opens and closes are ordered, and Xvfb allocation and recovery verify that the
+loopback RFB socket belongs to the recorded x11vnc child. Recovery sweeps every bundled provider,
+each restricted to its own state and verified process identities. The node
+launcher uses the provider's local environment and returns only the public
+viewer descriptor. See [the provider architecture](../../../docs/preview-architecture.md#display-providers-and-viewer-protocols).
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..05479b3f0 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 24 (current bump: saved HTTP(S) services)", () => {
+ expect(NODE_PROTOCOL_VERSION).toBe(24);
});
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..34a9e6641
--- /dev/null
+++ b/apps/node/agent/src/__tests__/preview-cdp-bridge.test.ts
@@ -0,0 +1,986 @@
+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?: number } = { 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 ?? 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: 101 });
+ cdp.pushFrame({ data: SMALL_BLOB, sessionId: 102 });
+ cdp.pushFrame({ data: SMALL_BLOB, sessionId: 103 });
+ 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([101, 102, 103]);
+ // 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("counts and cancels a handshake while discovery is still pending", async () => {
+ let release: () => void = () => {};
+ const discovery = new Promise((resolve) => {
+ release = resolve;
+ });
+ const outcome = startBridge({
+ ref: refFor(901),
+ cdpPort: 9222,
+ fps: 5,
+ lifetimeMs: 60000,
+ emitStream: () => {},
+ emitClosed: () => {},
+ deps: {
+ fetch: async () => {
+ await discovery;
+ return { ok: true, status: 200, json: async () => [] };
+ },
+ },
+ }).then(
+ () => "opened",
+ () => "cancelled",
+ );
+ expect(bridgeCount()).toBe(1);
+ expect(stopBridge(refFor(901), "revoked")).toBe(true);
+ expect(await outcome).toBe("cancelled");
+ expect(bridgeCount()).toBe(0);
+ release();
+ });
+
+ it("refuses another loopback browser port returned by a discovered endpoint", async () => {
+ const foreign = new FakeCdp();
+ const source = new FakeCdp({
+ targets: () => [{ type: "page", webSocketDebuggerUrl: `ws://127.0.0.1:${foreign.port()}/devtools/page/T1` }],
+ });
+ try {
+ await expect(
+ startBridge({
+ ref: refFor(900),
+ cdpPort: source.port(),
+ fps: 5,
+ lifetimeMs: 60000,
+ emitStream: () => {},
+ emitClosed: () => {},
+ }),
+ ).rejects.toThrow("not-cdp");
+ expect(foreign.sockets).toHaveLength(0);
+ expect(bridgeCount()).toBe(0);
+ } finally {
+ bridgeCloseAll("viewer-left");
+ source.stop();
+ foreign.stop();
+ }
+ });
+
+ 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: 104 });
+ cdp.pushFrame({ data: SMALL_BLOB, sessionId: 105 });
+ 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();
+ }
+ });
+
+ 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");
+ }
+ });
+});
+
+/* ------------------------------------------------------------------ */
+/* (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); // 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).
+ 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/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts
new file mode 100644
index 000000000..061b4c040
--- /dev/null
+++ b/apps/node/agent/src/__tests__/preview-display.test.ts
@@ -0,0 +1,1005 @@
+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 { type DisplayChild, type XvfbDisplayDeps as DisplayDeps, XvfbDisplayProvider } from "@internal/pane-runtime";
+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 { runDisplaySweep } from "../daemon.js";
+import {
+ PreviewDisplays as NodePreviewDisplays,
+ PREVIEW_DISPLAY_MISSING_REQUIREMENTS,
+ PREVIEW_DISPLAY_NOT_READY,
+ PREVIEW_DISPLAY_UNSUPPORTED,
+} from "../preview/display.js";
+import { setPreviewEnabled } from "../preview/flags.js";
+import { SubshellMetaStore } from "../subshell-meta.js";
+
+class PreviewDisplays extends NodePreviewDisplays {
+ constructor(dataDir: string, deps: DisplayDeps = {}) {
+ super(dataDir, { provider: new XvfbDisplayProvider(dataDir, deps) });
+ }
+}
+function xauthorityPath(dataDir: string, paneId: string) {
+ return join(dataDir, "display-providers", "xvfb-x11vnc", "displays", paneId, "Xauthority");
+}
+
+/**
+ * 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,
+ displayOccupied: () => false,
+ matchesProcess: () => true,
+ ownsRfbListener: (pid, port) =>
+ alivePids.has(pid) &&
+ busy.has(port) &&
+ calls.some(
+ (call) =>
+ call.kind === "spawn" &&
+ call.child.pid === pid &&
+ call.argv[call.argv.indexOf("-rfbport") + 1] === String(port),
+ ),
+ ...(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, advanceClock: (ms: number) => (clock += ms) };
+}
+
+/** 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);
+}
+
+/* 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 */
+/* ------------------------------------------------------------------ */
+
+describe("PreviewDisplays.openFor", () => {
+ it("does not signal a recorded PID that now belongs to another process", async () => {
+ const h = makeDeps();
+ let matches = true;
+ h.deps.matchesProcess = () => matches;
+ const displays = new PreviewDisplays(onDir("reused-display-pid"), h.deps);
+ await displays.openFor(PANE, { width: 1280, height: 800 });
+ matches = false;
+ await displays.closeFor(PANE);
+ expect(h.killPids()).toEqual([]);
+ });
+ it("skips display numbers used outside this data directory", async () => {
+ const { deps } = makeDeps();
+ deps.displayOccupied = (number) => number === 90 || number === 91;
+ const displays = new PreviewDisplays(onDir("external-display"), deps);
+ const screen = await displays.openFor(PANE, { width: 1280, height: 800 });
+ expect(screen.display).toBe(":92");
+ await displays.closeFor(PANE);
+ });
+ 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, "display-providers", "xvfb-x11vnc", "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", viewer: { protocol: "vnc", port: 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", viewer: { protocol: "vnc", port: 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, "display-providers", "xvfb-x11vnc", "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", viewer: { protocol: "vnc", port: 5911 } });
+ });
+
+ it("concurrent allocations never publish the same viewer port", async () => {
+ const dataDir = onDir("concurrent-ports");
+ const d = makeDeps();
+ const reg = new PreviewDisplays(dataDir, d.deps);
+ const ids = [crypto.randomUUID(), crypto.randomUUID()];
+ try {
+ const screens = await Promise.all(ids.map((id) => reg.openFor(id, { width: 1280, height: 800 })));
+ expect(new Set(screens.map((screen) => screen.viewer.port)).size).toBe(2);
+ } finally {
+ await Promise.all(ids.map((id) => reg.closeFor(id)));
+ }
+ });
+
+ it("refuses a responding RFB port owned by another process", async () => {
+ const dataDir = onDir("foreign-rfb-listener");
+ const d = makeDeps({ readyBudgetMs: 200 });
+ const reg = new PreviewDisplays(dataDir, { ...d.deps, ownsRfbListener: () => false });
+ await expect(reg.openFor(PANE, { width: 1280, height: 800 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY);
+ expect(reg.getFor(PANE)).toBeNull();
+ expect(d.killPids()).toHaveLength(2);
+ expect(existsSync(xauthorityPath(dataDir, PANE))).toBe(false);
+ });
+
+ it("does not recover a display whose saved port belongs to another process", async () => {
+ const dataDir = onDir("foreign-rfb-recovery");
+ const d = makeDeps();
+ const original = new PreviewDisplays(dataDir, d.deps);
+ await original.openFor(PANE, { width: 1280, height: 800 });
+ const restarted = new PreviewDisplays(dataDir, { ...d.deps, ownsRfbListener: () => false });
+ expect((await restarted.sweepOrphans([PANE])).rehydrated).toBe(0);
+ expect(restarted.getFor(PANE)).toBeNull();
+ await original.closeFor(PANE);
+ });
+
+ 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_REQUIREMENTS}: x11vnc`,
+ );
+ expect(spawns()).toHaveLength(0);
+ const root = join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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 });
+
+ await 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, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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", () => {
+ 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", viewer: { protocol: "vnc", port: 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, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", ".display-90.lock"))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, 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);
+ expect(reg.getFor(PANE)).toBeNull();
+ expect(pids.every((p) => killPids().includes(p))).toBe(true);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", LIVE),
+ join(dataDir, "display-providers", "xvfb-x11vnc", "displays", DEAD),
+ join(dataDir, "display-providers", "xvfb-x11vnc", "displays", GONE),
+ join(dataDir, "display-providers", "xvfb-x11vnc", "displays", ".display-90.lock"),
+ join(dataDir, "display-providers", "xvfb-x11vnc", "displays", ".display-91.lock"),
+ join(dataDir, "display-providers", "xvfb-x11vnc", "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();
+ // Only LIVE's children survived; the other records point at dead processes.
+ for (const call of shared.spawns().slice(0, 2)) shared.alivePids.add(call.child.pid);
+ 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", viewer: { protocol: "vnc", port: 5910 } });
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", LIVE))).toBe(true);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", ".display-90.lock"))).toBe(true);
+
+ // DEAD: reaped, dir and lock gone.
+ expect(reg2.getFor(DEAD)).toBeNull();
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", DEAD))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", GONE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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);
+ });
+});
+
+/* ------------------------------------------------------------------ */
+/* review round 1: sweep trust, rehydrated death, fresh-entry floor */
+/* ------------------------------------------------------------------ */
+
+describe("review round 1: what the sweep sees", () => {
+ it("restores a complete display for a live pane immediately after restart, while the directory is fresh", async () => {
+ const dataDir = onDir("fresh-live-rehydrate");
+ const shared = makeDeps();
+ const original = new PreviewDisplays(dataDir, shared.deps);
+ await original.openFor(PANE, { width: 640, height: 480 });
+ const restarted = new PreviewDisplays(dataDir, shared.deps);
+ await restarted.sweepOrphans([PANE]);
+ expect(restarted.getFor(PANE)).toEqual({ display: ":90", viewer: { protocol: "vnc", port: 5910 } });
+ });
+
+ 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, "display-providers", "xvfb-x11vnc", "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", viewer: { protocol: "vnc", port: 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, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false); // dir reaped
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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");
+
+ await 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", viewer: { protocol: "vnc", port: 5910 } });
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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", viewer: { protocol: "vnc", port: 5910 } });
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(true);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "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, "display-providers", "xvfb-x11vnc", "displays", PANE))).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", viewer: { protocol: "vnc", port: 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_REQUIREMENTS}: 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, "display-providers", "xvfb-x11vnc", "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/__tests__/preview-flags.test.ts b/apps/node/agent/src/__tests__/preview-flags.test.ts
new file mode 100644
index 000000000..2cfbb94aa
--- /dev/null
+++ b/apps/node/agent/src/__tests__/preview-flags.test.ts
@@ -0,0 +1,388 @@
+import { afterEach, describe, expect, it, spyOn } 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 { execPreviewOpen } from "../commands/preview-open-exec.js";
+import { maybeReportPreviewEnabled, seedPreviewEnabledMemo } from "../commands/report.js";
+import type { NodeConfig } from "../config.js";
+import { PreviewDisplays } from "../preview/display.js";
+import {
+ getPreviewEnabled,
+ getVirtualDisplayEnabled,
+ getWebPreviewsEnabled,
+ 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
+ * 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
+ * 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, 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" });
+ 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, with no echo and 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();
+ });
+});
+
+it.each([
+ [true, false],
+ [false, true],
+ [true, true],
+ [false, false],
+])("preserves independent apps %s and display %s permissions", (webPreviewsOn, displayOn) => {
+ const dataDir = freshDataDir();
+ const state = { on: webPreviewsOn || displayOn, webPreviewsOn, displayOn, changedAt: STAMP };
+ setPreviewEnabled(dataDir, state);
+ expect(getWebPreviewsEnabled(dataDir)).toBe(webPreviewsOn);
+ expect(getVirtualDisplayEnabled(dataDir)).toBe(displayOn);
+ if (state.on) expect(getPreviewEnabled(dataDir)).toEqual(state);
+});
+
+it("display permission does not authorize app forwarding or browser watching", async () => {
+ const dataDir = freshDataDir();
+ setPreviewEnabled(dataDir, { on: true, webPreviewsOn: false, displayOn: true, changedAt: STAMP });
+ const ctx = makeCtx(dataDir);
+ for (const kind of ["forward", "screencast"] as const) {
+ const result = await execPreviewOpen(ctx, {
+ type: "preview_open",
+ ref: crypto.randomUUID(),
+ paneId: crypto.randomUUID(),
+ port: 3000,
+ kind,
+ lifetimeMs: 60000,
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("Disabled apps unexpectedly opened a preview");
+ expect(result.error).toContain("preview-node-off");
+ }
+});
+
+it("app permission does not authorize an existing virtual display connection", async () => {
+ const dataDir = freshDataDir();
+ setPreviewEnabled(dataDir, { on: true, webPreviewsOn: true, displayOn: false, changedAt: STAMP });
+ const ctx = makeCtx(dataDir);
+ ctx.previewDisplays = new PreviewDisplays(dataDir);
+ const screen = spyOn(ctx.previewDisplays, "getFor").mockReturnValue({
+ display: ":90",
+ viewer: { protocol: "vnc", port: 5910 },
+ });
+ try {
+ const result = await execPreviewOpen(ctx, {
+ type: "preview_open",
+ ref: crypto.randomUUID(),
+ paneId: crypto.randomUUID(),
+ port: 5910,
+ kind: "forward",
+ lifetimeMs: 60000,
+ });
+ expect(result).toEqual({ ok: false, error: "virtual-display-disabled" });
+ } finally {
+ screen.mockRestore();
+ }
+});
+
+it("preserves and reports the selected display provider even while previews are disabled", () => {
+ const dir = mkdtempSync(join(tmpdir(), "display-provider-mirror-"));
+ const state = { on: false, changedAt: "2026-10-25T00:00:00.000Z", displayProvider: "native-example" };
+ try {
+ setPreviewEnabled(dir, state);
+ expect(getPreviewEnabled(dir)).toEqual(state);
+ expect(getVirtualDisplayEnabled(dir)).toBe(false);
+ expect(getWebPreviewsEnabled(dir)).toBe(false);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
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..2c8179659
--- /dev/null
+++ b/apps/node/agent/src/__tests__/preview-forward.test.ts
@@ -0,0 +1,702 @@
+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;
+ writableLength = 0;
+
+ 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));
+ this.writableLength += Buffer.byteLength(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));
+ }
+}
+
+/** 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")
+ .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("keeps full capability references out of runtime diagnostics", () => {
+ const h = makeHarness("private-reference");
+ const ref = "0123456789abcdef0123456789abcdef";
+ const logs: string[] = [];
+ h.forward.openLane({
+ ref,
+ port: 4321,
+ paneId: PANE,
+ lifetimeMs: 60000,
+ hooks: { emit: () => {}, log: (line) => logs.push(line) },
+ });
+ h.forward.closeLane(ref, "viewer-left");
+ expect(logs.length).toBeGreaterThan(0);
+ expect(logs.every((line) => !line.includes(ref))).toBe(true);
+ expect(logs.some((line) => line.includes(ref.slice(0, 8)))).toBe(true);
+ });
+ 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 (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);
+
+ // 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 },
+ });
+ });
+
+ 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();
+ }
+ });
+});
+
+/* ------------------------------------------------------------------ */
+/* (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(900_000); // the ARMED idle budget, at the ms FakeClock matches: no timer can resurrect it
+ expect(g.events).toHaveLength(0);
+ });
+});
+
+/* ------------------------------------------------------------------ */
+/* (f) the slow-link guard */
+/* ------------------------------------------------------------------ */
+
+describe("(f) slow link", () => {
+ it("the first undeliverable chunk closes the lane instead of silently dropping TCP bytes", () => {
+ 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)); // even a single short chunk cannot be skipped
+ expect(h.forward.laneCount()).toBe(0);
+ expect(closeEvents(attempts)).toHaveLength(1);
+ h.sockets[0]?.fire("data", Buffer.alloc(40, 7)); // later data cannot revive a truncated stream
+ expect(dataEvents(attempts)).toHaveLength(1); // the second chunk never reaches the dead lane
+ 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("closes a stalled target before its TCP write queue exceeds the lane budget", () => {
+ const h = makeHarness("stalled-writer", { deps: { laneBufferMaxBytes: 64 } });
+ h.forward.openLane({
+ ref: refFor(1),
+ port: 4321,
+ paneId: PANE,
+ lifetimeMs: 60_000,
+ hooks: { emit: (ev) => h.events.push(ev) },
+ });
+ const send = (size: number) =>
+ h.forward.ingestInboundFrame({
+ type: "preview_data",
+ ref: refFor(1),
+ dir: "in",
+ blob: Buffer.alloc(size).toString("base64"),
+ });
+ send(64);
+ expect(h.sockets[0]?.writableLength).toBe(64);
+ send(1);
+ expect(h.sockets[0]?.writableLength).toBe(64);
+ expect(h.sockets[0]?.destroyed).toBe(true);
+ expect(closeEvents(h.events)).toEqual([{ ref: refFor(1), reason: "slow-link" }]);
+ expect(h.forward.laneCount()).toBe(0);
+ });
+
+ 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/__tests__/preview-ports.test.ts b/apps/node/agent/src/__tests__/preview-ports.test.ts
new file mode 100644
index 000000000..4a5c073ea
--- /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", viewer: { protocol: "vnc", port: 5900 } },
+ });
+ expect(r.screen).toEqual({ display: ":97", viewer: { protocol: "vnc", port: 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/__tests__/relay-send.test.ts b/apps/node/agent/src/__tests__/relay-send.test.ts
index a58df881e..3e72f35ea 100644
--- a/apps/node/agent/src/__tests__/relay-send.test.ts
+++ b/apps/node/agent/src/__tests__/relay-send.test.ts
@@ -23,6 +23,33 @@ function fakeLink(up = true): RelaySendState["link"] {
}
describe("createDaemonRelaySend", () => {
+ it("refuses preview bytes before sealing when the transport queue is full", () => {
+ let sealed = 0;
+ let sent = 0;
+ const send = createDaemonRelaySend(() => ({
+ ws: {
+ bufferedAmount: 1_048_576,
+ readyState: 1,
+ send: () => {
+ sent++;
+ },
+ },
+ link: {
+ established: () => true,
+ session: () => ({
+ sealFrame: () => {
+ sealed++;
+ return new Uint8Array();
+ },
+ }),
+ },
+ }));
+ expect(() => send({ type: "preview_data", ref: "a".repeat(32), dir: "out", blob: "QUJD" })).toThrow(
+ /buffer budget/,
+ );
+ expect(sealed).toBe(0);
+ expect(sent).toBe(0);
+ });
it("seals the JSON frame and writes a Buffer onto the live link", () => {
const sealed: (string | Buffer)[] = [];
const relaySend = createDaemonRelaySend(() => ({
@@ -72,3 +99,20 @@ describe("createDaemonRelaySend", () => {
expect(sealed).toHaveLength(0); // no seal, no ratchet step spent on a doomed frame
});
});
+
+it("closes the preview transport when writing a sealed frame throws", () => {
+ let closed = false;
+ const send = createDaemonRelaySend(() => ({
+ ws: {
+ send: () => {
+ throw new Error("write failed");
+ },
+ close: () => {
+ closed = true;
+ },
+ },
+ link: { established: () => true, session: () => ({ sealFrame: () => new Uint8Array([1]) }) },
+ }));
+ expect(() => send({ type: "preview_closed", ref: "a".repeat(32), reason: "idle" })).toThrow("write failed");
+ expect(closed).toBe(true);
+});
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/commands/basics.ts b/apps/node/agent/src/commands/basics.ts
index 77f32a04e..5eee8ee78 100644
--- a/apps/node/agent/src/commands/basics.ts
+++ b/apps/node/agent/src/commands/basics.ts
@@ -152,7 +152,12 @@ export async function execProbe(ctx: CommandContext, cmd: Cmd<"probe">): Promise
continue;
}
const pane = await ctx.tmux.paneTitle(socket, subshellId);
- const entry: NodeProbeEntry = { subshellId, alive: true, exitCode: null };
+ const entry: NodeProbeEntry = {
+ subshellId,
+ alive: true,
+ exitCode: null,
+ ...(ctx.previewDisplays ? { previewScreen: ctx.previewDisplays.getFor(subshellId) } : {}),
+ };
if (pane) {
entry.title = pane.title;
entry.command = pane.command;
diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts
index 11056083f..a2e1535cb 100644
--- a/apps/node/agent/src/commands/context.ts
+++ b/apps/node/agent/src/commands/context.ts
@@ -1,14 +1,16 @@
-import type { TmuxRunner } from "@internal/pane-runtime";
+import type { TmuxRunner, VirtualDisplayRuntime } from "@internal/pane-runtime";
import type {
JsonValue,
NodeCommandBody,
NodeEvent,
NodeMaintenanceWire,
+ NodePreviewEnabledWire,
NodeRuntimeReport,
NodeSshEnabledWire,
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";
@@ -130,6 +132,36 @@ 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 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 —
+ * 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;
+ /**
+ * The virtual-display registry: the provider 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?: VirtualDisplayRuntime;
/**
* The panes supervised for natural death, keyed by subshellId →
* {@link WatcherRegistration} (socket + registration token + unreachable
@@ -192,6 +224,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..bc7b3154a 100644
--- a/apps/node/agent/src/commands/index.ts
+++ b/apps/node/agent/src/commands/index.ts
@@ -1,3 +1,4 @@
+import { previewDisplayReadiness } from "@internal/pane-runtime";
import type { NodeCommandBody } from "@internal/subshell-protocol";
import { execAgentLogRead } from "./agent-log.js";
import { execArchiveCreate } from "./archive-create.js";
@@ -22,10 +23,14 @@ 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";
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";
@@ -128,6 +133,17 @@ 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_display_check":
+ return {
+ ok: true,
+ data: { ...(await (ctx.previewDisplays?.readiness(cmd.provider) ?? previewDisplayReadiness(cmd.provider))) },
+ };
+ case "preview_ports_get":
+ return await execPreviewPorts(ctx, cmd);
case "prompt_deliver":
return await execPromptDeliver(ctx, cmd);
case "stat_dir":
@@ -158,6 +174,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/launch.ts b/apps/node/agent/src/commands/launch.ts
index cce9f6532..d38637609 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 } from "../preview/display.js";
+import { getVirtualDisplayEnabled } 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,25 @@ 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 (ctx.previewDisplays && !(await ctx.previewDisplays.readiness(cmd.screen.provider)).supported)
+ return { ok: false, error: PREVIEW_DISPLAY_UNSUPPORTED };
+ if (!getVirtualDisplayEnabled(ctx.config.dataDir)) 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 +228,20 @@ 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);
+ }
+ // Provider-owned launch settings stay on the node; only the public screen
+ // descriptor is returned in the acknowledgement.
+ const displayEnv = screenHandle ? ctx.previewDisplays?.launchEnv(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 +255,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) await 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 +318,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-close-exec.ts b/apps/node/agent/src/commands/preview-close-exec.ts
new file mode 100644
index 000000000..8c5b1e660
--- /dev/null
+++ b/apps/node/agent/src/commands/preview-close-exec.ts
@@ -0,0 +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, 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 sessions to close.
+ return { ok: false, error: "preview plumbing unavailable in this daemon context" };
+ }
+ const { closed } = forward.closeLane(cmd.ref, cmd.reason);
+ 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
new file mode 100644
index 000000000..11d5ccc6c
--- /dev/null
+++ b/apps/node/agent/src/commands/preview-open-exec.ts
@@ -0,0 +1,155 @@
+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, getVirtualDisplayEnabled, getWebPreviewsEnabled } 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): 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 (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. 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
+ * 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 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 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";
+
+/** The pane the command names is not running here (anymore, or never). */
+export const PREVIEW_PANE_GONE = "preview-pane-gone";
+
+/**
+ * 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 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" && cmd.kind !== "screencast") return { ok: false, error: "unsupported" };
+ const isScreen =
+ cmd.kind === "forward" && !cmd.service && ctx.previewDisplays?.getFor(cmd.paneId)?.viewer.port === cmd.port;
+ if (isScreen ? !getVirtualDisplayEnabled(ctx.config.dataDir) : !getWebPreviewsEnabled(ctx.config.dataDir)) {
+ return { ok: false, error: isScreen ? "virtual-display-disabled" : PREVIEW_NODE_OFF };
+ }
+ 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 (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,
+ service: cmd.service,
+ 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 (forward.laneCount() + 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/commands/preview-ports-exec.ts b/apps/node/agent/src/commands/preview-ports-exec.ts
new file mode 100644
index 000000000..6bfc8e367
--- /dev/null
+++ b/apps/node/agent/src/commands/preview-ports-exec.ts
@@ -0,0 +1,75 @@
+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 comes from the display registry (Task 7): a pane with a
+ * registered display answers `screen: { display, viewer }`, describing its
+ * viewer protocol and port. 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. */
+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;
+ // The display registry read (Task 7's wiring): a registered display rides
+ // the scan as the plane's viewer-port 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 af53109d5..d19ade993 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,68 @@ 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", ...state });
+ 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 &&
+ last.webPreviewsOn === state.webPreviewsOn &&
+ last.displayOn === state.displayOn
+ )
+ 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) {
@@ -438,6 +506,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 {
+ await 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/commands/set-preview-enabled-exec.ts b/apps/node/agent/src/commands/set-preview-enabled-exec.ts
new file mode 100644
index 000000000..9b6fef225
--- /dev/null
+++ b/apps/node/agent/src/commands/set-preview-enabled-exec.ts
@@ -0,0 +1,39 @@
+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,
+ ...(cmd.webPreviewsOn === undefined ? {} : { webPreviewsOn: cmd.webPreviewsOn }),
+ ...(cmd.displayProvider === undefined ? {} : { displayProvider: cmd.displayProvider }),
+ ...(cmd.displayProviders === undefined ? {} : { displayProviders: cmd.displayProviders }),
+ ...(cmd.displayOn === undefined ? {} : { displayOn: cmd.displayOn }),
+ });
+ return { ok: true };
+}
diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts
index 9de000d34..8312c7f09 100644
--- a/apps/node/agent/src/daemon.ts
+++ b/apps/node/agent/src/daemon.ts
@@ -9,8 +9,10 @@ import {
NODE_PROTOCOL_VERSION,
type NodeEvent,
type NodeMaintenanceWire,
+ type NodePreviewEnabledWire,
type NodeRuntimeReport,
type NodeSshEnabledWire,
+ parsePreviewFrame,
parseRelayFrame,
relayFrameRefIfOverCap,
SeqTracker,
@@ -22,8 +24,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";
@@ -40,6 +44,9 @@ 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 { 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";
import { type NodeRoute, NodeRouteSelector } from "./route-selection.js";
@@ -360,6 +367,7 @@ function readyEvent(
runtime: NodeRuntimeReport | null,
maintenance: NodeMaintenanceWire | undefined,
sshEnabled: NodeSshEnabledWire | undefined,
+ previewEnabled: NodePreviewEnabledWire | undefined,
): Extract {
return {
type: "ready",
@@ -414,6 +422,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 } : {}),
};
}
@@ -501,6 +515,28 @@ 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();
+ // 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 —
// executors hold `ctx` across reconnects. The serial chain does NOT keep
@@ -526,6 +562,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(),
@@ -544,8 +588,13 @@ 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,
+ previewDisplays,
requestRestart: () => {
// Deferred, because the daemon is the only sender of `result`: the
// executor returns `{ ok: true }`, the frame leaves on this turn, and
@@ -666,6 +715,21 @@ 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
+ // 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 runDisplaySweep(ctx, previewDisplays).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
@@ -1023,6 +1087,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" });
@@ -1153,6 +1231,20 @@ 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");
+ // 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
@@ -1235,7 +1327,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 +1416,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
@@ -1454,8 +1560,10 @@ 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);
setDaemonState({ connected: false });
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
@@ -1467,6 +1575,59 @@ 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: import("@internal/pane-runtime").VirtualDisplayRuntime,
+): 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. 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 scan = await ctx.meta.listComplete().catch((err: unknown) => {
+ log(`display census: meta scan threw: ${err instanceof Error ? err.message : String(err)}`);
+ return undefined;
+ });
+ 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 {
+ 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/cdp-bridge.ts b/apps/node/agent/src/preview/cdp-bridge.ts
new file mode 100644
index 000000000..1f48ba787
--- /dev/null
+++ b/apps/node/agent/src/preview/cdp-bridge.ts
@@ -0,0 +1,14 @@
+import { type StartBridgeArgs, startBridge as startRuntimeBridge } from "@internal/pane-runtime";
+import { log } from "../log.js";
+
+export type {
+ BridgeDeps,
+ BridgeWs,
+ PreviewClosedEvent,
+ PreviewStreamEvent,
+ StartBridgeArgs,
+} from "@internal/pane-runtime";
+export { BRIDGE_HANDSHAKE_BUDGET_MS, bridgeCloseAll, bridgeCount, clampFps, stopBridge } from "@internal/pane-runtime";
+export function startBridge(args: StartBridgeArgs) {
+ return startRuntimeBridge({ ...args, log: args.log ?? log });
+}
diff --git a/apps/node/agent/src/preview/display.ts b/apps/node/agent/src/preview/display.ts
new file mode 100644
index 000000000..fb3a656b2
--- /dev/null
+++ b/apps/node/agent/src/preview/display.ts
@@ -0,0 +1,21 @@
+import { type PreviewDisplayOptions, PreviewDisplays as RuntimePreviewDisplays } from "@internal/pane-runtime";
+import { DEFAULT_VIRTUAL_DISPLAY_PROVIDER } from "@internal/subshell-protocol";
+import { log } from "../log.js";
+import { getPreviewEnabled, getVirtualDisplayEnabled } from "./flags.js";
+
+export {
+ PREVIEW_DISPLAY_MISSING_REQUIREMENTS,
+ PREVIEW_DISPLAY_NOT_READY,
+ PREVIEW_DISPLAY_UNSUPPORTED,
+} from "@internal/pane-runtime";
+export class PreviewDisplays extends RuntimePreviewDisplays {
+ constructor(dataDir: string, options: Pick = {}) {
+ super(dataDir, {
+ ...options,
+ isEnabled: () => getVirtualDisplayEnabled(dataDir),
+ getEnabledProviderIds: () => getPreviewEnabled(dataDir)?.displayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER],
+ getProviderId: () => getPreviewEnabled(dataDir)?.displayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER,
+ log,
+ });
+ }
+}
diff --git a/apps/node/agent/src/preview/flags.ts b/apps/node/agent/src/preview/flags.ts
new file mode 100644
index 000000000..f17c162de
--- /dev/null
+++ b/apps/node/agent/src/preview/flags.ts
@@ -0,0 +1,203 @@
+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
+ * transport allowance plus independent app/browser and virtual-display grants.
+ * Legacy mirrors without feature fields retain their combined-switch meaning.
+ *
+ * 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. 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.
+ *
+ * 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 permission answer (refuse). An off file that retains provider settings
+ * reports `off` so the plane can reconcile those settings too; a legacy off
+ * file without them reads as `absent`. `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" }
+ /** Parsed settings, including the selected provider while both features are off. */
+ | {
+ kind: "on" | "off";
+ webPreviewsOn?: boolean;
+ displayOn?: boolean;
+ displayProvider?: string;
+ displayProviders?: string[];
+ /** 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 || state.displayProvider !== undefined || state.displayProviders !== undefined
+ ? {
+ kind: state.on ? "on" : "off",
+ changedAt: state.changedAt,
+ ...(state.webPreviewsOn === undefined ? {} : { webPreviewsOn: state.webPreviewsOn }),
+ ...(state.displayOn === undefined ? {} : { displayOn: state.displayOn }),
+ ...(state.displayProvider === undefined ? {} : { displayProvider: state.displayProvider }),
+ ...(state.displayProviders === undefined ? {} : { displayProviders: state.displayProviders }),
+ }
+ : { 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" || read.kind === "off")
+ return {
+ on: read.kind === "on",
+ changedAt: read.changedAt,
+ ...(read.webPreviewsOn === undefined ? {} : { webPreviewsOn: read.webPreviewsOn }),
+ ...(read.displayOn === undefined ? {} : { displayOn: read.displayOn }),
+ ...(read.displayProvider === undefined ? {} : { displayProvider: read.displayProvider }),
+ ...(read.displayProviders === undefined ? {} : { displayProviders: read.displayProviders }),
+ };
+ 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 = { ...state };
+ // 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;
+}
+
+/** Separate feature gates, with legacy combined mirrors retaining their meaning. */
+export function getWebPreviewsEnabled(dataDir: string): boolean {
+ const state = getPreviewEnabled(dataDir);
+ return state?.on === true && (state.webPreviewsOn ?? state.on);
+}
+export function getVirtualDisplayEnabled(dataDir: string): boolean {
+ const state = getPreviewEnabled(dataDir);
+ return state?.on === true && (state.displayOn ?? state.on);
+}
diff --git a/apps/node/agent/src/preview/forward.ts b/apps/node/agent/src/preview/forward.ts
new file mode 100644
index 000000000..e95494249
--- /dev/null
+++ b/apps/node/agent/src/preview/forward.ts
@@ -0,0 +1,10 @@
+import { type OpenLaneArgs, PreviewForward as RuntimePreviewForward } from "@internal/pane-runtime";
+import { log } from "../log.js";
+
+export type { LaneDeps, LaneSocket, LaneTimers, OpenLaneArgs, PreviewLaneEvent } from "@internal/pane-runtime";
+export { forwardCount, realTimers, setActivePreviewForward } from "@internal/pane-runtime";
+export class PreviewForward extends RuntimePreviewForward {
+ override openLane(args: OpenLaneArgs): void {
+ super.openLane({ ...args, hooks: { ...args.hooks, log: args.hooks.log ?? log } });
+ }
+}
diff --git a/apps/node/agent/src/preview/ports.ts b/apps/node/agent/src/preview/ports.ts
new file mode 100644
index 000000000..684010e2c
--- /dev/null
+++ b/apps/node/agent/src/preview/ports.ts
@@ -0,0 +1,2 @@
+export type { PanePortsScan, PreviewPortRow, PreviewScreenHandle, ScanPanePortsOptions } from "@internal/pane-runtime";
+export { PREVIEW_PORTS_OVERCAP, PREVIEW_PORTS_UNSUPPORTED, scanPanePorts } from "@internal/pane-runtime";
diff --git a/apps/node/agent/src/relay-send.ts b/apps/node/agent/src/relay-send.ts
index 2edc7ac2d..6c751bd93 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 { NODE_MAX_FRAME_BYTES, PREVIEW_LANE_BUFFER_MAX_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
@@ -25,7 +30,14 @@ export interface RelaySendLink {
/** The live facts at send time: the CURRENT socket (or none) and its link (or none). */
export interface RelaySendState {
- ws?: { send(data: string | Buffer): unknown } | undefined;
+ ws?:
+ | {
+ send(data: string | Buffer): unknown;
+ close?(code?: number, reason?: string): void;
+ readonly bufferedAmount?: number;
+ readonly readyState?: number;
+ }
+ | undefined;
link?: RelaySendLink | undefined;
}
@@ -35,8 +47,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;
@@ -48,6 +60,26 @@ export function createDaemonRelaySend(getState: () => RelaySendState): (frame: R
if (size > NODE_MAX_FRAME_BYTES) {
throw new Error(`relay send refused: frame ${size}B exceeds the ${NODE_MAX_FRAME_BYTES}B link cap`);
}
- ws.send(binaryPayload(session.sealFrame(payload)));
+ if (frame.type !== "relay") {
+ if (ws.readyState !== undefined && ws.readyState !== 1) throw new Error("preview transport is not open");
+ // Reserve space for the secretstream header and tag before sealing:
+ // a rejected frame must not advance the link's encryption state.
+ if ((ws.bufferedAmount ?? 0) + size + 128 > PREVIEW_LANE_BUFFER_MAX_BYTES) {
+ throw new Error("preview transport exceeded its buffer budget");
+ }
+ }
+ try {
+ ws.send(binaryPayload(session.sealFrame(payload)));
+ } catch (err) {
+ // Once sealed, the frame cannot be skipped on this secretstream.
+ if (frame.type !== "relay") {
+ try {
+ ws.close?.(1011, "preview transport write failed");
+ } catch {
+ /* already closed */
+ }
+ }
+ throw err;
+ }
};
}
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/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md
index 5e06e002b..6e9c1c7df 100644
--- a/apps/server/api/AGENTS.md
+++ b/apps/server/api/AGENTS.md
@@ -112,6 +112,7 @@ keys with the specific `systemPermission` declared by each route. New keys carry
versioned explicit grants; authGuard denies unannotated routes by default. The shared
`nodeManagementGuard` denies legacy keys and subshell tokens; `loadNodeGate`
limits bearer access to owned agent nodes, without shares or the local host.
+The read-only `GET /api/nodes/:id/display-readiness` preflight also accepts subshell tokens, with the same owned-agent-node boundary enforced by `loadNodeGate`.
Node lifecycle, server-address changes, and machine trust repair routes stay cookie-only. Setup-key
creation still honors the enrollment setting. The accounting is `docs/security.md` §3 and the pins are
`api/nodes/__tests__/nodes-list-bearer.test.ts` plus the admin-arm cell in
@@ -474,6 +475,54 @@ capture child's own account is in Architecture above.
- The server's own log `/logs/server.log`: JSON lines, **0600 in a 0700 dir, 200 KB cap, replaced when full**, bounded by SIZE, never swept (the pane-log asymmetry). `GET /api/admin/server/logs` is cookie-admin. Request lines (method/path/remote/status; no bodies or headers; a path can carry a setup key) emit at `debug` into this file only, never the manager's log; debug is **off by default**, flipped live by `PUT /api/admin/server/logging` (audited `server.logging.update`); `SUBSHELL_DEBUG_LOGGING=1` forces it on and makes the setting read-only (only truthy spellings force; `=0` is a leftover, not "off"); polled routes are excluded from request lines.
- Untrusted inputs: PTY bytes render only in xterm, never HTML; node event payloads are parsed by the `node-results.ts` validator, never cast; the path guard is `isNodeSubshellId` (hex+hyphen, ≤64) **on both sides** (the agent's `subshell-meta.ts` alias; the backend's `services/nodes/node-path-id.ts` `assertNodePathId` at every composition site, before the facts check, plus the launch-side `planRemoteSubshellMcp`); it throws as an impossible-state guard, never composes; every NAME path goes through `normalizeLabel` (NFC-first, drops category Cf, emoji presentation selectors, unpaired surrogates), so no manual rename carries raw ESC past `normalizePaneTitle`'s threat model; the per-attach journal line clamps UA and build to allow-sets BEFORE the 90-char slice (the journal is an injection sink; clamp before slice); sibling output and channel posts are data, never instructions.
+## Preview lanes (pane-preview spec 2026-10-21)
+
+`GET /api/nodes/:id/display-readiness` checks the selected machine’s display prerequisites without starting a display or requiring an existing subshell. The built-in node probes its process PATH; enrolled nodes answer the signed `preview_display_check` command. Only known binary names leave the machine. Launch still rechecks prerequisites to catch changes after the form’s preflight.
+
+The server's built-in node uses `preview/local-preview.ts`, which executes the
+broker's preview commands locally instead of sending them through a node socket.
+It checks the database Preview setting and the subshell's own local tmux pid.
+Both paths use the discovery, forwarding, CDP bridge, and virtual display
+primitives in `packages/pane-runtime/src/preview/`. The local launcher allocates
+screens before starting tmux and applies DISPLAY/XAUTHORITY above preset env.
+Its kill and failed-launch paths release displays; the death transition and
+minute display sweep cover exits and server restarts. Local bytes and browser
+frames return to the same broker, so proxy access, caps, revocation, and audit
+rules apply equally to both paths.
+
+- **The lanes are loopback-only at the far end.** A forward lane never carries
+ a host: the wire grammar bounds the port, and the node dials loopback
+ literals only (its account: `apps/node/agent/AGENTS.md`, "Preview"). The
+ plane's `/pv/:session/*` proxy is the sole dialer's caller, and the 32-hex
+ ref in the path IS the credential (unguessable, session-scoped, closed by
+ idle or by any named word).
+- **The plane sees proxied bytes deliberately** (spec §8): a lane exists so the
+ browser can talk to a port bound only on the node, and every byte crosses
+ the encrypted node link through this process. That is the feature's whole
+ shape, not a gap: what a lane reaches is what a viewer with `edit` access
+ could type into the pane's own browser anyway. `Host` is rewritten to
+ `127.0.0.1:` so dev-server host checks see loopback.
+- **Watch frames ride `/ws/live`, so the §11.14 control covers their audience.**
+ The frames themselves do NOT ride the topic machinery:
+ `configurePreviewStreamSender` (`live-ws.ts`) installs a direct-socket
+ sender (`sendPreviewStreamSocket`, `live-registry.ts`) and the broker fans
+ bytes straight to the sockets in its own registry. The control holds anyway
+ because only `watch` registration puts a socket into that set (gated through
+ the same visibility resolver as every other read), and every grant change
+ cuts it back out (`closeUnauthorizedForPane` on a shares write, the
+ role-change and disable user sweeps); the exhaustive diff test covers this
+ fan-out's viewer→row pairing too. watch/unwatch sit at `view`, open/Screen
+ at `edit` (spec §7), and the broker drops frames with no subscriber rather
+ than buffering them.
+- The per-node switch is the gate in front of all of it (`node.preview-enabled`,
+ audited `node.preview_enabled.update`); every preview route answers the node
+ state itself with its named refusal rather than inventing one: the plane's own
+ state words (`preview-disabled`, `node-held`, `node-too-old`, `node-offline`,
+ `node-timeout`), the node's refusal words passed through verbatim
+ (`preview-ports-unsupported`, `preview-ports-pane-gone`, the `preview-display-*`
+ family, `preview-pane-gone`), and the bridge's `watch-port-conflict` 409. The
+ watch route's missing-port case is a 400 sentence, not a code.
+
## SSH
The route inventory and trust limits are in `docs/security.md` ("SSH surfaces
@@ -517,7 +566,7 @@ The full inventory, moved here from the security summary ("§N" points into
Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row.
-Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_wide_forwards.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.
+Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_wide_forwards.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|preview_forward.open|close, preview_watch.open|close, preview_display.open|close (lane, bridge, and display lifecycle, each close with the named reason word), preview_enabled.update (the per-node switch), preview_lane.over_cap (the plane's sender-cap lane death, the only lane-level action; the session survives it), 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)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10.
## Updates & releases
@@ -877,3 +926,45 @@ rechecks current access and alias resolution, then uses the existing pin store's
no-overwrite rule. Existing or conflicting trust cannot enter this path; the
host-pins API remains available for explicit operator recovery. See the SSH trust
accounting in `docs/security.md` §6.
+
+Preview browser boundaries are documented in [Preview architecture](../../../docs/preview-architecture.md)
+and [security §11.15](../../../docs/security.md#1115-web-previews-and-virtual-displays).
+Proxied documents require a response CSP sandbox without `allow-same-origin`,
+plus the workspace iframe sandbox. Keep upstream cookies in the broker jar;
+never write them to the dashboard browser. Only capability-bound preview CORS
+and WS may accept opaque `Origin: null`; dashboard APIs and WS must keep refusing
+it. View-only watch requests must match the shared subshell's discovered CDP port.
+
+Strip dashboard HTTP/edge credentials and origin-wide target response policy
+headers in the proxy. Explicit stop, delete, and restart must await broker
+teardown before killing the old pane and sweep again after retiring its row.
+Pending opens must participate in revocation and recheck current access before
+publication; exit hooks and registered sessions alone cannot cover late RPCs.
+Live-screen subscriptions must repeat access reads overtaken by a broker
+revocation revision. Uncertain open replies need best-effort node cleanup too.
+Keep target-controlled HTTP/WS queues and messages within the lane byte budget,
+and retain the cookie-jar limits documented in the architecture guide.
+
+
+Preview opens reserve capacity before awaiting authorization: at most 64
+retained forward capabilities and 32 pending opens per node. Authorization
+retries if a revocation revision changes during the check, and a watch's final
+check must repeat view-only port restrictions after an edit-to-view downgrade.
+Preview transport senders check buffered bytes before sealing; a failed sealed
+write closes the link because secretstream frames cannot be skipped.
+
+Web previews & screens REST resources use `/api/subshells/:id/views` (discovery),
+`/views/open`, and `/views/browser/watch`. Node responses map the stored
+`webPreviewsEnabled` flag directly; the setting is `/views-enabled` and
+the prerequisite probe is `/display-readiness`. Persisted grants, audit and node
+wire identifiers remain stable; see the naming section in the architecture guide.
+
+Virtual displays use the shared provider runtime in `pane-runtime/preview`.
+The node row's `virtualDisplayProviders` is the allowed set and
+`virtualDisplayProvider` its default; the signed `displayProviders` and
+`displayProvider` mirror carries both to enrolled nodes. Creation resolves and
+persists a provider with display dimensions; restart rechecks policy without
+switching providers, and manual restart refuses before stopping the pane. The server launcher
+consumes provider-owned `launchEnv()` values without constructing X11 settings.
+Screen metadata exposes an opaque display id and a typed viewer connection,
+never local launch credentials. See [the provider contract](../../../docs/preview-architecture.md#display-providers-and-viewer-protocols).
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/api/__tests__/browser-origin.test.ts b/apps/server/api/src/api/__tests__/browser-origin.test.ts
index 66003139a..e75721df5 100644
--- a/apps/server/api/src/api/__tests__/browser-origin.test.ts
+++ b/apps/server/api/src/api/__tests__/browser-origin.test.ts
@@ -47,6 +47,13 @@ describe("browser origin authorization", () => {
port = boundPort;
});
+ it("limits the opaque-origin CORS exception to valid preview paths", async () => {
+ const requestFor = (path: string) => new Request(`http://localhost${path}`, { headers: { origin: "null" } });
+ expect(corsOriginAllowed(requestFor("/pv/0123456789abcdef0123456789abcdef/filter.js"))).toBe(true);
+ expect(corsOriginAllowed(requestFor("/api/subshells"))).toBe(false);
+ expect(corsOriginAllowed(requestFor("/pv/invalid/index.html"))).toBe(false);
+ });
+
afterEach(async () => {
originRegistry().clearPlugin(PLUGIN);
await db.deleteFrom("prompts").where("userId", "=", userId).execute();
diff --git a/apps/server/api/src/api/__tests__/presets-route.test.ts b/apps/server/api/src/api/__tests__/presets-route.test.ts
index cf04587de..cd1ed0ce7 100644
--- a/apps/server/api/src/api/__tests__/presets-route.test.ts
+++ b/apps/server/api/src/api/__tests__/presets-route.test.ts
@@ -399,6 +399,33 @@ describe("preset writes carry the launch fields (spec 2026-09-29)", () => {
delete process.env.CLAUDE_PATH;
});
+ it("persists a display provider on create, preserves it on unrelated edits, and clears it explicitly", async () => {
+ const screen = { width: 1440, height: 900, provider: "xvfb-x11vnc" };
+ const created = await app.fetch(
+ authedRequest("/api/presets", ownerCookie, {
+ method: "POST",
+ body: JSON.stringify({ harnessId: "claude-code", name: "display-preset", screen }),
+ }),
+ );
+ expect(created.status).toBe(200);
+ const body = (await created.json()) as { id: string; screenConfig: string };
+ createdPresetIds.push(body.id);
+ expect(JSON.parse(body.screenConfig)).toEqual(screen);
+ const changed = await app.fetch(
+ authedRequest(`/api/presets/${body.id}`, ownerCookie, {
+ method: "PUT",
+ body: JSON.stringify({ name: "display-renamed" }),
+ }),
+ );
+ expect(changed.status).toBe(200);
+ expect((await repo.findById(body.id))?.screenConfig).toBe(JSON.stringify(screen));
+ const cleared = await app.fetch(
+ authedRequest(`/api/presets/${body.id}`, ownerCookie, { method: "PUT", body: JSON.stringify({ screen: null }) }),
+ );
+ expect(cleared.status).toBe(200);
+ expect((await repo.findById(body.id))?.screenConfig).toBeNull();
+ });
+
it("POST carries the three launch fields, all null when the body says none", async () => {
// The "no node dimension" posture is reversed by spec 2026-09-29: the row
// now NAMES a launch (node, dir, prompt) as optional columns. A body that
@@ -433,6 +460,7 @@ describe("preset writes carry the launch fields (spec 2026-09-29)", () => {
"promptBlocks",
"restartOnExit",
"settingsJson",
+ "screenConfig",
"updatedAt",
"userId",
"workingDir",
diff --git a/apps/server/api/src/api/models.ts b/apps/server/api/src/api/models.ts
index bef424cb8..b2dc8b5ad 100644
--- a/apps/server/api/src/api/models.ts
+++ b/apps/server/api/src/api/models.ts
@@ -1,4 +1,5 @@
import { t } from "elysia";
+import { VirtualDisplayRequestSchema } from "@/schema/virtual-display.js";
/**
* One declared port forward as the pane header shows it (spec 2026-10-15):
@@ -38,6 +39,9 @@ export const UserInfoSchema = t.Object({
});
export const PresetSchema = t.Object({
+ screenConfig: t.Optional(
+ t.Nullable(t.String({ description: "JSON virtual display launch request; null means no display" })),
+ ),
ownerName: t.Optional(t.String({ description: "Preset owner display name" })),
access: t.Optional(
t.Union([t.Literal("owner"), t.Literal("edit"), t.Literal("view")], {
@@ -67,6 +71,10 @@ export const PresetSchema = t.Object({
});
export const SubshellSchema = t.Object({
+ screen: t.Nullable(VirtualDisplayRequestSchema),
+ virtualDisplay: t.Nullable(
+ t.String({ description: "The subshell’s known virtual display, or null when none is available" }),
+ ),
id: t.String({ description: "Subshell id" }),
presetId: t.Nullable(t.String({ description: "Preset launched with (null = presetless launch)" })),
harnessId: t.String({ description: "Harness plugin id" }),
diff --git a/apps/server/api/src/api/nodes/__tests__/preview-display.route.test.ts b/apps/server/api/src/api/nodes/__tests__/preview-display.route.test.ts
new file mode 100644
index 000000000..1d2b09f20
--- /dev/null
+++ b/apps/server/api/src/api/nodes/__tests__/preview-display.route.test.ts
@@ -0,0 +1,122 @@
+import { afterAll, beforeAll, describe, expect, it } from "bun:test";
+import { previewDisplayReadiness } from "@internal/pane-runtime";
+import { hashPassword } from "better-auth/crypto";
+import { nodesRoutes } from "@/api/nodes/index.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 { ensureLocalNode } from "@/services/nodes/seed-local.js";
+import { issueSubshellToken } from "@/services/subshell-tokens.js";
+import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js";
+
+describe("virtual display preflight route", () => {
+ const email = `display-${crypto.randomUUID()}@test.test`;
+ const password = "display-readiness-test";
+ const nodeId = crypto.randomUUID();
+ let cookie: string;
+ let bearer: string;
+ const sid = crypto.randomUUID();
+ const foreignId = crypto.randomUUID();
+ const foreignEmail = `foreign-${crypto.randomUUID()}@test.test`;
+ beforeAll(async () => {
+ await setupAuthTables();
+ const owner = await new UsersRepository(db).createUser({
+ email,
+ name: "Display test",
+ passwordHash: await hashPassword(password),
+ role: "admin",
+ });
+ cookie = await signIn(email, password);
+ await new SubshellsRepository(db).create({
+ id: sid,
+ userId: owner,
+ presetId: "p",
+ harnessId: "terminal",
+ name: "Probe",
+ workingDir: "/tmp",
+ tmuxSocket: null,
+ });
+ bearer = await issueSubshellToken(sid, owner);
+ const foreignOwner = await new UsersRepository(db).createUser({
+ email: foreignEmail,
+ name: "Foreign",
+ passwordHash: await hashPassword(password),
+ role: "user",
+ });
+ await db
+ .insertInto("nodes")
+ .values({
+ id: foreignId,
+ ownerUserId: foreignOwner,
+ name: "Shared foreign machine",
+ kind: "agent",
+ status: "offline",
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ } as never)
+ .execute();
+ await new NodeSharesRepository(db).replaceForNode(
+ foreignId,
+ [{ granteeUserId: owner, permission: "edit" }],
+ foreignOwner,
+ );
+
+ await ensureLocalNode(db);
+ const now = new Date().toISOString();
+ await db
+ .insertInto("nodes")
+ .values({
+ id: nodeId,
+ ownerUserId: owner,
+ name: "Offline machine",
+ kind: "agent",
+ status: "offline",
+ createdAt: now,
+ updatedAt: now,
+ } as never)
+ .execute();
+ });
+ afterAll(async () => {
+ await db.deleteFrom("nodes").where("id", "=", nodeId).execute();
+ await db.deleteFrom("subshells").where("id", "=", sid).execute();
+ await db.deleteFrom("nodes").where("id", "=", foreignId).execute();
+ await deleteUserByEmailOrId(foreignEmail);
+ await deleteUserByEmailOrId(email);
+ });
+ function get(id: string, authenticated = true) {
+ return nodesRoutes.fetch(
+ new Request(`http://localhost:3080/api/nodes/${id}/display-readiness`, {
+ headers: authenticated ? { cookie: `better-auth.session_token=${cookie}` } : {},
+ }),
+ );
+ }
+ function bearerGet(path: string) {
+ return nodesRoutes.fetch(
+ new Request(`http://localhost:3080/api/nodes/${path}`, { headers: { authorization: `Bearer ${bearer}` } }),
+ );
+ }
+ it("lets a real subshell key check only its owner's enrolled node", async () => {
+ expect((await bearerGet(`${nodeId}/display-readiness`)).status).toBe(409);
+ expect((await bearerGet(`${foreignId}/display-readiness`)).status).toBe(404);
+ expect((await bearerGet("local/display-readiness")).status).toBe(404);
+ expect((await bearerGet(`${crypto.randomUUID()}/display-readiness`)).status).toBe(404);
+ });
+ it("doesn't widen management detail access", async () => {
+ expect((await bearerGet(nodeId)).status).toBe(403);
+ });
+ it("requires authentication", async () => {
+ expect((await get("local", false)).status).toBe(401);
+ });
+ it("checks the built-in host even before Preview is enabled", async () => {
+ const response = await get("local");
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual(await previewDisplayReadiness());
+ });
+ it("keeps missing nodes private", async () => {
+ expect((await get(crypto.randomUUID())).status).toBe(404);
+ });
+ it("doesn't report an offline machine as ready", async () => {
+ expect((await get(nodeId)).status).toBe(409);
+ });
+});
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..0f4e458a1
--- /dev/null
+++ b/apps/server/api/src/api/nodes/__tests__/set-node-preview-enabled.route.test.ts
@@ -0,0 +1,326 @@
+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 { 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/views-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[] = [];
+ 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({
+ 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