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> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.preview_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put( + nodeId: string, + cookie: string | undefined, + on: boolean, + bearer?: string, + feature?: "web-previews" | "display", + provider?: string, + providers?: string[], + ) { + const headers: Record = { "content-type": "application/json" }; + if (cookie) headers.cookie = `better-auth.session_token=${cookie}`; + if (bearer) headers.authorization = `Bearer ${bearer}`; + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/views-enabled`, { + method: "PUT", + headers, + body: JSON.stringify({ on, feature, provider, providers }), + }), + ); + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + // Offline on purpose: the route answers anyway (the flag is the + // plane's record), and no push can be seen from here - the push + // assertions live in the service test's scripted-wire pattern. + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("subshells").where("id", "=", KEYED).execute(); + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found: every file in this + // package gets its own database, but a flipped host switch still reads as + // the fleet default if a later test in THIS file's process re-reads it. + await db + .updateTable("nodes") + .set({ webPreviewsEnabled: 0, previewEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const id of createdKeyIds) { + await systemKeysRoutes.fetch(authDelete(id)); + } + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + function authDelete(id: string): Request { + return new Request(`http://localhost:3080/api/system-keys/${id}`, { + method: "DELETE", + headers: { cookie: `better-auth.session_token=${adminCookie}` }, + }); + } + + it("reads OFF before anyone has flipped it (no row = no history = off)", async () => { + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}`, { + headers: { cookie: `better-auth.session_token=${ownerCookie}` }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { webPreviewsEnabled: boolean }).webPreviewsEnabled).toBe(false); + }); + + it("the owner turns it on, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + const view = (await res.json()) as { webPreviewsEnabled: boolean }; + expect(view.webPreviewsEnabled).toBe(true); + }); + + it("names the acting human and names the selected feature in the audit row", async () => { + // The ROUTE is the only place `actorUserId` is wired in: the service + // takes it as an argument, so a regression to a null actor passes every + // test of the service alone. + await put(NODE, ownerCookie, false); + const before = await previewAudit(); + + expect((await put(NODE, ownerCookie, true)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered, so a newest-first read + // cannot name this act's row. + const added = [...(await previewAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + // The audit names the changed permission, without secrets. + expect(JSON.parse(added[0][1].metadataJson ?? "null")).toEqual({ on: true, feature: "web-previews" }); + }); + + it("turning it off lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { webPreviewsEnabled: boolean }).webPreviewsEnabled).toBe(false); + }); + + it("an edit grantee is refused: it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; deciding whether someone may watch this machine's panes is a + // different act. + expect((await put(NODE, editorCookie, true)).status).toBe(403); + }); + + it("a system key scoped to SSH cannot flip preview: the grants are separate", async () => { + // The catalog split this task had to make: reusing `nodes:ssh` would let + // every ssh-scoped key flip preview. A key minted with the SSH grant and + // without `nodes:preview` is refused at the permission layer, before any + // gate reads the row. + const auditsBefore = (await previewAudit()).size; + const created = await systemKeysRoutes.fetch( + new Request("http://localhost:3080/api/system-keys", { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${adminCookie}` }, + body: JSON.stringify({ name: `pv-pin-${crypto.randomUUID().slice(0, 8)}`, permissions: ["nodes:ssh"] }), + }), + ); + expect(created.status).toBe(200); + const { key, id } = (await created.json()) as { key: string; id: string }; + createdKeyIds.push(id); + + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}/views-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, + body: JSON.stringify({ on: true }), + }), + ); + expect(res.status).toBe(403); + // The scoped-key refusal comes from the authGuard's onBeforeHandle as a + // plain-text HttpError body, not the route's JSON error shape - the route + // never ran. The grant it names is the point. + expect(await res.text()).toContain("nodes:preview"); + // The refusal is real: the audit trail did not grow. + expect((await previewAudit()).size).toBe(auditsBefore); + }); + + it("refuses a valid bearer credential outright", async () => { + // Machine credentials never manage a machine. The key is a LIVE one + // belonging to the node's own owner, so this 403 is `nodeManagementGuard` + // classifying the actor, not authentication failing, which a bogus string + // would not have distinguished. It is also the only cell here that pins + // the guard's MOUNT: a scoped key is refused one layer earlier, by + // authGuard's grant check, so dropping `.use(nodeManagementGuard)` from + // the route would let authGuard resolve a pane's token as its OWNER, flip + // `canManage` on the owner's own node, and leave every other test in this + // file green. + // + // Minted inside the test rather than in `beforeAll`, the maintenance + // precedent's discipline: terminating a subshell revokes its token, so + // this test owns its key end to end. + await subshells.create({ + id: KEYED, + userId: ownerId, + presetId: "p", + harnessId: "claude-code", + name: KEYED, + workingDir: "/tmp", + tmuxSocket: null, + }); + const key = await issueSubshellToken(KEYED, ownerId); + const auditsBefore = (await previewAudit()).size; + + const res = await put(NODE, undefined, true, key); + expect(res.status).toBe(403); + // The refusal names the guard, not the preview grant: a pane token + // carries no node permission map at all, so it never reaches the + // `nodes:preview` check the scoped-key cell above pins. (The guard + // answers with a plain-text HttpError, the same shape the scoped-key + // refusal has; the route's JSON error never ran.) + expect(await res.text()).toContain("cannot manage nodes"); + // The refusal is real: the row did not move. + expect((await previewAudit()).size).toBe(auditsBefore); + }); + + it("a stranger gets 404, never 403: an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, true)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, true)).status).toBe(404); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, true)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there: refused, not invisible (a node on the page must + // not answer "not found"). + expect((await put(LOCAL_NODE_ID, otherCookie, true)).status).toBe(403); + // Like SSH and unlike maintenance, this flip stops nothing, so ON is safe + // to test on the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { webPreviewsEnabled: boolean }).webPreviewsEnabled).toBe(true); + }); + it("changes the two permissions independently and checks ownership for both", async () => { + await put(NODE, ownerCookie, false); + const display = await put(NODE, ownerCookie, true, undefined, "display"); + expect(display.status).toBe(200); + expect(await display.json()).toMatchObject({ webPreviewsEnabled: false, virtualDisplayEnabled: true }); + const apps = await put(NODE, ownerCookie, true); + expect(await apps.json()).toMatchObject({ webPreviewsEnabled: true, virtualDisplayEnabled: true }); + const displayOff = await put(NODE, ownerCookie, false, undefined, "display"); + expect(await displayOff.json()).toMatchObject({ webPreviewsEnabled: true, virtualDisplayEnabled: false }); + expect((await put(NODE, otherCookie, true, undefined, "display")).status).toBe(404); + expect((await put(NODE, adminCookie, true, undefined, "display")).status).toBe(403); + }); + it("persists allowed providers, allows none, and rejects invalid policy without writing", async () => { + const off = await put(NODE, ownerCookie, true, undefined, "display", undefined, []); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ virtualDisplayEnabled: true, virtualDisplayProviders: [] }); + for (const invalid of [["unknown-provider"], ["xvfb-x11vnc", "xvfb-x11vnc"]]) { + const response = await put(NODE, ownerCookie, true, undefined, "display", undefined, invalid); + expect([400, 422]).toContain(response.status); + } + expect((await put(NODE, editorCookie, true, undefined, "display", undefined, ["xvfb-x11vnc"])).status).toBe(403); + expect((await put(NODE, ownerCookie, true, undefined, "web-previews", undefined, ["xvfb-x11vnc"])).status).toBe( + 400, + ); + const row = await db.selectFrom("nodes").selectAll().where("id", "=", NODE).executeTakeFirstOrThrow(); + expect(row.virtualDisplayProviders).toBe("[]"); + const on = await put(NODE, ownerCookie, true, undefined, "display", "xvfb-x11vnc", ["xvfb-x11vnc"]); + expect(await on.json()).toMatchObject({ + virtualDisplayProviders: ["xvfb-x11vnc"], + virtualDisplayProvider: "xvfb-x11vnc", + }); + }); + it("stores the provider with display permission and rejects unknown or unauthorized choices", async () => { + const result = await put(NODE, ownerCookie, true, undefined, "display", "xvfb-x11vnc"); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ virtualDisplayEnabled: true, virtualDisplayProvider: "xvfb-x11vnc" }); + const row = await db.selectFrom("nodes").selectAll().where("id", "=", NODE).executeTakeFirstOrThrow(); + expect(row.virtualDisplayProvider).toBe("xvfb-x11vnc"); + expect((await put(NODE, ownerCookie, true, undefined, "display", "unknown-provider")).status).toBe(400); + expect((await put(NODE, ownerCookie, true, undefined, "web-previews", "xvfb-x11vnc")).status).toBe(400); + expect((await put(NODE, editorCookie, true, undefined, "display", "xvfb-x11vnc")).status).toBe(403); + const off = await put(NODE, ownerCookie, false, undefined, "display"); + expect(await off.json()).toMatchObject({ virtualDisplayEnabled: false, virtualDisplayProvider: "xvfb-x11vnc" }); + }); +}); diff --git a/apps/server/api/src/api/nodes/display-readiness.route.ts b/apps/server/api/src/api/nodes/display-readiness.route.ts new file mode 100644 index 000000000..d7a00b583 --- /dev/null +++ b/apps/server/api/src/api/nodes/display-readiness.route.ts @@ -0,0 +1,66 @@ +import { parsePreviewDisplayReadiness } from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { authGuard, HttpError } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { getLocalPreviewRuntime } from "@/preview/local-preview.js"; +import { apiModels } from "@/schema/index.js"; +import { DisplayProviderIdSchema } from "@/schema/virtual-display.js"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; + +/** Read-only launch preflight. Machine actors may inspect owned enrolled nodes only, as in list_nodes; loadNodeGate never inherits shares or admin rights for them. */ +export const displayReadinessRoute = new Elysia() + .use(authGuard) + .use(apiModels) + .get( + "/:id/display-readiness", + async ({ params, query, user, actor }) => { + const gate = await loadNodeGate(user.id, params.id, actor); + if (!gate) throw new HttpError(404, "Node not found"); + if (gate.row.kind === "local") return getLocalPreviewRuntime().displays.readiness(query.provider); + try { + const result = parsePreviewDisplayReadiness( + await sendCommand(params.id, { + type: "preview_display_check", + ...(query.provider ? { provider: query.provider } : {}), + }), + ); + if (!result) throw new HttpError(502, "Couldn't check virtual display requirements. Recheck before launching."); + return result; + } catch (error) { + if (error instanceof NodeRpcError) { + throw new HttpError( + 409, + error.code === "unsupported" + ? "Update Subshell on the selected node, then recheck virtual display requirements." + : error.code === "offline" + ? "Couldn't reach the selected node. Bring it online, then recheck virtual display requirements." + : "Couldn't check the selected node. Make sure it is online and up to date, then choose Recheck.", + ); + } + throw error; + } + }, + { + query: t.Object({ provider: t.Optional(DisplayProviderIdSchema) }), + response: { + 200: t.Object({ + supported: t.Boolean({ description: "Whether the selected display provider supports the selected node" }), + missing: t.Array(t.String({ minLength: 1, maxLength: 128, description: "An unmet provider requirement" }), { + maxItems: 32, + description: "Programs, permissions, or other prerequisites needed by the provider", + }), + }), + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + 502: "ApiErrorResponse", + }, + systemPermission: "nodes:read", + detail: { + operationId: "getNodeDisplayReadiness", + tags: ["nodes"], + description: "Check virtual display prerequisites on the selected node", + }, + }, + ); diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index 5703f1c03..de812e4da 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { createSetupKeyRoute } from "@/api/nodes/create-setup-key.route.js"; import { deleteNodeRoute } from "@/api/nodes/delete-node.route.js"; import { deleteSetupKeyRoute } from "@/api/nodes/delete-setup-key.route.js"; +import { displayReadinessRoute } from "@/api/nodes/display-readiness.route.js"; import { enrollRoute } from "@/api/nodes/enroll.route.js"; import { getNodeRoute } from "@/api/nodes/get-node.route.js"; import { getNodeSharesRoute } from "@/api/nodes/get-node-shares.route.js"; @@ -22,6 +23,7 @@ import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route. import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; +import { setNodeViewsEnabledRoute } from "@/api/nodes/set-node-views-enabled.route.js"; import { updateNodeRoute } from "@/api/nodes/update-node.route.js"; import { routeDiscoveryRoute } from "./route-discovery.route.js"; @@ -57,6 +59,8 @@ const nodeRegistryRoutes = new Elysia() const nodeOperationsRoutes = new Elysia() .use(setNodeMaintenanceRoute) .use(setNodeSshEnabledRoute) + .use(setNodeViewsEnabledRoute) + .use(displayReadinessRoute) .use(repairNodeMachinePinRoute) .use(rotateNodeKeyRoute) .use(reregisterNodeRoute) diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index 3ca55960c..53dd3574c 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -1,8 +1,10 @@ import { readSystemSpecs } from "@internal/pane-runtime"; import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, type NodeSshFingerprintReport, parseNodeSshFingerprintReport, parseNodeSystemSpecs, + storedDisplayProviderIds, } from "@internal/subshell-protocol"; import { type Static, t } from "elysia"; import { db } from "@/db/index.js"; @@ -181,6 +183,17 @@ export const NodeViewSchema = t.Object({ description: "Whether this machine may be used for Subshell SSH (outbound ssh and serving its keys). Opt-in per node, off by default on every row including the control-plane host; the launch gate refuses SSH here while false, whoever asks", }), + virtualDisplayProviders: t.Array(t.String({ description: "Enabled provider identifier" }), { + description: "Providers allowed for new virtual displays", + }), + virtualDisplayProvider: t.String({ description: "Provider selected for new virtual displays" }), + virtualDisplayEnabled: t.Boolean({ + description: "Whether this node permits virtual displays. Each subshell must explicitly request one.", + }), + webPreviewsEnabled: t.Boolean({ + description: + "Whether authorized viewers may open apps and services and watch browsers in any subshell on this machine. Opt-in per node, off by default including the control-plane host; view operations are refused while false", + }), sshWideForwards: t.Boolean({ description: "Whether SSH port forwards on this machine may bind non-loopback addresses. Off by default: forwards bind loopback only, and widening turns the machine into a router for whatever network reaches the bound port. The owner's decision, refused at launch while false", @@ -483,6 +496,10 @@ function nodeViewBase( maintenanceAt: row.maintenanceAt, maintenanceSource: row.maintenanceSource, sshEnabled: row.sshEnabled === 1, + webPreviewsEnabled: row.webPreviewsEnabled === 1, + virtualDisplayEnabled: row.virtualDisplayEnabled === 1, + virtualDisplayProvider: row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + virtualDisplayProviders: storedDisplayProviderIds(row.virtualDisplayProviders), sshWideForwards: row.sshWideForwards === 1, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, diff --git a/apps/server/api/src/api/nodes/set-node-views-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-views-enabled.route.ts new file mode 100644 index 000000000..360966d66 --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-views-enabled.route.ts @@ -0,0 +1,113 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + storedDisplayProviderIds, + VIRTUAL_DISPLAY_PROVIDERS, +} from "@internal/subshell-protocol"; +import { Elysia, t } from "elysia"; +import { ForbiddenError, HttpError } from "@/api/auth-guard.js"; +import { loadNodeGate } from "@/api/nodes/node-gate.js"; +import { nodeManagementGuard } from "@/api/nodes/node-management-guard.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { apiErrorBody } from "@/lib/api-error.js"; +import { apiModels } from "@/schema/index.js"; +import { setPreviewEnabled } from "@/services/nodes/preview-enabled.js"; + +const PreviewEnabledBodySchema = t.Object({ + providers: t.Optional( + t.Array(t.String({ minLength: 1, maxLength: 64, description: "Enabled provider identifier" }), { + maxItems: 32, + uniqueItems: true, + description: "Providers allowed for new virtual displays", + }), + ), + provider: t.Optional( + t.String({ minLength: 1, maxLength: 64, description: "Bundled provider for new virtual displays on this node" }), + ), + feature: t.Optional(t.Union([t.Literal("web-previews"), t.Literal("display")])), + on: t.Boolean({ + description: + "Allow the selected feature on this machine. Web previews apply to every subshell; virtual displays must be requested at launch. Off by default. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/views-enabled` `{on}`: open or close pane preview on + * one machine (pane-preview spec Task 3). Browser session or an API key + * holding the `nodes:preview` grant. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as SSH and maintenance: a grantee may restart the agent or + * read its log, but deciding whether this machine's pane content may be + * watched is not theirs. The permission is its OWN catalog id, not + * `nodes:ssh`. A key scoped to SSH must not be able to flip preview; the + * grants answer different questions. + * + * The route writes nothing and audits nothing itself: `setPreviewEnabled` is + * the one change-site (row + audit + push), so no caller can perform half the + * act. The route is the only place the acting human is wired in. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` reconcile. + * Refusing would make the flag settable only while the machine is reachable, + * which is backwards for a gate that exists to be closed. + */ +export const setNodeViewsEnabledRoute = new Elysia() + .use(nodeManagementGuard) + .use(apiModels) + .put( + "/:id/views-enabled", + async ({ params, body, user, actor, status }) => { + const gate = await loadNodeGate(user.id, params.id, actor); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + if ( + body.provider && + (body.feature !== "display" || !VIRTUAL_DISPLAY_PROVIDERS.some((p) => p.id === body.provider)) + ) + throw new HttpError(400, "Choose an available virtual display provider."); + let providers: string[] | undefined; + let provider = body.provider; + if (body.providers !== undefined && body.feature !== "display") + throw new HttpError(400, "Provider settings apply only to virtual displays."); + if (body.feature === "display") { + providers = body.providers ?? storedDisplayProviderIds(gate.row.virtualDisplayProviders); + if (providers.some((id) => !VIRTUAL_DISPLAY_PROVIDERS.some((p) => p.id === id))) + throw new HttpError(400, "Choose available virtual display providers."); + provider ??= gate.row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; + if (providers.length && !providers.includes(provider)) { + if (body.provider) throw new HttpError(400, "The default display provider must be enabled on this node."); + provider = providers[0]; + } + } + await setPreviewEnabled(gate.row.id, user.id, body.on, body.feature, provider, providers); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored, not what this + // handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: PreviewEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "nodes:preview", + detail: { + operationId: "setNodeViewsEnabled", + tags: ["nodes"], + description: + "Enable or disable web previews and virtual displays on a node (owner only; admin on the control-plane host). Off by default; enabling lets authorized viewers watch this machine's pane content.", + }, + }, + ); diff --git a/apps/server/api/src/api/presets.route.ts b/apps/server/api/src/api/presets.route.ts index d10c36640..4fd9efbb3 100644 --- a/apps/server/api/src/api/presets.route.ts +++ b/apps/server/api/src/api/presets.route.ts @@ -32,6 +32,7 @@ import { loadNodeAccess, type NodeAccessDeps, nodeCanLaunch, nodeCanLaunchOn } f import { presetAccess, presetOwnerName, withPresetAccess } from "@/lib/preset-access.js"; import { duplicatePresetName } from "@/lib/preset-name-error.js"; import { apiModels } from "@/schema/index.js"; +import { VirtualDisplayRequestSchema } from "@/schema/virtual-display.js"; import { PORTABLE_MCP_LAUNCH, PORTABLE_REPORTER } from "@/services/mcp-resolve.js"; import { presetShares } from "@/services/preset-sharing.service.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; @@ -184,6 +185,7 @@ const CreatePresetBodySchema = t.Object({ flags: t.Optional(t.Array(t.String(), { description: "Extra CLI flags" })), settings: t.Optional(t.Record(t.String(), t.Any(), { description: "Settings JSON object" })), configIsolation: t.Optional(t.Boolean({ description: "Config source isolation" })), + screen: t.Optional(t.Nullable(VirtualDisplayRequestSchema)), restartOnExit: t.Optional(t.Boolean({ description: "New subshells auto-restart on exit" })), crossCommNotes: t.Optional( t.Nullable( @@ -225,6 +227,7 @@ const UpdatePresetBodySchema = t.Object( flags: t.Optional(t.Array(t.String(), { description: "Extra CLI flags" })), settings: t.Optional(t.Record(t.String(), t.Any(), { description: "Settings JSON object" })), configIsolation: t.Optional(t.Boolean({ description: "Config source isolation" })), + screen: t.Optional(t.Nullable(VirtualDisplayRequestSchema)), restartOnExit: t.Optional(t.Boolean({ description: "New subshells auto-restart on exit" })), crossCommNotes: t.Optional( t.Nullable( @@ -257,6 +260,7 @@ const UPDATE_PRESET_KEYS = new Set([ "settings", "configIsolation", "restartOnExit", + "screen", "crossCommEnabled", "crossCommNotes", "nodeId", @@ -383,6 +387,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) settingsJson: body.settings ? JSON.stringify(body.settings) : null, configIsolation: body.configIsolation ? 1 : 0, restartOnExit: body.restartOnExit ? 1 : 0, + screenConfig: body.screen ? JSON.stringify(body.screen) : null, crossCommEnabled: body.crossCommEnabled ? 1 : 0, crossCommNotes: body.crossCommNotes ?? null, ...launch, @@ -599,6 +604,7 @@ export const presetRoutes = new Elysia({ prefix: "/api/presets" }) configIsolation: body.configIsolation !== undefined ? (body.configIsolation ? 1 : 0) : existing.configIsolation, restartOnExit: body.restartOnExit !== undefined ? (body.restartOnExit ? 1 : 0) : existing.restartOnExit, + ...(body.screen !== undefined ? { screenConfig: body.screen ? JSON.stringify(body.screen) : null } : {}), crossCommEnabled, ...(body.crossCommNotes !== undefined ? { crossCommNotes: body.crossCommNotes } : {}), // The launch fields merge by KEY PRESENCE (explicit null clears), diff --git a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts new file mode 100644 index 000000000..84ec7337f --- /dev/null +++ b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts @@ -0,0 +1,1202 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { BackendErrorCodes } from "@internal/backend-errors"; +import { hashPassword } from "better-auth/crypto"; +import { Elysia } from "elysia"; +import { subshellRoutes } from "@/api/subshells/index.js"; +import { authDatabase } from "@/auth/database.js"; +import { ensureSystemUser } from "@/auth/system-user.js"; +import { getAuth } from "@/auth.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { PresetsRepository } from "@/db/repositories/presets.repository.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { holdConnection, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { ensureLocalNode } from "@/services/nodes/seed-local.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { issueSubshellToken } from "@/services/subshell-tokens.js"; +import { + attachScriptedNode, + ok, + type ScriptedHandlers, + type ScriptedNode, + statDirEcho, +} from "@/test-helpers/scripted-node.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../__tests__/helpers/auth-tables.js"; + +/** + * The Task 9 authz matrix and shape contract for the preview REST surface: + * `GET /api/subshells/:id/views`, `POST /:id/views/open`, + * `POST /:id/views/browser/watch`, `DELETE /:id/views/browser/watch`, and the create + * path's gated `screen` field. + * + * The spine is the actor table, cloned from the ssh route suites: `view` + * reads and watches, `edit` also forwards, a stranger sees only 404s, and + * the pane's own subshell key resolves as its OWNER with shares off (docs/ + * security.md §4), so it acts on its own rows and cannot discover foreign + * ones. Node keys 401 on REST (§5.5), a system key is nowhere without a + * deliberate grant and lands wherever one points (§3), and a scoped key + * without the route's annotation permission is refused at the annotation + * with the NAMED-permission branch. + * + * The shape rows pin spec §6's truthfulness rules: an unreachable node + * answers the same shape with `nodeEnabled: false` and a reason rather than + * an error; a switch-on non-Linux node answers `nodeEnabled: true` with the + * ports refusal NAMED (a blank list with no reason reads as "nothing is + * listening"); a display that died mid-pane passes `screenLost` through + * named beside `screen: null` (spec §9); and a terminated pane short-circuits + * ROUTE-side without any RPC leaving the plane. + */ + +const app = new Elysia().use(errorHandlerPlugin).use(subshellRoutes); + +const nodes = new NodesRepository(db); +const subshells = new SubshellsRepository(db); +const shares = new SubshellSharesRepository(db); + +const nodeId = `pvnode-${crypto.randomUUID().slice(0, 8)}`; + +/** The pane's node, live by default; each test overrides what it answers. */ +function liveHandlers(overrides: ScriptedHandlers = {}): ScriptedHandlers { + return { + stat_dir: statDirEcho, + launch: ok, + preview_open: (cmd) => (cmd.type === "preview_open" ? { ref: cmd.ref, subscribers: 1 } : new Error("wrong cmd")), + preview_close: ok, + preview_ports_get: () => ({ + ports: [{ proto: "tcp", port: 3000, pid: 4242 }], + cdpPort: 9222, + screen: null, + }), + ...overrides, + }; +} + +let sim: ScriptedNode; + +function attachLive(overrides: ScriptedHandlers = {}) { + sim?.detach(); + sim = attachScriptedNode(nodeId, liveHandlers(overrides)); + return sim; +} + +/** Call a preview door (or any subshell path) with one actor's credential. */ +async function call( + method: string, + path: string, + opts: { cookie?: string; bearer?: string; body?: unknown } = {}, +): Promise { + const headers = new Headers(); + if (opts.cookie) headers.set("cookie", `better-auth.session_token=${opts.cookie}`); + if (opts.bearer) headers.set("authorization", `Bearer ${opts.bearer}`); + if (opts.body !== undefined) { + headers.set("content-type", "application/json"); + } + return app.fetch( + new Request(`http://localhost:3080/api/subshells${path}`, { + method, + headers, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }), + ); +} + +const errorBody = async (res: Response) => (await res.json()) as { code: string; message: string }; +const jsonBody = async (res: Response) => (await res.json()) as Record; + +/** The four preview doors, so the actor table reads as a table. */ +const DOORS = [ + { label: "info", method: "GET", path: (id: string) => `/${id}/views` }, + { label: "open", method: "POST", path: (id: string) => `/${id}/views/open`, body: { port: 3000 } }, + { label: "watch", method: "POST", path: (id: string) => `/${id}/views/browser/watch`, body: { port: 9222 } }, + { label: "watch stop", method: "DELETE", path: (id: string) => `/${id}/views/browser/watch` }, +] as const; + +describe("preview REST surface (Task 9)", () => { + const pw = "preview-pass-1"; + const ownerEmail = `pv-owner-${crypto.randomUUID()}@subshell.local`; + const viewerEmail = `pv-viewer-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `pv-editor-${crypto.randomUUID()}@subshell.local`; + const foreignEmail = `pv-foreign-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let ownerCookie: string; + let viewerId: string; + let viewerCookie: string; + let editorId: string; + let editorCookie: string; + let foreignId: string; + let foreignCookie: string; + let systemUserId: string; + const createdSubshellIds: string[] = []; + const createdKeyIds: string[] = []; + + /** A RUNNING row written straight to the table. */ + async function directRow( + overrides: { userId?: string; status?: "running" | "terminated"; name?: string } = {}, + ): Promise { + const id = crypto.randomUUID(); + createdSubshellIds.push(id); + await subshells.create({ + id, + userId: overrides.userId ?? ownerId, + presetId: null, + harnessId: "terminal", + name: overrides.name ?? `pv ${id.slice(0, 4)}`, + workingDir: "/tmp", + tmuxSocket: `pv-sock-${id}`, + nodeId, + status: overrides.status ?? "running", + alive: overrides.status === "terminated" ? 0 : 1, + ssh: null, + }); + return id; + } + + async function grantPane(paneId: string, granteeUserId: string, permission: "view" | "edit"): Promise { + await shares.replaceForSubshell(paneId, [{ granteeUserId, permission }], ownerId); + } + + async function bearerFor(paneId: string): Promise { + const token = await issueSubshellToken(paneId, ownerId); + const row = await subshells.findById(paneId); + if (row?.apiKeyId) createdKeyIds.push(row.apiKeyId); + return token; + } + + async function paneCount(userId: string): Promise { + const r = await db + .selectFrom("subshells") + .select((eb) => eb.fn.countAll().as("n")) + .where("userId", "=", userId) + .executeTakeFirst(); + return Number(r?.n ?? 0); + } + + /** + * ENABLED api keys owned by `id`. Revocation disables the row rather than + * deleting it (`revokeSubshellToken` -> `setApiKeyEnabled(…, false)`), so a + * rolled-back create must not change this count even though its key row + * still exists, disabled. + */ + function liveKeyCount(userId: string): number { + const row = authDatabase() + .prepare<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM apikey WHERE "referenceId" = ? AND enabled = 1`) + .get(userId); + return Number(row?.n ?? 0); + } + + /** The create-path rollback leaves the row terminated, not deleted: read its status. */ + async function paneStatusByName(userId: string, name: string): Promise { + const r = await db + .selectFrom("subshells") + .select("status") + .where("userId", "=", userId) + .where("name", "=", name) + .executeTakeFirst(); + return r?.status; + } + + beforeAll(async () => { + await setupAuthTables(); + resetNodeRegistryForTests(); + await ensureLocalNode(db); + const users = new UsersRepository(db); + const mk = async (email: string): Promise<[string, string]> => { + const id = await users.createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + return [id, await signIn(email, pw)]; + }; + [ownerId, ownerCookie] = await mk(ownerEmail); + [viewerId, viewerCookie] = await mk(viewerEmail); + [editorId, editorCookie] = await mk(editorEmail); + [foreignId, foreignCookie] = await mk(foreignEmail); + systemUserId = await ensureSystemUser(); + await nodes.create({ id: nodeId, ownerUserId: ownerId, name: "Preview Node", kind: "agent" }); + await nodes.setPreviewEnabled(nodeId, { on: true, changedAt: new Date().toISOString() }); + await nodes.setPreviewEnabled(nodeId, { + on: true, + feature: "display", + provider: "xvfb-x11vnc", + providers: ["xvfb-x11vnc"], + changedAt: new Date().toISOString(), + }); + }); + + afterAll(async () => { + sim?.detach(); + shutdownPreviewBrokerForTests(); + resetNodeRegistryForTests(); + for (const id of createdSubshellIds) await subshells.delete(id).catch(() => {}); + for (const kid of createdKeyIds) authDatabase().run("DELETE FROM apikey WHERE id = ?", [kid]); + for (const email of [ownerEmail, viewerEmail, editorEmail, foreignEmail]) { + await deleteUserByEmailOrId(email).catch(() => {}); + } + await nodes.deleteById(nodeId).catch(() => {}); + }); + + beforeEach(async () => { + // A fresh registry keeps a held connection from one row bleeding into the + // next; a fresh broker (production seams, so the real RPC reaches the + // scripted node) keeps lanes and watches from doing the same. + resetNodeRegistryForTests(); + resetForTests(); + await nodes.setPreviewEnabled(nodeId, { on: true, changedAt: new Date().toISOString() }); + await nodes.setPreviewEnabled(nodeId, { + on: true, + feature: "display", + provider: "xvfb-x11vnc", + providers: ["xvfb-x11vnc"], + changedAt: new Date().toISOString(), + }); + }); + + /* ---------------------------------------------------------------- */ + /* the actor table */ + /* ---------------------------------------------------------------- */ + + it("saves external services without a display, deduplicates, scopes IDs, and revokes on removal", async () => { + attachLive(); + const pane = await directRow(); + const other = await directRow(); + const input = { host: "EXAMPLE.COM", port: 443, protocol: "https", name: "Docs" }; + const saved = await call("POST", `/${pane}/services`, { cookie: ownerCookie, body: input }); + expect(saved.status).toBe(200); + const service = (await saved.json()) as { id: string; host: string }; + expect(service.host).toBe("example.com"); + const duplicate = await call("POST", `/${pane}/services`, { cookie: ownerCookie, body: input }); + expect(((await duplicate.json()) as { id: string }).id).toBe(service.id); + const list = await call("GET", `/${pane}/services`, { cookie: ownerCookie }); + expect(((await list.json()) as { id: string }[]).map((row) => row.id)).toEqual([service.id]); + expect( + (await call("POST", `/${other}/views/open`, { cookie: ownerCookie, body: { serviceId: service.id } })).status, + ).toBe(404); + const opened = await call("POST", `/${pane}/views/open`, { cookie: ownerCookie, body: { serviceId: service.id } }); + expect(opened.status).toBe(200); + const { proxyBase } = (await opened.json()) as { proxyBase: string }; + const ref = proxyBase.split("/")[2]; + expect(getPreviewBroker().sessionFor(ref)?.service).toEqual({ host: "example.com", protocol: "https" }); + expect(getPreviewBroker().sessionFor(ref)?.mode).toBe("http"); + expect((await call("DELETE", `/${pane}/services/${service.id}`, { cookie: ownerCookie })).status).toBe(200); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect( + (await call("POST", `/${pane}/views/open`, { cookie: ownerCookie, body: { serviceId: service.id } })).status, + ).toBe(404); + expect(await call("GET", `/${pane}/services`, { cookie: ownerCookie }).then((r) => r.json())).toEqual([]); + }); + + it("requires edit for saved service mutations, hides foreign services, and rejects URL hosts", async () => { + const pane = await directRow(); + await grantPane(pane, viewerId, "view"); + const body = { port: 3000, protocol: "http" }; + expect((await call("GET", `/${pane}/services`, { cookie: viewerCookie })).status).toBe(200); + expect((await call("POST", `/${pane}/services`, { cookie: viewerCookie, body })).status).toBe(403); + expect((await call("DELETE", `/${pane}/services/missing`, { cookie: viewerCookie })).status).toBe(403); + expect((await call("GET", `/${pane}/services`, { cookie: foreignCookie })).status).toBe(404); + expect( + (await call("POST", `/${pane}/services`, { cookie: ownerCookie, body: { ...body, host: "http://example.com" } })) + .status, + ).toBe(400); + const saved = await call("POST", `/${pane}/services`, { cookie: ownerCookie, body }); + expect(saved.status).toBe(200); + expect(((await saved.json()) as { host: string }).host).toBe("localhost"); + }); + + it("the owner reaches all four doors", async () => { + attachLive(); + const pane = await directRow(); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: ownerCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("an edit grantee reaches all four doors too", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, editorId, "edit"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: editorCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("a view grantee reads and watches, and is 403 refused the forward", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, viewerId, "view"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: viewerCookie, + ...("body" in door ? { body: door.body } : {}), + }); + // Forwarding a raw lane is `edit`; info and the watch doors are reads. + expect(res.status).toBe(door.label === "open" ? 403 : 200); + } + }); + + it("refuses a view grantee's attempt to watch another port on the node", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, viewerId, "view"); + const res = await call("POST", `/${pane}/views/browser/watch`, { cookie: viewerCookie, body: { port: 9333 } }); + expect(res.status).toBe(403); + expect((await errorBody(res)).message).toContain("browser discovered in this subshell"); + }); + + it("refuses an arbitrary watch port when edit access becomes view while opening", async () => { + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered = () => {}; + const started = new Promise((resolve) => { + entered = resolve; + }); + attachLive({ + preview_open: async (cmd) => { + entered(); + await gate; + return cmd.type === "preview_open" ? { ref: cmd.ref, subscribers: 1 } : {}; + }, + }); + const pane = await directRow(); + await grantPane(pane, editorId, "edit"); + const pending = call("POST", `/${pane}/views/browser/watch`, { cookie: editorCookie, body: { port: 9333 } }); + await started; + await grantPane(pane, editorId, "view"); + await getPreviewBroker().closeUnauthorizedForPane(pane); + release(); + const response = await pending; + expect(response.status).toBe(403); + expect(getPreviewBroker().sessionForPane(pane)).toBeNull(); + }); + + it("a foreign account sees 404 on all four doors, never a 403", async () => { + attachLive(); + const pane = await directRow(); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + cookie: foreignCookie, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + }); + + it("a pane's own key acts as its owner, shares off, on both sides (§4)", async () => { + attachLive(); + // docs/security.md §4: raw REST resolves a pane's own subshell token as + // its OWNER with boost and shares OFF. Both halves matter: the owner's + // own rows must answer 200 (that is the sibling-action door the posture + // ACCEPTS), and a foreign row must answer 404 (the token sees no shares, + // so what it cannot own simply is not there). + const own = await directRow(); + const token = await bearerFor(own); + const other = await directRow(); // still the owner's pane, reached as its owner + for (const door of DOORS) { + const res = await call(door.method, door.path(other), { + bearer: token, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + // The foreign half: a stranger's pane is invisible to the token. + const foreignPane = await directRow({ userId: foreignId }); + for (const door of DOORS) { + const res = await call(door.method, door.path(foreignPane), { + bearer: token, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + }); + + it("a node key 401s on REST with the explicit node-key message (§5.5)", async () => { + attachLive(); + const pane = await directRow(); + const created = (await getAuth().api.createApiKey({ + body: { name: "pv-node-key", userId: ownerId, metadata: { kind: "node", nodeId } }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(created.id); + // Pinned against the auth-guard-bearer.test.ts:167 precedent: the REST + // guard is a 401 surface for node keys, never a 403 that leaks a route. + const res = await call("GET", `/${pane}/views`, { bearer: created.key }); + expect(res.status).toBe(401); + const body = await errorBody(res); + expect(body.code).toBe(BackendErrorCodes.INVALID_CREDENTIALS); + expect(body.message).toMatch(/node keys/i); + }); + + it("a system key is nowhere ungranted, and a deliberate grant reaches it (§3)", async () => { + attachLive(); + const pane = await directRow(); + // A legacy unversioned system key carries `systemKeyPermissions === + // null`, which passes the scoped-key annotation gate untouched (that is + // why the pane-visibility rows carry the weight); the gate that refuses + // it is the ordinary access resolver: the system user owns nothing and + // holds no grant, so every door answers 404. + const sysKey = (await getAuth().api.createApiKey({ + body: { name: "pv-system", userId: systemUserId, metadata: { kind: "system" } }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(sysKey.id); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + bearer: sysKey.key, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(404); + } + // The positive half (docs/security.md §3): a deliberate grant to the + // system user DOES reach it. `edit` so all four doors answer. + await grantPane(pane, systemUserId, "edit"); + for (const door of DOORS) { + const res = await call(door.method, door.path(pane), { + bearer: sysKey.key, + ...("body" in door ? { body: door.body } : {}), + }); + expect(res.status).toBe(200); + } + }); + + it("a scoped key lacking the route's permission is refused at the annotation, by name", async () => { + attachLive(); + const pane = await directRow(); + // Versioned and scoped: the guard's NAMED-permission branch fires + // (auth-guard.ts: "This API key needs the ${requiredSystemPermission} + // permission."), not the legacy "isn't available" wording, because every + // preview route carries a permission annotation like its siblings. + const scoped = (await getAuth().api.createApiKey({ + body: { + name: "pv-scoped", + userId: systemUserId, + metadata: { kind: "system", permissionVersion: 1, systemPermissions: ["channels:read"] }, + }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(scoped.id); + const read = await call("GET", `/${pane}/views`, { bearer: scoped.key }); + expect(read.status).toBe(403); + expect((await errorBody(read)).message).toMatch(/needs the subshells:read permission/); + const exec = await call("POST", `/${pane}/views/open`, { bearer: scoped.key, body: { port: 3000 } }); + expect(exec.status).toBe(403); + expect((await errorBody(exec)).message).toMatch(/needs the subshells:execute permission/); + // And with the permission it reaches, proving the branch was the gate. + const granted = (await getAuth().api.createApiKey({ + body: { + name: "pv-scoped-ok", + userId: systemUserId, + metadata: { kind: "system", permissionVersion: 1, systemPermissions: ["subshells:read", "subshells:execute"] }, + }, + })) as unknown as { id: string; key: string }; + createdKeyIds.push(granted.id); + await grantPane(pane, systemUserId, "edit"); + const seen = await call("GET", `/${pane}/views`, { bearer: granted.key }); + expect(seen.status).toBe(200); + }); + + /* ---------------------------------------------------------------- */ + /* the info shape */ + /* ---------------------------------------------------------------- */ + + it("a healthy live pane answers the full shape", async () => { + attachLive(); + const pane = await directRow(); + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBeUndefined(); + expect(body.ports).toEqual([{ proto: "tcp", port: 3000, pid: 4242 }]); + expect(body.cdpPort).toBe(9222); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBeUndefined(); + }); + + it("an unreachable node answers the same shape, nodeEnabled false, never an error", async () => { + attachLive(); + const pane = await directRow(); + sim.detach(); // the link is gone; the row still says running + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-offline"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("a held node names the hold, and below-floor names age", async () => { + const holder = attachLive(); + const pane = await directRow(); + const held = holdConnection(nodeId, holder.conn, { + reason: "protocol-mismatch", + agentVersion: "0.0.1", + protocolVersion: 1, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + expect(held).toBeDefined(); + let body = await jsonBody(await call("GET", `/${pane}/views`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-held"); + // Swap the held record for the too-old gate: same door, different truth. + resetNodeRegistryForTests(); + const s2 = attachScriptedNode(nodeId, liveHandlers()); + sim = s2; + holdConnection(nodeId, s2.conn, { + reason: "below-floor", + agentVersion: "0.1.0", + protocolVersion: 14, + os: "linux", + arch: "x64", + onIdle: () => {}, + }); + body = await jsonBody(await call("GET", `/${pane}/views`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-too-old"); + }); + + it("a switch-off node answers a reason and sends no RPC", async () => { + const node = attachLive(); + const pane = await directRow(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + await nodes.setPreviewEnabled(nodeId, { on: false, feature: "display", changedAt: new Date().toISOString() }); + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("preview-disabled"); + expect(body.ports).toEqual([]); + expect(node.countOf("preview_ports_get")).toBe(0); + }); + + it("an online, switch-on, non-Linux node names preview-ports-unsupported with nodeEnabled TRUE", async () => { + // The mac leg of Task 14 reads exactly this: nodeEnabled describes the + // NODE (online, switch on), and the refusal the node itself names rides + // in `reason`. A blank [] with no reason would read as "nothing is + // listening" and lie about why. + attachLive({ + preview_ports_get: () => new Error("preview-ports-unsupported"), + }); + const pane = await directRow(); + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBe("preview-ports-unsupported"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("an agent that predates the ports command answers the mac-leg shape, nodeEnabled TRUE", async () => { + // A plane-side `unsupported` (the connected agent predates + // preview_ports_get, so there is no `detail` to pass through) means + // exactly what the detail-bearing refusal means: the node is online and + // switched on, it just cannot scan. Same branch, same reason word. The + // seam throws the plane's own verdict because nothing older than the + // command vocabulary exists to script. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("unsupported", `node "${nodeId}" does not support preview_ports_get`, nodeId); + }, + }); + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.nodeEnabled).toBe(true); + expect(body.reason).toBe("preview-ports-unsupported"); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + }); + + it("a node failure the plane cannot name still answers the composed node-refused reason", async () => { + // `failed` with no detail sits outside every named branch, so the + // composed `node-refused (failed)` is what a polling page reads. It is + // now DOCUMENTED in the schema description, which is what this row + // pins: the composition stays honest, and a named-word refactor that + // starts swallowing detail would flip this row red. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("failed", `node "${nodeId}" reported: something the wire did not name`, nodeId); + }, + }); + const body = await jsonBody(await call("GET", `/${pane}/views`, { cookie: ownerCookie })); + expect(body.nodeEnabled).toBe(false); + expect(body.reason).toBe("node-refused (failed)"); + expect(body.ports).toEqual([]); + }); + + it("a display that died mid-pane passes screenLost named, paired with screen null", async () => { + // Spec §9: the marker arrives from the node and passes through named, + // never inferred. A route that swallowed it would render silence where + // the pane should say "the screen went away". + attachLive({ + preview_ports_get: () => ({ + ports: [{ proto: "tcp6", port: 8080, pid: 91 }], + cdpPort: null, + screen: null, + screenLost: "x11vnc", + }), + }); + const pane = await directRow(); + const body = await jsonBody(await call("GET", `/${pane}/views`, { cookie: ownerCookie })); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBe("x11vnc"); + expect(body.ports).toEqual([{ proto: "tcp6", port: 8080, pid: 91 }]); + }); + + it.each([ + [true, true], + [true, false], + [false, true], + [false, false], + ])("enforces apps %s and display %s independently for discovery and new connections", async (apps, display) => { + attachLive({ + preview_ports_get: () => ({ + ports: [{ proto: "tcp", port: 3000, pid: 91 }], + cdpPort: 9222, + screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } }, + }), + }); + const pane = await directRow(); + await nodes.setPreviewEnabled(nodeId, { on: apps, changedAt: new Date().toISOString() }); + await nodes.setPreviewEnabled(nodeId, { on: display, feature: "display", changedAt: new Date().toISOString() }); + const bearer = await bearerFor(pane); + for (const auth of [{ cookie: ownerCookie }, { bearer }]) { + const info = await jsonBody(await call("GET", `/${pane}/views`, auth)); + expect(info.webPreviewsEnabled).toBe(apps); + expect(info.virtualDisplayEnabled).toBe(display); + expect(info.ports).toEqual(apps ? [{ proto: "tcp", port: 3000, pid: 91 }] : []); + expect(info.cdpPort).toBe(apps ? 9222 : null); + expect(info.screen).toEqual(display ? { display: ":90", viewer: { protocol: "vnc", port: 5910 } } : null); + for (const [port, permitted] of [ + [3000, apps], + [5910, display], + ] as const) { + const response = await call("POST", `/${pane}/views/open`, { ...auth, body: { port } }); + expect(response.status).toBe(permitted ? 200 : 409); + } + const watch = await call("POST", `/${pane}/views/browser/watch`, { ...auth, body: { port: 9222 } }); + expect(watch.status).toBe(apps ? 200 : 409); + } + }); + + it("a live display passes the screen pair through", async () => { + attachLive({ + preview_ports_get: () => ({ + ports: [], + cdpPort: null, + screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } }, + }), + }); + const pane = await directRow(); + const body = await jsonBody(await call("GET", `/${pane}/views`, { cookie: ownerCookie })); + expect(body.screen).toEqual({ display: ":90", viewer: { protocol: "vnc", port: 5910 } }); + expect(body.screenLost).toBeUndefined(); + }); + + it("a terminated pane answers the empty shape with no RPC leaving the plane", async () => { + // The route-side short-circuit: a pane that is simply gone is not a + // preview failure. Applying the refusal-mapping rule here would answer + // `nodeEnabled: false` naming a ports refusal for a row that cannot + // answer anything, and no `screenLost`: a closed pane has no display + // that could die. The broker's spy (the wire) must stay untouched. + const node = attachLive(); + const pane = await directRow({ status: "terminated" }); + const res = await call("GET", `/${pane}/views`, { cookie: ownerCookie }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.ports).toEqual([]); + expect(body.cdpPort).toBeNull(); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBeUndefined(); + // nodeEnabled/reason still describe the NODE (online, switch on). + expect(body.nodeEnabled).toBe(true); + expect(node.countOf("preview_ports_get")).toBe(0); + }); + + /* ---------------------------------------------------------------- */ + /* open and watch */ + /* ---------------------------------------------------------------- */ + + it("open hands out a proxyBase for the ref it minted", async () => { + const node = attachLive(); + const pane = await directRow(); + const res = await call("POST", `/${pane}/views/open`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body.proxyBase).toMatch(/^\/pv\/[0-9a-f]{32}\/$/); + const opens = node.cmdsOf("preview_open"); + expect(opens.length).toBeGreaterThan(0); + expect(opens[0].paneId).toBe(pane); + expect(opens[0].port).toBe(3000); + }); + + // The string case names a string that cannot READ as a number: Elysia's + // schema layer coerces a numeric string ("3000") like every other numeric + // surface on this app, and an honest bound check says so rather than + // pretending a port of "3000" arrives. + it.each([ + ["zero", 0], + ["above the port range", 70000], + ["a fraction", 1.5], + ["a string that is not a number", "not-a-port"], + ["missing entirely", undefined], + ])("open refuses port %s at the schema", async (_label, port) => { + const node = attachLive(); + const pane = await directRow(); + const res = await call("POST", `/${pane}/views/open`, { + cookie: ownerCookie, + body: port === undefined ? {} : { port }, + }); + expect(res.status).toBe(400); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + expect(node.countOf("preview_open")).toBe(0); + }); + + it("watch with no body defaults to the cached cdpPort, and stop is idempotent", async () => { + const node = attachLive(); + const pane = await directRow(); + const start = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: {} }); + expect(start.status).toBe(200); + expect(await jsonBody(start)).toEqual({ ok: true }); + const opens = node.cmdsOf("preview_open"); + expect(opens.length).toBe(1); + expect(opens[0].kind).toBe("screencast"); + expect(opens[0].port).toBe(9222); // the ports answer's cdpPort + const stop = await call("DELETE", `/${pane}/views/browser/watch`, { cookie: ownerCookie }); + expect(stop.status).toBe(200); + expect(await jsonBody(stop)).toEqual({ ok: true }); + // The caller unsubscribed; a second stop is a no-op, not a 404 hunt. + const stop2 = await call("DELETE", `/${pane}/views/browser/watch`, { cookie: ownerCookie }); + expect(stop2.status).toBe(200); + expect(node.countOf("preview_close")).toBeGreaterThanOrEqual(1); + }); + + it("watch with no port and no cached cdpPort is a 400 that names what is missing", async () => { + attachLive({ + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const pane = await directRow(); + const res = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: {} }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(/cdpPort/i); + expect(body.message).toMatch(/port/i); + }); + + it("watch naming a port the live bridge does not hold is 409, surfaced by name", async () => { + attachLive(); + const pane = await directRow(); + const first = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(first.status).toBe(200); + // The bridge exists on 3000; re-pointing the same pane's one bridge to + // 4000 is the broker's `watch-port-conflict` refusal (Task 8 owns the + // rule; the route owes it a named 409, not a bare 500). + const clash = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: { port: 4000 } }); + expect(clash.status).toBe(409); + expect((await errorBody(clash)).message).toMatch(/watch-port-conflict/); + const stop = await call("DELETE", `/${pane}/views/browser/watch`, { cookie: ownerCookie }); + expect(stop.status).toBe(200); + }); + + it("watch on an offline node is a 409 naming it, never a 500", async () => { + attachLive(); + const pane = await directRow(); + sim.detach(); + const res = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/offline|not connected|gone/i); + }); + + it("watch whose preview_open times out is a 409 naming a retry, never a 500", async () => { + // The node-state pre-gate cannot see this: a stale SPA POSTs a watch at + // a node that went slow after its last info poll, and the plain Error a + // timeout carries would render a 500. Mirror of open's timeout arm + // (same sentence shape, the named retry). The seam throws the plane's + // own verdict because waiting out the real RPC budget to prove a + // mapping is a test spending seconds it has not earned. + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("timeout", `node "${nodeId}" did not answer preview_open in time`, nodeId); + }, + }); + const res = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/did not answer the browser watch in time/); + expect(body.message).toMatch(/try again/i); + }); + + it("watch against an agent that predates the preview command is a 409 naming the update, never a 500", async () => { + // The other half of the stale-SPA race: an older node answers the plane + // with `unsupported`, which carries no status either. Same 409 shape as + // open's, naming the remedy (update the node). + attachLive(); + const pane = await directRow(); + resetForTests({ + send: async () => { + throw new NodeRpcError("unsupported", `node "${nodeId}" does not support preview_open`, nodeId); + }, + }); + const res = await call("POST", `/${pane}/views/browser/watch`, { cookie: ownerCookie, body: { port: 3000 } }); + expect(res.status).toBe(409); + const body = await errorBody(res); + expect(body.message).toMatch(/does not support web previews and screens yet/); + expect(body.message).toMatch(/update the node/i); + }); + + /* ---------------------------------------------------------------- */ + /* the create path's screen field */ + /* ---------------------------------------------------------------- */ + + /** Install the harness so the create path reaches its node resolution. */ + async function makeLaunchable(overrides: ScriptedHandlers = {}): Promise { + attachLive(overrides); + await nodes.applyInventory( + nodeId, + JSON.stringify([{ harnessId: "claude-code", installed: true, binaryPath: "/usr/bin/claude" }]), + ); + } + + async function createBody(extra: Record = {}) { + return { + harnessId: "claude-code", + workingDir: "/tmp", + nodeId, + ...extra, + }; + } + + it("a screen launch on a preview-off node is refused BEFORE the launch RPC", async () => { + // The preflight runs in the service, before the row, the token, and the + // wire: the fake's `launch` spy must stay empty. The remedy names the + // switch, mirroring spec §6's wording; the node gate (Task 7) stays the + // authority for everything the plane cannot see. + await makeLaunchable(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + await nodes.setPreviewEnabled(nodeId, { on: false, feature: "display", changedAt: new Date().toISOString() }); + const before = await paneCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv screened", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(/Enable Allow virtual displays/i); + expect(sim.countOf("launch")).toBe(0); + expect(await paneCount(ownerId)).toBe(before); + }); + + it("a screen launch on a preview-on node reaches the wire carrying screen", async () => { + await makeLaunchable(); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv screen ok", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const launches = sim.cmdsOf("launch"); + expect(launches.length).toBe(1); + expect(launches[0].screen).toEqual({ width: 1280, height: 800, provider: "xvfb-x11vnc" }); + const created = await jsonBody(res); + createdSubshellIds.push(String(created.id)); + }); + + it("preserves display dimensions on restart and refuses a disabled display before killing the pane", async () => { + await makeLaunchable({ kill: ok }); + const response = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "display restart", screen: { width: 1440, height: 900 } }), + }); + expect(response.status).toBe(200); + const id = String((await jsonBody(response)).id); + createdSubshellIds.push(id); + expect((await subshells.findById(id))?.screenConfig).toBe( + JSON.stringify({ width: 1440, height: 900, provider: "xvfb-x11vnc" }), + ); + await nodes.setPreviewEnabled(nodeId, { on: false, feature: "display", changedAt: new Date().toISOString() }); + const refused = await call("POST", `/${id}/restart`, { cookie: ownerCookie }); + expect(refused.status).toBe(409); + expect(sim.countOf("kill")).toBe(0); + await nodes.setPreviewEnabled(nodeId, { + on: true, + feature: "display", + provider: "xvfb-x11vnc", + providers: ["xvfb-x11vnc"], + changedAt: new Date().toISOString(), + }); + await db.updateTable("nodes").set({ virtualDisplayProviders: "[]" }).where("id", "=", nodeId).execute(); + const providerOff = await call("POST", `/${id}/restart`, { cookie: ownerCookie }); + expect(providerOff.status).toBe(409); + expect((await errorBody(providerOff)).message).toContain("provider is disabled"); + expect(sim.countOf("kill")).toBe(0); + await db + .updateTable("nodes") + .set({ virtualDisplayProvider: "future-provider", virtualDisplayProviders: JSON.stringify(["xvfb-x11vnc"]) }) + .where("id", "=", nodeId) + .execute(); + const restarted = await call("POST", `/${id}/restart`, { cookie: ownerCookie }); + expect(restarted.status).toBe(200); + expect(sim.cmdsOf("launch").at(-1)?.screen).toEqual({ width: 1440, height: 900, provider: "xvfb-x11vnc" }); + expect(sim.countOf("launch")).toBe(2); + }); + + it("apps can stay enabled while virtual-display launches are refused", async () => { + await makeLaunchable(); + await nodes.setPreviewEnabled(nodeId, { on: false, feature: "display", changedAt: new Date().toISOString() }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "display disabled", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(400); + expect(sim.countOf("launch")).toBe(0); + }); + + it("virtual-display launches work with web previews disabled", async () => { + await makeLaunchable(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "display only", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + expect(sim.cmdsOf("launch")[0].screen).toEqual({ width: 1280, height: 800, provider: "xvfb-x11vnc" }); + createdSubshellIds.push(String((await jsonBody(res)).id)); + }); + + it("rejects disabled and unknown provider choices before writing or launching", async () => { + await makeLaunchable(); + const count = await paneCount(ownerId); + for (const provider of ["future-provider", "xvfb-x11vnc"]) { + await db.updateTable("nodes").set({ virtualDisplayProviders: "[]" }).where("id", "=", nodeId).execute(); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ screen: { width: 1280, height: 800, provider } }), + }); + expect(res.status).toBe(400); + expect((await errorBody(res)).message).toContain("provider"); + expect(sim.countOf("launch")).toBe(0); + expect(await paneCount(ownerId)).toBe(count); + } + }); + + it("inherits a preset display, honors explicit opt-out, and pins the actual choice", async () => { + await makeLaunchable(); + const presets = new PresetsRepository(db); + const presetId = crypto.randomUUID(); + const screen = { width: 1440, height: 900, provider: "xvfb-x11vnc" }; + await presets.create({ + id: presetId, + userId: ownerId, + harnessId: "claude-code", + name: "Display preset", + description: null, + envJson: null, + flagsJson: null, + settingsJson: null, + configIsolation: 0, + screenConfig: JSON.stringify(screen), + }); + try { + for (const optOut of [false, true]) { + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ presetId, ...(optOut ? { screen: null } : {}) }), + }); + expect(res.status).toBe(200); + const id = String((await jsonBody(res)).id); + createdSubshellIds.push(id); + expect(sim.cmdsOf("launch").at(-1)?.screen).toEqual(optOut ? undefined : screen); + expect((await subshells.findById(id))?.screenConfig).toBe(optOut ? null : JSON.stringify(screen)); + expect((await jsonBody(await call("GET", `/${id}`, { cookie: ownerCookie }))).screen).toEqual( + optOut ? null : screen, + ); + } + } finally { + await presets.delete(presetId); + } + }); + + it("a plain launch carries no screen member at all", async () => { + await makeLaunchable(); + const res = await call("POST", "", { cookie: ownerCookie, body: await createBody({ name: "pv plain" }) }); + expect(res.status).toBe(200); + const launches = sim.cmdsOf("launch"); + expect(launches.length).toBe(1); + expect(launches[0].screen).toBeUndefined(); + const created = await jsonBody(res); + createdSubshellIds.push(String(created.id)); + }); + + it.each([ + ["width below the floor", { width: 319, height: 800 }], + ["height above the ceiling", { width: 1280, height: 5000 }], + ["a fractional width", { width: 1000.5, height: 800 }], + ["a non-object", "1280x800"], + ])("create refuses screen %s at the schema", async (_label, screen) => { + await makeLaunchable(); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv bounds", screen }), + }); + expect(res.status).toBe(400); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INPUT_VALIDATION_ERROR); + expect(sim.countOf("launch")).toBe(0); + }); + + // The node-side half of the screen gate: refusals that arrive FROM the + // wire, which the service preflight cannot see (an older node, or a + // switch flipped mid-race). Each row's `echo` says whether the remedy + // matcher appends the node's own detail: it does only for a SUFFIXED form + // (the missing-binaries pair). The bare word and the node-off word match + // their keys exactly, and the node-off key already IS its remedy string, + // so neither is appended to. + const SCREEN_REFUSALS: ReadonlyArray = [ + ["preview-display-unsupported", /provider is not supported/, false], + ["preview-display-missing-requirements: x11vnc", /provider has unmet requirements/, true], + ["preview-node-off: turn on Preview for this node", /Turn on Preview for this node/i, false], + ]; + + it.each(SCREEN_REFUSALS)( + "a node-side screen refusal %s is a 400 with the remedy, and the create rolls back", + async (detail, remedy, echo) => { + // The DB switch stays ON (beforeEach set it), so the preflight passes + // and the refusal only ever comes from the scripted launch itself. + // One `launch` flying is the contrast with the preflight row's zero. + await makeLaunchable({ launch: () => new Error(detail) }); + const name = `pv refused ${detail.slice(0, 22)}`; + const keysBefore = liveKeyCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name, screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(400); + const body = await errorBody(res); + expect(body.message).toMatch(remedy); + if (echo) { + // The node's word rides along in the message so an operator can + // grep exactly what the agent said. + expect(body.message).toContain(detail); + } + expect(sim.countOf("launch")).toBe(1); + // The manager's rollback: best-effort kill, the row ends TERMINATED + // (it was written before the wire, so the row stays, retired), and the + // pane's token stops being an enabled key. + expect(await paneStatusByName(ownerId, name)).toBe("terminated"); + expect(liveKeyCount(ownerId)).toBe(keysBefore); + }, + ); + + it("a malformed launch ack rolls the create back and answers the plain error, never a false 200", async () => { + // `{ screen: "junk" }` fails the launch grammar: the plane cannot say + // what screen the pane has, so it must not claim the pane launched. The + // posture is the plain Error (500 through the handler) plus the full + // rollback; pinned so a rewording of the ack path can neither turn the + // breach into a success nor leak a live row with a live token. + await makeLaunchable({ launch: () => ({ screen: "junk" }) }); + const name = "pv junk ack"; + const keysBefore = liveKeyCount(ownerId); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name, screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(500); + expect((await errorBody(res)).code).toBe(BackendErrorCodes.INTERNAL_SERVER_ERROR); + expect(await paneStatusByName(ownerId, name)).toBe("terminated"); + expect(liveKeyCount(ownerId)).toBe(keysBefore); + }); + + it("the launch ack's screen echo arms the info answer before the ports scan reports it", async () => { + // The VNC bridge is up when the ack flies; the next preview_ports_get + // may not know the screen yet, so the broker's launch memory answers + // the info poll inside that window. The memory holds the ack verbatim, + // which is also the spy that onDisplayOpened fired. + await makeLaunchable({ + launch: () => ({ screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } } }), + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv ack echo", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const created = String((await jsonBody(res)).id); + createdSubshellIds.push(created); + expect(getPreviewBroker().displayForPane(created)).toEqual({ + nodeId, + screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } }, + }); + const body = await jsonBody(await call("GET", `/${created}/views`, { cookie: ownerCookie })); + expect(body.screen).toEqual({ display: ":90", viewer: { protocol: "vnc", port: 5910 } }); + expect(body.screenLost).toBeUndefined(); + }); + + it("the launch memory never masks a screenLost the node reported", async () => { + // A death the node reported outranks the plane's memory of the birth: + // once the scan says the display process died, info keeps answering + // screen null with the named marker, not the remembered screen. Without + // this leg the overlay could show a dead screen forever. + await makeLaunchable({ + launch: () => ({ screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } } }), + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null }), + }); + const res = await call("POST", "", { + cookie: ownerCookie, + body: await createBody({ name: "pv ack then lost", screen: { width: 1280, height: 800 } }), + }); + expect(res.status).toBe(200); + const created = String((await jsonBody(res)).id); + createdSubshellIds.push(created); + // Same node identity, new answers: the scan now reports the x11vnc died. + attachLive({ + preview_ports_get: () => ({ ports: [], cdpPort: null, screen: null, screenLost: "x11vnc" }), + }); + const body = await jsonBody(await call("GET", `/${created}/views`, { cookie: ownerCookie })); + expect(body.screen).toBeNull(); + expect(body.screenLost).toBe("x11vnc"); + }); + + /* ---------------------------------------------------------------- */ + /* the pane's own exit tears the preview down */ + /* ---------------------------------------------------------------- */ + + it("a self-reported exit closes the pane's preview state as pane-gone", async () => { + attachLive(); + const pane = await directRow(); + const token = await bearerFor(pane); + // Start a watch so there IS bridge state to lose, then report the exit + // exactly the pane's own reporter call would. + const start = await call("POST", `/${pane}/views/browser/watch`, { bearer: token, body: { port: 3000 } }); + expect(start.status).toBe(200); + expect(getPreviewBroker().sessionForPane(pane)).not.toBeNull(); + const res = await call("POST", `/${pane}/exit`, { bearer: token, body: { exitCode: 0 } }); + expect(res.status).toBe(200); + // The broker is the one that knows "this pane is gone" now: every lane + // and bridge of it is closed (the audit rides onDisplayClosed inside the + // broker; the route never calls it on its own). + expect(getPreviewBroker().sessionForPane(pane)).toBeNull(); + }); +}); diff --git a/apps/server/api/src/api/subshells/browser-watch.route.ts b/apps/server/api/src/api/subshells/browser-watch.route.ts new file mode 100644 index 000000000..9307bd07b --- /dev/null +++ b/apps/server/api/src/api/subshells/browser-watch.route.ts @@ -0,0 +1,195 @@ +import { Elysia, t } from "elysia"; +import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState, recheckPreviewOpen } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { PREVIEW_PORTS_UNSUPPORTED } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; + +/** + * The screencast watch doors, one file per resource rule: `POST + * /api/subshells/:id/views/browser/watch` starts the pane's ONE screencast bridge, + * `DELETE /api/subshells/:id/views/browser/watch` says "this viewer is done". + * Watching is read-side (the node only ever sends devtools screencast + * frames), so both doors sit at `view`, the same split `open` draws at + * `edit`. + * + * No ref is handed out and none is needed: the bridge is keyed by pane and + * every viewer of it shares one, so stop is pane-keyed too. A DELETE with no + * bridge is a no-op, 200 (the caller unsubscribes, idempotently); the route + * never asks whether one exists, because `stopWatch` already answers that + * question truthfully and a route-side hunt would be a second answer. + * + * POST without a `port` falls back to the pane's cached `cdpPort` through the + * broker (its 5 s cache is what makes the poll cheap); a pane with neither + * gets a 400 naming what is missing. A port the LIVE bridge does not hold is + * the broker's `watch-port-conflict` refusal, surfaced as a named 409. + */ + +const PreviewWatchBodySchema = t.Object({ + port: t.Optional( + t.Integer({ + minimum: 1, + maximum: 65_535, + description: "Devtools port to screencast; omitted falls back to the pane's cached cdpPort", + }), + ), +}); + +const PreviewWatchResponseSchema = t.Object({ + ok: t.Literal(true, { description: "The bridge is running (or, for DELETE, the viewer's interest is withdrawn)" }), +}); + +export const browserWatchRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/:id/views/browser/watch", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + const { row, access } = await gatePreview(user.id, params.id, "view", actor); + const nodeState = await previewNodeState(row.nodeId); + if (!nodeState.nodeEnabled) { + throw new HttpError( + 409, + `Web previews & screens is not available on that node right now (${nodeState.reason}).`, + ); + } + const broker = getPreviewBroker(); + let port = body.port; + if (access === "view") { + let discovered: number | null; + try { + discovered = (await broker.getPorts(row.nodeId, params.id)).cdpPort; + } catch { + throw new HttpError( + 409, + "The subshell's browser could not be checked. Try again when its node is available.", + ); + } + if (discovered === null || (port !== undefined && port !== discovered)) { + throw new HttpError( + 403, + "View access can only watch the browser discovered in this subshell. Ask its owner to check the browser setup.", + ); + } + port = discovered; + } + if (port === undefined) { + // The cached ports answer is the fallback; a failure here is mapped, + // because the caller asked the pane, not the cache, what to watch. + try { + port = (await broker.getPorts(row.nodeId, params.id)).cdpPort ?? undefined; + } catch (err) { + if (err instanceof NodeRpcError && err.code === "failed" && err.detail === PREVIEW_PORTS_UNSUPPORTED) { + throw new HttpError(400, `That node cannot scan its panes' ports (${err.detail}). Pass a port directly.`); + } + if (err instanceof NodeRpcError && err.code === "offline") { + throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + } + throw new HttpError(409, "The pane's open ports could not be read. Pass a port directly."); + } + if (port === undefined) { + throw new HttpError( + 400, + "The request named no port and the pane has no cached cdpPort to fall back to. Pass a port, or let a devtools port be seen first.", + ); + } + } + try { + await broker.startWatch({ + viewerUserId: user.id, + paneId: params.id, + nodeId: row.nodeId, + port, + authorize: async () => { + const currentAccess = await recheckPreviewOpen(user.id, params.id, row.nodeId, "view", actor); + if (currentAccess === "view" && (await broker.getPorts(row.nodeId, params.id)).cdpPort !== port) { + throw new HttpError( + 403, + "View access can only watch the browser discovered in this subshell. Ask its owner to check the browser setup.", + ); + } + }, + }); + return { ok: true } as const; + } catch (err) { + if (err instanceof PreviewRefusal) { + // watch-port-conflict (the bridge is busy on another port) and the + // watch cap, both named (Task 8 owns the rule; the route owes the + // SPA a 409 that says which). + throw new HttpError(409, `${err.code}: ${err.message}`); + } + if (err instanceof NodeRpcError) { + if (err.code === "failed" && err.detail !== undefined) { + // The node's refusal of the screencast open (`not-cdp`, pane + // gone, switch off mid-call): pass the word through named. + throw new HttpError(409, `That node refused the watch: ${err.detail}`); + } + if (err.code === "offline") { + throw new HttpError(409, "That node is not connected right now. Reconnect it, then watch again."); + } + if (err.code === "timeout") { + // The pre-gate cannot see a node that went slow after the page's + // last info poll, and a timeout carries no status: left alone it + // would render a 500. Mirrors open's timeout arm. + throw new HttpError(409, "The node did not answer the browser watch in time. Try again in a moment."); + } + // `unsupported`: the connected agent predates the preview command + // (a stale SPA POSTs a watch to an older node). Same remedy as + // open's: update the node. + throw new HttpError( + 409, + "That node does not support web previews and screens yet. Update the node, then retry.", + ); + } + throw err; + } + }, + { + body: PreviewWatchBodySchema, + response: { + 200: PreviewWatchResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "startSubshellBrowserWatch", + tags: ["subshells"], + description: + "Start (or join) the pane's single screencast bridge. A missing port falls back to the cached cdpPort; with neither, the refusal says what is missing", + }, + }, + ) + .delete( + "/:id/views/browser/watch", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + await gatePreview(user.id, params.id, "view", actor); + // Pane-keyed and idempotent: the broker decides whether this was the + // last viewer; a second DELETE changes nothing and still answers ok. + await getPreviewBroker().stopWatch(user.id, params.id); + return { ok: true } as const; + }, + { + response: { + 200: PreviewWatchResponseSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "stopSubshellBrowserWatch", + tags: ["subshells"], + description: + "Withdraw this viewer's interest in the pane's screencast. Idempotent: no bridge open is a success, not a hunt", + }, + }, + ); diff --git a/apps/server/api/src/api/subshells/create-subshell.route.ts b/apps/server/api/src/api/subshells/create-subshell.route.ts index a2024cc41..555cc6cff 100644 --- a/apps/server/api/src/api/subshells/create-subshell.route.ts +++ b/apps/server/api/src/api/subshells/create-subshell.route.ts @@ -3,6 +3,7 @@ import { Elysia, t } from "elysia"; import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/plugins/context.plugin.js"; import { apiModels } from "@/schema/index.js"; +import { VirtualDisplayRequestSchema } from "@/schema/virtual-display.js"; const CreateSubshellBodySchema = t.Object({ harnessId: t.String({ minLength: 1, description: "Harness plugin id to launch" }), @@ -34,6 +35,7 @@ const CreateSubshellBodySchema = t.Object({ "Workspace to file the new subshell in: a tab row is written for it beside the launch. 404 when it is not the caller's (or absent). Omitted = the pane starts ungrouped", }), ), + screen: t.Optional(t.Nullable(VirtualDisplayRequestSchema)), }); /** Shared with restart (same response shape). */ @@ -88,6 +90,7 @@ export const createSubshellRoute = new Elysia() prompt: body.prompt, nodeId: body.nodeId, workspaceId: body.workspaceId, + screen: body.screen, machineActor: actor !== "cookie", // A launch that arrives on a PANE'S OWN token is an agent opening a // sibling to talk to it (`create_subshell`); a system key is admin diff --git a/apps/server/api/src/api/subshells/index.ts b/apps/server/api/src/api/subshells/index.ts index 343b00ded..044f3e839 100644 --- a/apps/server/api/src/api/subshells/index.ts +++ b/apps/server/api/src/api/subshells/index.ts @@ -1,4 +1,5 @@ import { Elysia } from "elysia"; +import { browserWatchRoute } from "@/api/subshells/browser-watch.route.js"; import { createSubshellRoute } from "@/api/subshells/create-subshell.route.js"; import { deleteSubshellRoute } from "@/api/subshells/delete-subshell.route.js"; import { execSubshellRoute } from "@/api/subshells/exec-subshell.route.js"; @@ -16,6 +17,9 @@ import { summarySubshellRoute } from "@/api/subshells/summary-subshell.route.js" import { terminateSubshellRoute } from "@/api/subshells/terminate-subshell.route.js"; import { updateSubshellNameRoute } from "@/api/subshells/update-subshell-name.route.js"; import { updateSubshellNotifyRoute } from "@/api/subshells/update-subshell-notify.route.js"; +import { viewsInfoRoute } from "@/api/subshells/views-info.route.js"; +import { viewsOpenRoute } from "@/api/subshells/views-open.route.js"; +import { servicesRoute } from "./services.route.js"; import { subshellSubagentsRoute } from "./subshell-subagents.route.js"; /** @@ -31,6 +35,10 @@ export const subshellRoutes = new Elysia({ prefix: "/api/subshells" }).use([ summarySubshellRoute, getSubshellRoute, getSubshellLogRoute, + viewsInfoRoute, + viewsOpenRoute, + servicesRoute, + browserWatchRoute, subshellSharesRoutes, updateSubshellNotifyRoute, subshellAttentionRoute, diff --git a/apps/server/api/src/api/subshells/preview-gate.ts b/apps/server/api/src/api/subshells/preview-gate.ts new file mode 100644 index 000000000..2cc8f6ea7 --- /dev/null +++ b/apps/server/api/src/api/subshells/preview-gate.ts @@ -0,0 +1,100 @@ +import { type GuardActor, HttpError } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; +import { accountDisabled } from "@/services/account-status.js"; +import { getHeld, isNodeOffline } from "@/services/nodes/node-registry.js"; +import { + getAnyPreviewEnabled, + getVirtualDisplayEnabled, + getWebPreviewsEnabled, +} from "@/services/nodes/preview-enabled.js"; + +/** + * The shared door for the three preview routes. It mirrors the subshell + * service's private gate exactly (same 404 for absent-or-invisible, same 403 + * wording for insufficient) because a bearer pane key resolves as its owner + * with shares off, and the preview routes must not widen or narrow that. + */ + +const repos = { + subshells: new SubshellsRepository(db), + shares: new SubshellSharesRepository(db), +}; + +/** The row plus the resolved access, or the HTTP refusal a stranger deserves. */ +export async function gatePreview( + viewerId: string, + subshellId: string, + min: Exclude, + actor: GuardActor, +): Promise<{ row: SubshellTable; access: Access }> { + const { row, access } = await loadSubshellAccess(repos, viewerId, subshellId, { + allowShares: actor !== "subshell-key", + }); + if (!row || access === "none") throw new HttpError(404, "Subshell not found"); + if (!accessAtLeast(access, min)) { + throw new HttpError(403, "You don't have permission to do that with this subshell"); + } + return { row, access }; +} + +/** + * What the NODE can do about preview, independent of any one pane: the + * switch, the held gates, and the link. `reason` is a stable word the SPA + * maps to a tooltip; the closed set is `preview-disabled`, `node-held`, + * `node-too-old`, `node-offline`, plus the per-failure words the info route + * adds when the live call itself refuses. + */ +export interface PreviewNodeState { + nodeEnabled: boolean; + reason?: string; +} + +/** The node-side truth for one machine: switch off, held, gone, or healthy. */ +export async function previewNodeState( + nodeId: string, + feature: "web-previews" | "display" | "any" = "web-previews", +): Promise { + if ( + !(await (feature === "display" + ? getVirtualDisplayEnabled + : feature === "any" + ? getAnyPreviewEnabled + : getWebPreviewsEnabled)(nodeId)) + ) { + return { nodeEnabled: false, reason: "preview-disabled" }; + } + const held = getHeld(nodeId); + if (held) { + // `below-floor` is the version gate; every other hold (protocol + // mismatch, encryption-required) is a handshake fact, not an age fact. + return { nodeEnabled: false, reason: held.reason === "below-floor" ? "node-too-old" : "node-held" }; + } + if (isNodeOffline(nodeId)) { + return { nodeEnabled: false, reason: "node-offline" }; + } + return { nodeEnabled: true }; +} + +/** Recheck after node RPCs so stale route authorization cannot mint fresh access. */ +export async function recheckPreviewOpen( + viewerId: string, + paneId: string, + nodeId: string, + min: "view" | "edit", + actor: GuardActor, + feature: "web-previews" | "display" = "web-previews", +): Promise { + if (await accountDisabled(db, viewerId)) throw new HttpError(401, "Your account is no longer available."); + const { row, access } = await gatePreview(viewerId, paneId, min, actor); + if (row.nodeId !== nodeId || row.status !== "running" || row.alive !== 1) { + throw new HttpError(409, "This subshell is no longer running. Open Web previews & screens after restarting it."); + } + if (!(await previewNodeState(nodeId, feature)).nodeEnabled) { + throw new HttpError(409, "Web previews & screens is no longer available on this node."); + } + return access; +} diff --git a/apps/server/api/src/api/subshells/services.route.ts b/apps/server/api/src/api/subshells/services.route.ts new file mode 100644 index 000000000..7cf82c67d --- /dev/null +++ b/apps/server/api/src/api/subshells/services.route.ts @@ -0,0 +1,44 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview } from "@/api/subshells/preview-gate.js"; +import { listServices, removeService, saveService } from "@/services/subshell-services.js"; + +/** Saved destinations use the same ownership/share boundary as interactive views. */ +export const servicesRoute = new Elysia() + .use(authGuard) + .get( + "/:id/services", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + await gatePreview(user.id, params.id, "view", actor); + return listServices(params.id); + }, + { systemPermission: "subshells:read", detail: { operationId: "listSubshellServices", tags: ["subshells"] } }, + ) + .post( + "/:id/services", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + await gatePreview(user.id, params.id, "edit", actor); + return saveService(params.id, body, user.id); + }, + { + systemPermission: "subshells:execute", + body: t.Object({ + name: t.Optional(t.String({ maxLength: 80 })), + host: t.Optional(t.String({ minLength: 1, maxLength: 253 })), + port: t.Integer({ minimum: 1, maximum: 65535 }), + protocol: t.Union([t.Literal("http"), t.Literal("https")]), + }), + detail: { operationId: "saveSubshellService", tags: ["subshells"] }, + }, + ) + .delete( + "/:id/services/:serviceId", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + await gatePreview(user.id, params.id, "edit", actor); + return removeService(params.id, params.serviceId, user.id); + }, + { systemPermission: "subshells:execute", detail: { operationId: "removeSubshellService", tags: ["subshells"] } }, + ); diff --git a/apps/server/api/src/api/subshells/subshell-exit.route.ts b/apps/server/api/src/api/subshells/subshell-exit.route.ts index 6870d2893..4464db1af 100644 --- a/apps/server/api/src/api/subshells/subshell-exit.route.ts +++ b/apps/server/api/src/api/subshells/subshell-exit.route.ts @@ -2,6 +2,8 @@ import { Elysia, t } from "elysia"; import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/plugins/context.plugin.js"; import { apiModels } from "@/schema/index.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; +import { logger } from "@/utils/logger.js"; const ExitBodySchema = t.Object({ exitCode: t.Optional( @@ -49,6 +51,22 @@ export const subshellExitRoute = new Elysia() throw new HttpError(403, "Only a subshell's own key may report its exit"); } await ctx.services.subshells.reportExit(params.id, body.exitCode ?? null); + // The pane is gone, so everything hanging off it goes too: every + // forward lane, the screencast bridge, and the display-audit fact + // (the broker files the display close through its own bookkeeping, + // so the route never calls `onDisplayClosed` beside it). Best-effort + // by design: the death row is already written, and a preview that + // refuses to tear down must not turn the pane's own report into a 500. + // This is the FAST path, not the only close: the shared death + // transition (`#applyDeath`, where the sweep and the exit census + // land) belts the same `closeForPane` for a dead pane, and the node's + // link-drop pass still catches a whole node. The broker's close is + // idempotent, so a fast path racing a belt audits one close, not two. + try { + await getPreviewBroker().closeForPane(params.id, "pane-gone"); + } catch (err) { + logger.warn(`preview teardown after exit report for ${params.id} failed: ${String(err)}`); + } return { ok: true } as const; }, { diff --git a/apps/server/api/src/api/subshells/views-info.route.ts b/apps/server/api/src/api/subshells/views-info.route.ts new file mode 100644 index 000000000..ce5ab7da3 --- /dev/null +++ b/apps/server/api/src/api/subshells/views-info.route.ts @@ -0,0 +1,167 @@ +import { Elysia, t } from "elysia"; +import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { getVirtualDisplayEnabled, getWebPreviewsEnabled } from "@/services/nodes/preview-enabled.js"; +import { PREVIEW_PORTS_UNSUPPORTED } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; + +/** + * `GET /api/subshells/:id/views` (spec §6): what the pane currently offers + * to watch. The answer is ALWAYS this shape, never an error, because the + * open pane view polls it: an unreachable node, a switched-off machine, a + * platform without the ports scan, and a pane that is simply gone each read + * as the same fields with `nodeEnabled` and `reason` carrying the truth. + * + * Two shape rules carry weight. A switch-on node that refuses the ports scan + * for platform reasons answers `nodeEnabled: true` with the refusal NAMED in + * `reason` (a node that cannot speak is still online; a blank `[]` with no + * reason reads as "nothing is listening" and lies about why). And a closed + * pane short-circuits route-side to the empty shape without any RPC: the + * refusal-mapping rule is for live panes whose node-side answer failed, and + * a pane that is gone has no display that could die, so no `screenLost`. + */ + +const PreviewPortSchema = t.Object({ + proto: t.Union([t.Literal("tcp"), t.Literal("tcp6")], { + description: "Address family the listener is on", + }), + port: t.Integer({ description: "Listening port on the pane's machine (loopback)" }), + pid: t.Integer({ description: "Process holding the listener, inside the pane's tree" }), +}); + +const PreviewScreenSchema = t.Object({ + display: t.String({ description: "Provider display identifier (for example :90)" }), + viewer: t.Object( + { + protocol: t.Literal("vnc", { description: "Viewer protocol served by the provider" }), + port: t.Integer({ minimum: 1, maximum: 65535, description: "Provider viewer port on the node's loopback" }), + }, + { description: "Viewer connection for the allocated display" }, + ), +}); + +const PreviewInfoResponseSchema = t.Object({ + ports: t.Array(PreviewPortSchema, { + description: "Listening sockets in the pane's process tree; empty when there are none or the node cannot answer", + }), + cdpPort: t.Nullable(t.Integer({ description: "The pane's open devtools port, or null when nothing speaks CDP" })), + screen: t.Nullable(PreviewScreenSchema, { + description: "The pane's virtual display when it has one, or null (never a guess)", + }), + screenLost: t.Optional( + t.String({ + maxLength: 128, + description: "Set only beside screen null: which display process died since the pane launched", + }), + ), + webPreviewsEnabled: t.Optional(t.Boolean()), + virtualDisplayEnabled: t.Optional(t.Boolean()), + nodeEnabled: t.Boolean({ + description: "Whether the pane's NODE can serve preview right now (switch, gates, link), not the pane", + }), + reason: t.Optional( + t.String({ + description: + "Why nodeEnabled is false: a plane word (preview-disabled, node-held, node-too-old, node-offline, node-timeout), the composed node-refused (code) or preview-refused for what the plane cannot name, or the node's own refusal word passed through verbatim; also carries the ports refusal word when nodeEnabled is true and the node itself refused the scan", + }), + ), +}); + +/** The empty shape for every "the node cannot answer" branch. */ +const EMPTY_PORTS = { ports: [] as never[], cdpPort: null, screen: null } as const; + +/** Map a failed `preview_ports_get` onto spec §6's nodeEnabled/reason pair. */ +function mapPortsFailure(err: unknown): { nodeEnabled: boolean; reason: string } { + if (err instanceof NodeRpcError) { + if (err.code === "offline") return { nodeEnabled: false, reason: "node-offline" }; + if (err.code === "timeout") return { nodeEnabled: false, reason: "node-timeout" }; + // The agent's own refusal, passed through NAMED (the wire-constant + // habit). `preview-ports-unsupported` keeps `nodeEnabled: true`: the + // node is online and switched on, it just has no /proc to read. + if (err.code === "unsupported") { + // The PLANE's own verdict that the connected agent predates the + // command: it carries no `detail`, but it says exactly what the + // detail-bearing refusal says. Same branch, same reason word. + return { nodeEnabled: true, reason: PREVIEW_PORTS_UNSUPPORTED }; + } + if (err.detail !== undefined && err.code === "failed") { + if (err.detail === PREVIEW_PORTS_UNSUPPORTED) return { nodeEnabled: true, reason: PREVIEW_PORTS_UNSUPPORTED }; + return { nodeEnabled: false, reason: err.detail }; + } + return { nodeEnabled: false, reason: `node-refused (${err.code})` }; + } + // A malformed answer or a bug below: still not an error for a polling page. + return { nodeEnabled: false, reason: "preview-refused" }; +} + +export const viewsInfoRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .get( + "/:id/views", + async ({ params, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "read"); + const { row } = await gatePreview(user.id, params.id, "view", actor); + const nodeId = row.nodeId; + const nodeState = { + ...(await previewNodeState(nodeId, "any")), + webPreviewsEnabled: await getWebPreviewsEnabled(nodeId), + virtualDisplayEnabled: await getVirtualDisplayEnabled(nodeId), + }; + // A pane that is not running is answered route-side, with no RPC: + // short-circuit order is closed-first so a dead row on a live node + // still says "no ports, no screen", and nodeState keeps describing + // the NODE rather than the pane. + if (row.status !== "running") { + return { ...EMPTY_PORTS, ...nodeState }; + } + if (!nodeState.nodeEnabled) { + return { ...EMPTY_PORTS, ...nodeState }; + } + try { + const broker = getPreviewBroker(); + const answer = await broker.getPorts(nodeId, params.id); + // The launch ack names the screen before the node's own scan + // reports it (the bridge is up when the ack flies; a scan can lag + // it). The plane's memory may fill a null screen, but it never + // masks a live answer and never overwrites a named screenLost: a + // death the node reported outranks the plane's memory of the birth. + const remembered = + answer.screen === null && answer.screenLost === undefined ? broker.displayForPane(params.id) : null; + return { + ports: nodeState.webPreviewsEnabled ? answer.ports : [], + cdpPort: nodeState.webPreviewsEnabled ? answer.cdpPort : null, + screen: nodeState.virtualDisplayEnabled + ? remembered && remembered.nodeId === nodeId + ? remembered.screen + : answer.screen + : null, + ...(!nodeState.virtualDisplayEnabled || answer.screenLost === undefined + ? {} + : { screenLost: answer.screenLost }), + ...nodeState, + }; + } catch (err) { + return { ...EMPTY_PORTS, ...nodeState, ...mapPortsFailure(err) }; + } + }, + { + response: { + 200: PreviewInfoResponseSchema, + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "subshells:read", + detail: { + operationId: "listSubshellViews", + tags: ["subshells"], + description: + "What the pane currently offers to watch: listening ports, devtools port, virtual display, and whether its node can serve preview. Always answers, never errors, because the open pane view polls it", + }, + }, + ); diff --git a/apps/server/api/src/api/subshells/views-open.route.ts b/apps/server/api/src/api/subshells/views-open.route.ts new file mode 100644 index 000000000..6ad9e56f8 --- /dev/null +++ b/apps/server/api/src/api/subshells/views-open.route.ts @@ -0,0 +1,134 @@ +import { Elysia, t } from "elysia"; +import { authGuard, HttpError, requirePerm } from "@/api/auth-guard.js"; +import { gatePreview, previewNodeState, recheckPreviewOpen } from "@/api/subshells/preview-gate.js"; +import { contextPlugin } from "@/plugins/context.plugin.js"; +import { apiModels } from "@/schema/index.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { detailIsNodeOff, PREVIEW_PANE_GONE } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; +import { getService } from "@/services/subshell-services.js"; + +/** + * `POST /api/subshells/:id/views/open` (spec §6): open one forward lane to + * a pane port and hand out the proxy path. Forwarding a raw TCP lane is an + * `edit` act; watching and reading are not. The ref is the proxy's only + * secret, so it is minted here (the broker owns its whole lifecycle) and the + * route ships nothing but `/pv//` for the SPA to point a browser at. + */ + +const PreviewOpenBodySchema = t.Union([ + t.Object({ + port: t.Integer({ + minimum: 1, + maximum: 65_535, + description: "Loopback port inside the pane's machine to forward (the node dials loopback only)", + }), + }), + t.Object({ serviceId: t.String({ format: "uuid" }) }), +]); + +const PreviewOpenResponseSchema = t.Object({ + proxyBase: t.String({ + description: "Path prefix the SPA proxies the forwarded lane through, ending in a slash", + }), +}); + +export const viewsOpenRoute = new Elysia() + .use(contextPlugin) + .use(authGuard) + .use(apiModels) + .post( + "/:id/views/open", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + const { row } = await gatePreview(user.id, params.id, "edit", actor); + const transportState = await previewNodeState(row.nodeId, "any"); + if (!transportState.nodeEnabled) + throw new HttpError( + 409, + `Web previews & screens is not available on that node right now (${transportState.reason}).`, + ); + const service = "serviceId" in body ? await getService(params.id, body.serviceId) : undefined; + const port = service?.port ?? ("port" in body ? body.port : 0); + const broker = getPreviewBroker(); + const remembered = broker.displayForPane(params.id); + const scan = service ? null : await broker.getPorts(row.nodeId, params.id).catch(() => null); + const screen = scan?.screen ?? (remembered?.nodeId === row.nodeId ? remembered.screen : null); + const feature = !service && screen?.viewer.port === port ? "display" : "web-previews"; + const nodeState = await previewNodeState(row.nodeId, feature); + if (!nodeState.nodeEnabled) { + // The info poll already said this; arriving here means the page was + // stale (or the switch moved mid-click). Say the same thing as a 409. + throw new HttpError( + 409, + feature === "display" + ? "Virtual displays are disabled on this node." + : `Web previews are not available on that node right now (${nodeState.reason}).`, + ); + } + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: user.id, + paneId: params.id, + nodeId: row.nodeId, + port, + service: service ? { host: service.host, protocol: service.protocol } : undefined, + serviceId: service?.id, + authorize: async () => { + if (service) await getService(params.id, service.id); + await recheckPreviewOpen(user.id, params.id, row.nodeId, "edit", actor, feature); + }, + }); + return { proxyBase: `/pv/${ref}/` }; + } catch (err) { + if (err instanceof PreviewRefusal) { + // The broker's own caps (lanes over the per-pane/per-node cap): + // named, 409, so the SPA can say "close a preview first". + throw new HttpError(409, `${err.code}: ${err.message}`); + } + if (err instanceof NodeRpcError) { + if (err.code === "offline") { + throwApiOffline(); + } + if (err.code === "failed" && err.detail !== undefined) { + if (err.detail.startsWith(PREVIEW_PANE_GONE) || detailIsNodeOff(err.detail)) { + throw new HttpError(409, err.detail); + } + throw new HttpError(409, `That node refused the view: ${err.detail}`); + } + if (err.code === "timeout") { + throw new HttpError(409, "The node did not answer the view request in time. Try again in a moment."); + } + // `unsupported`: the connected agent predates the preview command. + throw new HttpError( + 409, + "That node does not support web previews and screens yet. Update the node, then retry.", + ); + } + throw err; + } + }, + { + body: PreviewOpenBodySchema, + response: { + 200: PreviewOpenResponseSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + systemPermission: "subshells:execute", + detail: { + operationId: "openSubshellView", + tags: ["subshells"], + description: + "Open a loopback port or saved serviceId and return its private proxy path. Requires edit access. Saved services use their stored host and HTTP/HTTPS protocol.", + }, + }, + ); + +/** The one 409 every "cannot talk to that node" branch renders. */ +function throwApiOffline(): never { + throw new HttpError(409, "That node is not connected right now. Reconnect it, or watch the pane's screen instead."); +} diff --git a/apps/server/api/src/api/users/__tests__/users-management.test.ts b/apps/server/api/src/api/users/__tests__/users-management.test.ts index 103c3a91b..72f222e19 100644 --- a/apps/server/api/src/api/users/__tests__/users-management.test.ts +++ b/apps/server/api/src/api/users/__tests__/users-management.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeCommandBody } from "@internal/subshell-protocol"; import { hashPassword, verifyPassword } from "better-auth/crypto"; import { sql } from "kysely"; import { authRateLimitRoutes } from "@/api/auth-rate-limit.route.js"; @@ -18,6 +19,7 @@ import { OWNER_DISABLED_CLOSE_CODE, resetNodeRegistryForTests, } from "@/services/nodes/node-registry.js"; +import { getPreviewBroker, resetForTests as resetPreviewBrokerForTests } from "@/services/preview-broker.js"; import { issueSubshellToken } from "@/services/subshell-tokens.js"; import { registerLiveSocket, resetLiveRegistryForTests } from "@/ws/live-registry.js"; import { registerViewer, resetLiveViewersForTests, type WsSocket } from "@/ws/viewers.js"; @@ -180,6 +182,43 @@ describe("admin user management", () => { resetLiveRegistryForTests(); }); + it("closes that user's live preview sessions revoked", async () => { + // The cut this route owes the preview broker is a different object from + // the socket drop above: a preview session is plane-side broker state + // (lanes, watch counts, stream attaches) that closing a /ws/live socket + // does not touch. Without `closeAllForUser`, a demoted account's open + // forward lane keeps proxying bytes to its target port until the + // 15-minute idle sweep finds it. The mechanism is tested in the broker + // suite; this pins that the ROUTE performs the cut (spec 2026-10-21 §7). + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: admin2Id, + paneId: memberSubshellId, + nodeId: "n-preview", + port: 3000, + }); + // A lane, because the lane is the NODE-side state: the sweep's wire + // frame names it (the session ref was the probe, already closed). + await getPreviewBroker().openLaneForConnection(ref); + const res = await req("PATCH", `/${admin2Id}/role`, adminCookie, { role: "user" }); + expect(res.status).toBe(200); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect(sent.some((c) => c.type === "preview_close" && c.reason === "revoked")).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); + it("promotes a member to admin", async () => { const res = await req("PATCH", `/${memberId}/role`, adminCookie, { role: "admin" }); expect(res.status).toBe(200); @@ -444,6 +483,39 @@ describe("admin user management", () => { resetLiveRegistryForTests(); }); + it("closes the disabled user's live preview sessions revoked", async () => { + // The stronger act's twin of the demotion cut: spec 2026-10-21 §7's + // "disable closes live sessions revoked" is a promise about THIS route, + // and a preview session is not a socket. Opened through the real + // broker with a faked command sender, the forward session must be gone + // and its node lanes closed named `revoked` when the write lands. + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: memberId, + paneId: memberSubshellId, + nodeId: "n-preview", + port: 3000, + }); + await getPreviewBroker().openLaneForConnection(ref); + const res = await req("PATCH", `/${memberId}/disabled`, adminCookie, { disabled: true }); + expect(res.status).toBe(200); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect(sent.some((c) => c.type === "preview_close" && c.reason === "revoked")).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); + /** * A fake open terminal attach, registered the way both attach paths do * it: `registerViewer` with a `viewerId`, and the `attachUserId` the diff --git a/apps/server/api/src/api/users/set-user-disabled.route.ts b/apps/server/api/src/api/users/set-user-disabled.route.ts index d6990f813..2069c4cad 100644 --- a/apps/server/api/src/api/users/set-user-disabled.route.ts +++ b/apps/server/api/src/api/users/set-user-disabled.route.ts @@ -8,6 +8,7 @@ import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { audit } from "@/services/audit.js"; import { disconnectNode, getHeld, getLive, OWNER_DISABLED_CLOSE_CODE } from "@/services/nodes/node-registry.js"; import { failConnPendings } from "@/services/nodes/node-rpc.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { dropLiveSocketsFor } from "@/ws/live-registry.js"; import { dropTerminalSocketsFor } from "@/ws/viewers.js"; @@ -82,6 +83,27 @@ export const setUserDisabledRoute = new Elysia().use(requireAdmin).patch( const tokensRevoked = body.disabled ? dropUserTokensFor(target.id) : 0; const terminalSocketsClosed = body.disabled ? dropTerminalSocketsFor(target.id, "account disabled") : 0; const droppedSockets = body.disabled ? dropLiveSocketsFor(target.id, "account disabled") : 0; + // The preview broker's revoke cut, on the same disable-only edge (spec + // 2026-10-21 §7: "disable closes live sessions revoked"). A preview + // session is broker state, not a socket: the feed and terminal drops + // cannot touch a forward lane's node-side lane or a watch count, and + // without this a disabled account's lane keeps proxying to its target + // port until the 15-minute idle sweep. Awaited like the set-node-shares + // relay twin: the answer must not race the cut it describes. Re-enabling + // sweeps nothing, same rule as the three lines above. + const previewSessionsClosed = body.disabled + ? await getPreviewBroker() + .closeAllForUser(target.id, "revoked") + .catch((err: unknown) => { + // Degraded-and-honest, the node-sweep rule below: the disable + // has committed, and a swept-so-far broker is strictly better + // than an unswept one. The row reports ZERO on this path whatever + // was swept before the throw — the count under-claims rather than + // promising a number the catch cannot know. + logger.withError(err).warn(`users: disable of ${target.id} committed, but its preview sweep failed`); + return 0; + }) + : 0; // Operator ruling 2026-09-24: disabling an account takes its enrolled // nodes offline too, and this is the half that makes it true NOW — // `authenticateNodeUpgrade` refuses every later dial-in while the flag @@ -154,7 +176,7 @@ export const setUserDisabledRoute = new Elysia().use(requireAdmin).patch( // when the act cut something and are absent on a re-enable, so the // shape says which edge ran. `tokensRevoked` first because the // sweep order above starts there. - ...(body.disabled ? { tokensRevoked, terminalSocketsClosed, nodesDisconnected } : {}), + ...(body.disabled ? { tokensRevoked, terminalSocketsClosed, nodesDisconnected, previewSessionsClosed } : {}), }), }); return { diff --git a/apps/server/api/src/api/users/set-user-role.route.ts b/apps/server/api/src/api/users/set-user-role.route.ts index 30d61c771..d3501e044 100644 --- a/apps/server/api/src/api/users/set-user-role.route.ts +++ b/apps/server/api/src/api/users/set-user-role.route.ts @@ -6,6 +6,8 @@ import { db } from "@/db/index.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import { DEFAULT_USER_ROLE, USER_ROLES } from "@/db/types/user-role.js"; import { audit } from "@/services/audit.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; +import { logger } from "@/utils/logger.js"; import { dropLiveSocketsFor } from "@/ws/live-registry.js"; const RoleSchema = t.Union( @@ -62,6 +64,23 @@ export const setUserRoleRoute = new Elysia().use(requireAdmin).patch( // authenticates at connect and is never re-checked. Dropping them makes // the client reconnect and re-derive what it may subscribe to. const droppedSockets = dropLiveSocketsFor(target.id); + // The preview broker's revoke cut, riding the same edge (pane-preview + // spec §7: a user write stops that person's preview access NOW). A + // preview session is broker state, not a socket: dropping the feed + // cannot touch a forward lane's node connection or a watch count, and + // without this a just-re-roled account's lane keeps proxying to its + // target port until the 15-minute idle sweep. Awaited like the + // `set-node-shares` relay twin: the answer must not race the cut it + // describes. + await getPreviewBroker() + .closeAllForUser(target.id, "revoked") + .catch((err: unknown) => { + // Degraded-and-honest, the disable route's rule for the same sweep: + // the role write is committed and the audit row below must still land + // — a throw here would 500 past a change that already happened. What + // was swept so far stays swept; nothing reports it from here. + logger.withError(err).warn(`users: role change of ${target.id} committed, but its preview sweep failed`); + }); await audit({ actorUserId: user.id, action: "user.role_change", diff --git a/apps/server/api/src/api/workspaces/__tests__/workspace-layout.test.ts b/apps/server/api/src/api/workspaces/__tests__/workspace-layout.test.ts index 4a82e1011..3809825ab 100644 --- a/apps/server/api/src/api/workspaces/__tests__/workspace-layout.test.ts +++ b/apps/server/api/src/api/workspaces/__tests__/workspace-layout.test.ts @@ -216,3 +216,53 @@ describe("pruneLayout", () => { expect("edgeGroups" in pruned).toBe(false); }); }); + +describe("preview tabs follow their parent pane's visibility", () => { + function layout() { + return { + grid: { + root: { + type: "branch", + data: [ + { + type: "leaf", + data: { + id: "group", + views: [ + "a", + "preview:a:screen", + "preview:a:app:41430", + "preview:b:screen", + "preview:a:app:99999", + "preview:a", + ], + activeView: "preview:a:screen", + }, + }, + ], + }, + }, + panels: { + a: { id: "a" }, + "preview:a:screen": { id: "preview:a:screen", contentComponent: "preview", params: { paneId: "foreign" } }, + "preview:a:app:41430": { id: "preview:a:app:41430" }, + "preview:a:app:99999": { id: "preview:a:app:99999" }, + "preview:a": { id: "preview:a" }, + "preview:b:screen": { id: "preview:b:screen" }, + }, + }; + } + it("preserves the preview with its authorized parent and binds its content to that parent", () => { + const pruned = pruneLayout(layout(), new Set(["a"])); + expect(panelIdsInLayout(pruned)).toEqual(["a", "preview:a:screen", "preview:a:app:41430"]); + expect((pruned?.panels as Record | undefined)?.["preview:a:screen"]).toEqual({ + id: "preview:a:screen", + contentComponent: "preview", + params: { paneId: "a", view: { kind: "screen" } }, + renderer: "always", + }); + }); + it("removes the preview when its parent is deleted or access is revoked", () => { + expect(pruneLayout(layout(), new Set())).toBeNull(); + }); +}); diff --git a/apps/server/api/src/api/workspaces/workspace-layout.ts b/apps/server/api/src/api/workspaces/workspace-layout.ts index f07605851..70a17b990 100644 --- a/apps/server/api/src/api/workspaces/workspace-layout.ts +++ b/apps/server/api/src/api/workspaces/workspace-layout.ts @@ -1,3 +1,4 @@ +import { parseWorkspacePreviewId } from "@internal/subshell-protocol"; /** * dockview's serialized layout. Treated as opaque apart from the panel and * group references we prune or repair (`panels`, `grid`, `activeGroup`, @@ -77,11 +78,19 @@ export function pruneLayout(layout: unknown, livePaneIds: ReadonlySet): if (!isRecord(layout) || !isRecord(layout.panels) || !isRecord(layout.grid)) return null; const panelIds = Object.keys(layout.panels); - const survivors = panelIds.filter((id) => livePaneIds.has(id)); + const allowed = (id: string) => livePaneIds.has(id) || livePaneIds.has(parseWorkspacePreviewId(id)?.paneId ?? ""); + const survivors = panelIds.filter(allowed); if (survivors.length === 0) return null; const panels: Record = {}; - for (const id of survivors) panels[id] = (layout.panels as Record)[id]; + for (const id of survivors) { + const panel = (layout.panels as Record)[id]; + const preview = parseWorkspacePreviewId(id); + panels[id] = + preview && isRecord(panel) + ? { ...panel, contentComponent: "preview", params: preview, renderer: "always" } + : panel; + } // Prune the grid against the ids that actually survived into `panels`, not // against `livePaneIds`. The two differ for a view naming a live pane that @@ -125,12 +134,12 @@ export function pruneLayout(layout: unknown, livePaneIds: ReadonlySet): const entries = layout[field]; if ( Array.isArray(entries) && - entries.some((entry) => viewIdsInWindowEntry(entry).some((id) => !livePaneIds.has(id))) + entries.some((entry) => viewIdsInWindowEntry(entry).some((id) => !survivorSet.has(id))) ) { delete result[field]; } } - if (viewIdsInEdgeGroups(layout.edgeGroups).some((id) => !livePaneIds.has(id))) { + if (viewIdsInEdgeGroups(layout.edgeGroups).some((id) => !survivorSet.has(id))) { delete result.edgeGroups; } diff --git a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts index 68f26dfbc..57f87ea01 100644 --- a/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,21 +392,21 @@ test("real entry: available older upgrade snapshot restores and migrates before // are stripped (rows AND their DDL) so the snapshot ends where an install of // that era would; removing one from the middle while a later row stands // above it is exactly the non-contiguous history kysely refuses to boot. - // WHY 0047..0058 together: this era's twelve migrations are the tail of the + // WHY 0047..0065 together: these migrations are the tail of the // registered ledger, so the prefix runs contiguously through 0046 and boot - // re-applies all twelve. Advancing LATEST_BACKUP_MIGRATION (0058) means the - // strip set moves with it in the same commit. + // re-applies the whole tail. Advancing LATEST_BACKUP_MIGRATION means the strip + // set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "ALTER TABLE nodes DROP COLUMN virtual_display_providers; ALTER TABLE presets DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_provider; ALTER TABLE subshells DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_enabled; DROP TABLE subshell_services; ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN web_previews_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN virtual_display_providers text NOT NULL DEFAULT '[\"xvfb-x11vnc\"]'; ALTER TABLE presets ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN web_previews_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN preview_enabled_at text; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); UPDATE migration_restore_probe SET value='newer destination';", ); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); @@ -420,7 +420,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices')", ) .all(), ).toEqual([]); @@ -430,7 +430,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices') ORDER BY name", ) .all(), ).toEqual([ @@ -449,6 +449,12 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0059-cross-agent-guidance" }, { name: "0060-subshell-parent" }, { name: "0061-node-system-specs" }, + { name: "0062-node-preview-enabled" }, + { name: "0063-subshell-services" }, + { name: "0064-node-virtual-display-enabled" }, + { name: "0065-subshell-screen-config" }, + { name: "0066-node-display-provider" }, + { name: "0067-display-provider-choices" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( @@ -474,6 +480,13 @@ test("real entry: available older upgrade snapshot restores and migrates before expect(migrated.query("SELECT name FROM pragma_table_info('nodes') WHERE name='ssh_wide_forwards'").get()).toEqual({ name: "ssh_wide_forwards", }); + expect( + migrated + .query( + "SELECT name FROM pragma_table_info('nodes') WHERE name IN ('web_previews_enabled','preview_enabled_at') ORDER BY name", + ) + .all(), + ).toEqual([{ name: "preview_enabled_at" }, { name: "web_previews_enabled" }]); expect(migrated.query("SELECT name FROM pragma_table_info('ssh_presets') WHERE name='forwards'").get()).toEqual({ name: "forwards", }); diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 8f916b997..9d9345ac3 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -59,8 +59,14 @@ import * as workspaceFirstMigration from "@/db/migrations/0058-workspace-first.j import * as crossAgentGuidanceMigration from "@/db/migrations/0059-cross-agent-guidance.js"; import * as subshellParentMigration from "@/db/migrations/0060-subshell-parent.js"; import * as nodeSystemSpecsMigration from "@/db/migrations/0061-node-system-specs.js"; +import * as nodePreviewEnabledMigration from "@/db/migrations/0062-node-preview-enabled.js"; +import * as subshellServicesMigration from "@/db/migrations/0063-subshell-services.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; import * as presetSharingMigration from "./migrations/0057-preset-sharing.js"; +import * as nodeVirtualDisplayEnabledMigration from "./migrations/0064-node-virtual-display-enabled.js"; +import * as subshellScreenConfigMigration from "./migrations/0065-subshell-screen-config.js"; +import * as nodeDisplayProviderMigration from "./migrations/0066-node-display-provider.js"; +import * as displayProviderChoicesMigration from "./migrations/0067-display-provider-choices.js"; /** * Runs all pending Kysely migrations against the app database. @@ -177,6 +183,12 @@ export async function runMigrations(): Promise { "0059-cross-agent-guidance": crossAgentGuidanceMigration, "0060-subshell-parent": subshellParentMigration, "0061-node-system-specs": nodeSystemSpecsMigration, + "0062-node-preview-enabled": nodePreviewEnabledMigration, + "0063-subshell-services": subshellServicesMigration, + "0064-node-virtual-display-enabled": nodeVirtualDisplayEnabledMigration, + "0065-subshell-screen-config": subshellScreenConfigMigration, + "0066-node-display-provider": nodeDisplayProviderMigration, + "0067-display-provider-choices": displayProviderChoicesMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts b/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts new file mode 100644 index 000000000..cbc4a9b5a --- /dev/null +++ b/apps/server/api/src/db/migrations/0062-node-preview-enabled.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +// Adds the per-node preview switch. Default 0 keeps the whole fleet (and the +// control-plane host) off after an upgrade: watching panes is opt-in per +// machine, and preview exposes terminal content, so an upgrade must never +// turn it on anywhere (0047's SSH-capability shape and doctrine). +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("web_previews_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + await db.schema.alterTable("nodes").addColumn("preview_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("preview_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("web_previews_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/0063-subshell-services.ts b/apps/server/api/src/db/migrations/0063-subshell-services.ts new file mode 100644 index 000000000..a737b37ed --- /dev/null +++ b/apps/server/api/src/db/migrations/0063-subshell-services.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("subshell_services") + .addColumn("id", "text", (c) => c.primaryKey()) + .addColumn("subshell_id", "text", (c) => c.notNull().references("subshells.id").onDelete("cascade")) + .addColumn("name", "text", (c) => c.notNull()) + .addColumn("host", "text", (c) => c.notNull()) + .addColumn("port", "integer", (c) => c.notNull()) + .addColumn("protocol", "text", (c) => c.notNull()) + .addColumn("created_at", "text", (c) => c.notNull()) + .addUniqueConstraint("subshell_services_target", ["subshell_id", "host", "port", "protocol"]) + .execute(); +} +export async function down(db: Kysely): Promise { + await db.schema.dropTable("subshell_services").execute(); +} diff --git a/apps/server/api/src/db/migrations/0064-node-virtual-display-enabled.ts b/apps/server/api/src/db/migrations/0064-node-virtual-display-enabled.ts new file mode 100644 index 000000000..0bc8410f7 --- /dev/null +++ b/apps/server/api/src/db/migrations/0064-node-virtual-display-enabled.ts @@ -0,0 +1,14 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("virtual_display_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + // Preserve the permission existing owners granted with the combined switch. + await sql`update nodes set virtual_display_enabled = web_previews_enabled`.execute(db); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("virtual_display_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/0065-subshell-screen-config.ts b/apps/server/api/src/db/migrations/0065-subshell-screen-config.ts new file mode 100644 index 000000000..d56b6bb4f --- /dev/null +++ b/apps/server/api/src/db/migrations/0065-subshell-screen-config.ts @@ -0,0 +1,9 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema.alterTable("subshells").addColumn("screen_config", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("subshells").dropColumn("screen_config").execute(); +} diff --git a/apps/server/api/src/db/migrations/0066-node-display-provider.ts b/apps/server/api/src/db/migrations/0066-node-display-provider.ts new file mode 100644 index 000000000..0f4a1e061 --- /dev/null +++ b/apps/server/api/src/db/migrations/0066-node-display-provider.ts @@ -0,0 +1,11 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("virtual_display_provider", "text", (col) => col.notNull().defaultTo("xvfb-x11vnc")) + .execute(); +} +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("virtual_display_provider").execute(); +} diff --git a/apps/server/api/src/db/migrations/0067-display-provider-choices.ts b/apps/server/api/src/db/migrations/0067-display-provider-choices.ts new file mode 100644 index 000000000..8d548982c --- /dev/null +++ b/apps/server/api/src/db/migrations/0067-display-provider-choices.ts @@ -0,0 +1,16 @@ +import { type Kysely, sql } from "kysely"; +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("nodes") + .addColumn("virtual_display_providers", "text", (c) => c.notNull().defaultTo('["xvfb-x11vnc"]')) + .execute(); + await db.schema.alterTable("presets").addColumn("screen_config", "text").execute(); + // Before provider overrides existed, every allocated display used Xvfb. + await sql`UPDATE subshells SET screen_config = json_set(screen_config, '$.provider', 'xvfb-x11vnc') WHERE screen_config IS NOT NULL AND json_valid(screen_config) AND json_extract(screen_config, '$.provider') IS NULL`.execute( + db, + ); +} +export async function down(db: Kysely): Promise { + await db.schema.alterTable("presets").dropColumn("screen_config").execute(); + await db.schema.alterTable("nodes").dropColumn("virtual_display_providers").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts new file mode 100644 index 000000000..b20f24e6e --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0062-node-preview-enabled.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as previewEnabledMigration from "@/db/migrations/0062-node-preview-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; +} + +/** + * The per-node preview switch columns (pane-preview spec Task 3). Default 0 is + * the whole upgrade story, cloned from 0047's doctrine: a node enrolled before + * this migration (and the seeded local row) reads OFF, because watching panes + * is opt-in per machine: preview exposes terminal content, so an upgrade must + * never turn it on anywhere. + */ +describe("migration 0062-node-preview-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0062 repository did - no preview fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-10-21T00:00:00.000Z", + updated_at: "2026-10-21T00:00:00.000Z", + }) + .execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0062-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await previewEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the two columns", async () => { + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).toContain("web_previews_enabled"); + expect(names).toContain("preview_enabled_at"); + }); + + it("leaves every pre-existing node with preview off (default-off doctrine)", async () => { + const r = await sql<{ + web_previews_enabled: number; + preview_enabled_at: string | null; + }>`SELECT web_previews_enabled, preview_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ web_previews_enabled: 0, preview_enabled_at: null }); + }); + + it("defaults a row written without the columns to 0", async () => { + await legacyNode("fresh"); + const r = await sql<{ + web_previews_enabled: number; + }>`SELECT web_previews_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].web_previews_enabled).toBe(0); + }); + + it("stores the flag with its stamp", async () => { + await db + .updateTable("nodes") + .set({ web_previews_enabled: 1, preview_enabled_at: "2026-10-21T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + web_previews_enabled: number; + preview_enabled_at: string; + }>`SELECT web_previews_enabled, preview_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ web_previews_enabled: 1, preview_enabled_at: "2026-10-21T10:00:00.000Z" }); + }); + + it("down removes both columns and leaves the rows", async () => { + await previewEnabledMigration.down(db); + const cols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const names = cols.rows.map((c) => c.name); + expect(names).not.toContain("web_previews_enabled"); + expect(names).not.toContain("preview_enabled_at"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await previewEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/migrations/__tests__/0064-node-virtual-display-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0064-node-virtual-display-enabled.test.ts new file mode 100644 index 000000000..44bd39cd8 --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0064-node-virtual-display-enabled.test.ts @@ -0,0 +1,29 @@ +import { Database } from "bun:sqlite"; +import { expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { down, up } from "../0064-node-virtual-display-enabled.js"; + +it("preserves the old combined grant but defaults newly enrolled nodes off", async () => { + const db = new Kysely({ dialect: new BunSqliteDialect({ database: new Database(":memory:") }) }); + try { + await sql`create table nodes (id text, web_previews_enabled integer not null default 0)`.execute(db); + await sql`insert into nodes values ('enabled', 1), ('disabled', 0)`.execute(db); + await up(db); + await sql`insert into nodes (id) values ('new')`.execute(db); + const rows = await sql<{ + id: string; + virtual_display_enabled: number; + }>`select id, virtual_display_enabled from nodes order by id`.execute(db); + expect(rows.rows).toEqual([ + { id: "disabled", virtual_display_enabled: 0 }, + { id: "enabled", virtual_display_enabled: 1 }, + { id: "new", virtual_display_enabled: 0 }, + ]); + await down(db); + await up(db); + expect((await sql`select * from nodes`.execute(db)).rows).toHaveLength(3); + } finally { + await db.destroy(); + } +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index b87987caa..2545671b1 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -63,6 +63,11 @@ export class NodesRepository extends BaseRepository { // and the gate is opt-in per machine (spec 4.3); mirror it the same way. sshEnabled: input.sshEnabled ?? 0, sshEnabledAt: input.sshEnabledAt ?? null, + // A new node watches no panes: `0` is the column default (migration + // 0062) and preview is opt-in per machine; mirror it the same way. + webPreviewsEnabled: input.webPreviewsEnabled ?? 0, + virtualDisplayEnabled: input.virtualDisplayEnabled ?? 0, + previewEnabledAt: input.previewEnabledAt ?? null, sshWideForwards: 0, createdAt: input.createdAt ?? now, updatedAt: now, @@ -297,6 +302,44 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the preview switch and its stamp (pane-preview spec Task 3). + * + * Same shape as {@link setSshEnabled}: both fields move together, and the + * CALLER supplies `changedAt`: the stamp is the reconciliation protocol + * between the plane's row and the node's mirror file, so a relay must not + * re-stamp the fact it is carrying. + */ + async setPreviewEnabled( + id: string, + state: { + /** True = this machine's panes may be watched. */ + on: boolean; + feature?: "web-previews" | "display"; + provider?: string; + providers?: string[]; + /** ISO 8601 of the write that produced `on`. Never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + ...(state.feature === "display" + ? { + virtualDisplayEnabled: state.on ? 1 : 0, + ...(state.provider ? { virtualDisplayProvider: state.provider } : {}), + ...(state.providers ? { virtualDisplayProviders: JSON.stringify(state.providers) } : {}), + } + : { webPreviewsEnabled: state.on ? 1 : 0 }), + previewEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * Write the SSH wide-forwards allowance (spec 2026-10-15 §6). No stamp * column: unlike {@link setSshEnabled} there is no node-side mirror to diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index a669d7fde..77bd869aa 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -33,6 +33,7 @@ import type { WorkspaceTable } from "@/db/types/workspaces.db-types.js"; * them separately.) */ export interface Database { + subshellServices: import("./subshell-services.db-types.js").SubshellServiceTable; presetShares: PresetShareTable; sshPresetShares: PresetShareTable; authAttempts: AuthAttemptsTable; diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 2c8b0d4fe..03da9c769 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -107,6 +107,26 @@ export interface NodeTable { * maintenance. */ sshEnabledAt: string | null; + /** + * 1 = apps, services and browsers may be viewed on this machine (pane-preview spec + * Task 3). Opt-in per node, never a fleet default: 0 for every row that + * predates the column, so an upgrade leaves preview off everywhere until + * the owner flips it: the flag exposes terminal content. + */ + webPreviewsEnabled: number; + /** Independent permission to start and connect to virtual displays. */ + virtualDisplayEnabled: number; + /** Backend selected for new displays; existing displays retain their provider. */ + virtualDisplayProvider?: string; + /** JSON array of providers allowed for new displays. */ + virtualDisplayProviders?: string; + /** + * ISO 8601 of the write that last changed {@link webPreviewsEnabled} (the + * wire's `changedAt`), or null when the flag was never written. The stamp + * rides the flag on every push, the shape {@link sshEnabledAt} has for + * SSH. + */ + previewEnabledAt: string | null; /** * 1 = this machine's owner allows SSH port forwards to bind NON-loopback * addresses (docs/superpowers/specs/2026-10-15-ssh-port-forwards-design.md diff --git a/apps/server/api/src/db/types/presets.db-types.ts b/apps/server/api/src/db/types/presets.db-types.ts index 69e0b4b75..f43904418 100644 --- a/apps/server/api/src/db/types/presets.db-types.ts +++ b/apps/server/api/src/db/types/presets.db-types.ts @@ -9,6 +9,8 @@ * (multi-user ready). */ export interface PresetTable { + /** Optional JSON display request; omitted provider follows the node default. */ + screenConfig?: string | null; /** Unique preset id (uuid) */ id: string; /** Owning user id (better-auth user id) */ diff --git a/apps/server/api/src/db/types/subshell-services.db-types.ts b/apps/server/api/src/db/types/subshell-services.db-types.ts new file mode 100644 index 000000000..dad0c05bb --- /dev/null +++ b/apps/server/api/src/db/types/subshell-services.db-types.ts @@ -0,0 +1,9 @@ +export interface SubshellServiceTable { + id: string; + subshellId: string; + name: string; + host: string; + port: number; + protocol: "http" | "https"; + createdAt: string; +} diff --git a/apps/server/api/src/db/types/subshells.db-types.ts b/apps/server/api/src/db/types/subshells.db-types.ts index fcf1ec34c..9f1ca4216 100644 --- a/apps/server/api/src/db/types/subshells.db-types.ts +++ b/apps/server/api/src/db/types/subshells.db-types.ts @@ -7,6 +7,8 @@ import type { SubshellStatus } from "./subshell-status.js"; * subshell at query time (tmux is the source of truth). */ export interface SubshellTable { + /** Requested virtual display dimensions, retained across manual and automatic restarts. */ + screenConfig: string | null; /** Unique subshell id (uuid) */ id: string; /** Owning user id */ @@ -118,6 +120,7 @@ export interface SubshellTable { */ export type NewSubshell = Omit< SubshellTable, + | "screenConfig" | "presetId" | "createdAt" | "endedAt" @@ -142,6 +145,7 @@ export type NewSubshell = Omit< | "keyHomeNodeId" | "terminalReplayLines" > & { + screenConfig?: string | null; /** Preset launched with; omitted = NULL (presetless launch). Defaults on insert. */ presetId?: string | null; /** Node to launch on; omitted = DB default 'local' */ diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index ff35112b9..e80d732ea 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -36,6 +36,7 @@ import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { sweepLocalPreviewDisplays } from "@/preview/local-preview.js"; import { startServer } from "@/server.js"; import { sweepBackupDownloads } from "@/services/backup-download-jobs.js"; import { sweepRestoreStages } from "@/services/backup-staging.js"; @@ -57,6 +58,7 @@ import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { reconcileMaintenance } from "@/services/nodes/maintenance.js"; import { setNodeLifecycleHooks } from "@/services/nodes/node-events.js"; import { listOnline } from "@/services/nodes/node-registry.js"; +import { reconcilePreviewEnabled } from "@/services/nodes/preview-enabled.js"; import { ensureLocalNode } from "@/services/nodes/seed-local.js"; import { reconcileSshEnabled } from "@/services/nodes/ssh-enabled.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; @@ -224,6 +226,10 @@ async function bootServer(): Promise { getLogger().withError(err).warn("could not prepare this host's plugins at boot; will retry next start"); } + await sweepLocalPreviewDisplays().catch((err: unknown) => + getLogger().withError(err).warn("could not restore virtual displays at boot"), + ); + // The trusted-origin registry, from the RECORDS, before the listener: a // network that survived the restart is reachable the millisecond the port // opens, and a browser on it must not be 403'd on the first request. Reads @@ -328,6 +334,9 @@ async function bootServer(): Promise { // The SSH mirror's sibling (spec 2026-10-07 §4.3): same door, opposite // direction — the row is the record, so this reconcile only pushes. onSshEnabled: (nodeId, reported) => reconcileSshEnabled(nodeId, reported), + // The preview switch's sibling (pane-preview spec Task 3): same door, same + // direction rule: the row is the record, so this reconcile only pushes. + onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). diff --git a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts index 1ff8e2a55..df82d12d3 100644 --- a/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts +++ b/apps/server/api/src/plugins/__tests__/request-log-ignore.test.ts @@ -37,6 +37,15 @@ describe("REQUEST_LOG_IGNORE", () => { expect(ignored("/api/nodes/local/logs")).toBe(true); }); + it("ignores the open pane view's preview poll but not its acts", () => { + // The pane view polls GET /:id/views while it is open (spec §7); + // opening a forward or starting a watch changes what the pane does and + // must stay in the log. + expect(ignored("/api/subshells/018f2c4e-1a2b-7c3d-9e4f-5a6b7c8d9e0f/views")).toBe(true); + expect(ignored("/api/subshells/x/views/open")).toBe(false); + expect(ignored("/api/subshells/x/views/browser/watch")).toBe(false); + }); + it("does not swallow the node routes that are ACTS rather than polls", () => { // Service verbs, repointing and the logging switch each change a machine. // Those lines are the reason an admin turns debug logging on at all. @@ -53,4 +62,15 @@ describe("REQUEST_LOG_IGNORE", () => { // A route that happens to begin with those letters is a different thing. expect(ignored("/wsx")).toBe(false); }); + + it("ignores every /pv/ proxy path, because the session secret rides the path", () => { + // A request line is a path, and for the preview proxy the path IS the + // credential (spec 2026-10-21 §10): the 32-hex session ref is the only + // secret standing between a log reader and anyone's forwarded pane port. + // A debug log that records the request lines records every live link. + expect(ignored("/pv/4f9c1a2b3d4e5f60718293a4b5c6d7e8/index.html")).toBe(true); + expect(ignored("/pv/4f9c1a2b3d4e5f60718293a4b5c6d7e8/")).toBe(true); + // Not merely the prefix shape: any depth, any verb's path. + expect(ignored("/pv/deadbeefdeadbeefdeadbeefdeadbeef/ws/hmr")).toBe(true); + }); }); diff --git a/apps/server/api/src/plugins/context.plugin.ts b/apps/server/api/src/plugins/context.plugin.ts index f8a98e7c0..851d6faf4 100644 --- a/apps/server/api/src/plugins/context.plugin.ts +++ b/apps/server/api/src/plugins/context.plugin.ts @@ -39,6 +39,15 @@ export const REQUEST_LOG_IGNORE: (string | RegExp)[] = [ // A node's log, polled once a second by whoever has that node's page open. // A regex because the id is in the path. /^\/api\/nodes\/[^/]+\/logs$/, + // The preview poll an open pane view runs (spec §7): the exact path, so + // the open/watch acts under it keep their request lines. + /^\/api\/subshells\/[^/]+\/views$/, + // The /pv proxy: request lines carry the path, the path carries the + // 32-hex session ref, and the ref IS the credential (spec 2026-10-21 + // §10). A debug request line here would log a live link into someone's + // pane port. Same class as the setup-key rule in the API AGENTS.md log + // notes: paths that wear secrets never ride the log. + /^\/pv\//, /^\/ws(\/|$)/, // Minted once per live-feed connect, and the feed RETRIES on a bounded // backoff (spec 2026-09-19 § 5) — so a tab whose session has gone while the diff --git a/apps/server/api/src/preview/__tests__/lane-limits.test.ts b/apps/server/api/src/preview/__tests__/lane-limits.test.ts new file mode 100644 index 000000000..976cf819c --- /dev/null +++ b/apps/server/api/src/preview/__tests__/lane-limits.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { Duplex } from "node:stream"; +import { PREVIEW_LANE_BUFFER_MAX_BYTES } from "@internal/subshell-protocol"; +import { LaneDuplex, type LaneEvent } from "@/preview/lane-duplex.js"; +import { sendLaneRequest } from "@/preview/lane-http.js"; +import { readLaneHead } from "@/preview/lane-wire.js"; +import { openLaneWebSocket } from "@/preview/lane-ws-client.js"; +import { storeCookiesInJar } from "@/preview/pv-http.js"; + +function wire() { + let sent = ""; + const duplex = new Duplex({ + read() {}, + write(bytes, _encoding, done) { + sent += bytes.toString(); + done(); + }, + }); + return { duplex, request: () => sent }; +} +async function response(transfer = "transfer-encoding: chunked") { + const { duplex } = wire(); + let closed = 0; + const answer = sendLaneRequest(duplex, { method: "GET", path: "/", headers: [], body: null }, false, () => { + closed++; + duplex.destroy(); + }); + duplex.push(Buffer.from(`HTTP/1.1 200 OK\r\n${transfer}\r\n\r\n`)); + const result = await answer; + if (!result.body) throw new Error("Missing body"); + return { duplex, body: result.body, closed: () => closed }; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); + +describe("preview response memory and framing", () => { + it("counts only headers toward the head limit and handles coalesced informational responses", async () => { + const { duplex } = wire(); + const answer = readLaneHead(duplex); + const body = Buffer.alloc(128 * 1024, 120); + duplex.push( + Buffer.concat([ + Buffer.from("HTTP/1.1 103 Early Hints\r\n\r\nHTTP/1.1 200 OK\r\ncontent-length: 131072\r\n\r\n"), + body, + ]), + ); + const head = await answer; + expect(head.statusLine).toBe("HTTP/1.1 200 OK"); + expect(head.tail.equals(body)).toBe(true); + duplex.destroy(); + }); + + it("streams a large declared chunk before the whole chunk arrives", async () => { + const r = await response(); + const reader = r.body.getReader(); + r.duplex.push(Buffer.from("10000000\r\nfirst bytes")); + const part = await reader.read(); + expect(Buffer.from(part.value ?? []).toString()).toBe("first bytes"); + await reader.cancel(); + expect(r.closed()).toBe(1); + }); + for (const fragment of ["-1\r\n", "10junk\r\n", "f".repeat(8193), "2\r\nokNO"]) { + it(`refuses malformed chunk framing ${fragment.slice(0, 20)}`, async () => { + const r = await response(); + const outcome = new Response(r.body).text().then( + () => "accepted", + () => "rejected", + ); + r.duplex.push(Buffer.from(fragment)); + expect(await outcome).toBe("rejected"); + expect(r.closed()).toBe(1); + }); + } + it("closes a slow HTTP reader at the byte budget", async () => { + const r = await response("content-length: 9999999"); + for (let i = 0; i < 5; i++) r.duplex.push(Buffer.alloc(PREVIEW_LANE_BUFFER_MAX_BYTES / 4)); + await tick(); + expect(r.closed()).toBe(1); + await expect(new Response(r.body).arrayBuffer()).rejects.toThrow("buffer limit"); + }); + it("rejects a truncated length-delimited response", async () => { + const r = await response("content-length: 100"); + const outcome = new Response(r.body).text().then( + () => "accepted", + () => "rejected", + ); + r.duplex.push(Buffer.from("short")); + r.duplex.destroy(); + expect(await outcome).toBe("rejected"); + }); + it("caps unread lane bytes before a consumer starts", async () => { + let deliver: (event: LaneEvent) => void = () => {}; + let unsubscribed = false; + const duplex = new LaneDuplex({ + writeOut() {}, + onLane(cb) { + deliver = cb; + return () => { + unsubscribed = true; + }; + }, + }); + for (let i = 0; i < 5; i++) deliver({ bytes: Buffer.alloc(PREVIEW_LANE_BUFFER_MAX_BYTES / 4) }); + expect(duplex.destroyed).toBe(true); + expect(duplex.closedReason).toBe("slow-link"); + expect(unsubscribed).toBe(true); + }); + it("bounds target-controlled cookie count and bytes across many responses", () => { + const jar = new Map(); + for (let i = 0; i < 300; i++) storeCookiesInJar(jar, [`cookie${i}=${"x".repeat(2000)}`]); + expect(jar.size).toBeLessThanOrEqual(128); + expect([...jar.values()].reduce((sum, value) => sum + Buffer.byteLength(value), 0)).toBeLessThanOrEqual(64 * 1024); + expect(jar.has("cookie299")).toBe(true); + expect(jar.has("cookie0")).toBe(false); + }); + it("rejects a WebSocket message exceeding the limit across fragments", async () => { + const { duplex, request } = wire(); + let deaths = 0; + const opening = openLaneWebSocket(duplex, { + port: 3000, + path: "/socket", + headers: [], + onOpen() {}, + onMessage() { + throw new Error("oversized message delivered"); + }, + onCookies() {}, + onDeath() { + deaths++; + duplex.destroy(); + }, + }); + const key = /sec-websocket-key: ([^\r]+)/.exec(request())?.[1]; + if (!key) throw new Error("Missing WS key"); + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + duplex.push(Buffer.from(`HTTP/1.1 101 Switching Protocols\r\nsec-websocket-accept: ${accept}\r\n\r\n`)); + await opening; + const size = PREVIEW_LANE_BUFFER_MAX_BYTES / 2; + for (let i = 0; i < 3; i++) { + const header = Buffer.alloc(10); + header[0] = i === 0 ? 2 : 0; // non-final binary, then continuation frames + header[1] = 127; + header.writeBigUInt64BE(BigInt(size), 2); + duplex.push(header); + if (i < 2) duplex.push(Buffer.alloc(size)); + } + await tick(); + expect(deaths).toBeGreaterThan(0); + }); +}); diff --git a/apps/server/api/src/preview/__tests__/local-preview.test.ts b/apps/server/api/src/preview/__tests__/local-preview.test.ts new file mode 100644 index 000000000..3b40978a6 --- /dev/null +++ b/apps/server/api/src/preview/__tests__/local-preview.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PreviewLaneEvent } from "@internal/pane-runtime"; +import type { NodeCommandBody } from "@internal/subshell-protocol"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { LocalPreviewRuntime } from "@/preview/local-preview.js"; + +const id = crypto.randomUUID(); +const ref = "1".repeat(32); +const roots: string[] = []; +const runtimes: LocalPreviewRuntime[] = []; +function setup(over: Partial[0]> = {}) { + const dataDir = mkdtempSync(join(tmpdir(), "local-preview-")); + roots.push(dataDir); + const runtime = new LocalPreviewRuntime({ + dataDir, + enabled: async () => true, + pane: async () => ({ id, nodeId: "local", alive: 1, status: "running", tmuxSocket: "owned" }) as SubshellTable, + panePid: async () => process.pid, + ...over, + }); + runtimes.push(runtime); + return runtime; +} +afterEach(() => { + for (const runtime of runtimes.splice(0)) runtime.forward.closeAll("viewer-left"); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("the server's machine-local preview runtime", () => { + it("rejects each disabled feature before opening a lane or browser bridge", async () => { + for (const [kind, port, apps, display, error] of [ + ["forward", 3000, false, true, "preview-node-off"], + ["screencast", 9222, false, true, "preview-node-off"], + ["forward", 5910, true, false, "virtual-display-disabled"], + ] as const) { + const runtime = setup({ webPreviewsEnabled: async () => apps, displayEnabled: async () => display }); + const screen = spyOn(runtime.displays, "getFor").mockReturnValue({ + display: ":90", + viewer: { protocol: "vnc", port: 5910 }, + }); + try { + await expect( + runtime.execute({ type: "preview_open", kind, port, paneId: id, ref, lifetimeMs: 60000 }, () => {}), + ).rejects.toThrow(error); + expect(runtime.forward.laneCount()).toBe(0); + } finally { + screen.mockRestore(); + } + } + }); + + it("checks the DB switch before inspecting a process", async () => { + let reads = 0; + const runtime = setup({ + enabled: async () => false, + pane: async () => { + reads++; + return undefined; + }, + }); + await expect(runtime.execute({ type: "preview_ports_get", paneId: id }, () => {})).rejects.toThrow( + "preview-node-off", + ); + expect(reads).toBe(0); + }); + + it("refuses a remote or dead pane before scanning ports", async () => { + let scans = 0; + for (const row of [ + { nodeId: "other", alive: 1 }, + { nodeId: "local", alive: 0 }, + ]) { + const runtime = setup({ + pane: async () => ({ id, status: "running", tmuxSocket: "owned", ...row }) as SubshellTable, + scan: async () => { + scans++; + return { ports: [], cdpPort: null, screen: null }; + }, + }); + await expect(runtime.execute({ type: "preview_ports_get", paneId: id }, () => {})).rejects.toThrow( + "preview-pane-gone", + ); + } + expect(scans).toBe(0); + }); + + it("discovers this process's real listening port without a node connection", async () => { + if (process.platform !== "linux") return; + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no test address"); + const answer = (await setup().execute({ type: "preview_ports_get", paneId: id }, () => {})) as { + ports: { port: number }[]; + }; + expect(answer.ports.some((p) => p.port === address.port)).toBe(true); + } finally { + server.close(); + } + }); + + it("forwards real bytes and still closes after the switch goes off", async () => { + const server = createServer((socket) => socket.on("data", (bytes) => socket.write(bytes))); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + let enabled = true; + const runtime = setup({ enabled: async () => enabled }); + const events: PreviewLaneEvent[] = []; + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no test address"); + const command: NodeCommandBody = { + type: "preview_open", + paneId: id, + ref, + kind: "forward", + port: address.port, + lifetimeMs: 60_000, + }; + expect(await runtime.execute(command, (event) => events.push(event))).toEqual({ ref, pending: true }); + runtime.forward.ingestInboundFrame({ + type: "preview_data", + ref, + dir: "in", + blob: Buffer.from("local echo").toString("base64"), + }); + const deadline = Date.now() + 3000; + while (!events.some((event) => event.type === "preview_data") && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 10)); + const frame = events.find((event) => event.type === "preview_data"); + expect(frame?.type === "preview_data" ? Buffer.from(frame.blob, "base64").toString() : null).toBe("local echo"); + enabled = false; + expect(await runtime.execute({ type: "preview_close", ref, reason: "viewer-left" }, () => {})).toEqual({ + ref, + closed: true, + }); + expect(runtime.forward.laneCount()).toBe(0); + } finally { + runtime.forward.closeAll("viewer-left"); + server.close(); + } + }); +}); diff --git a/apps/server/api/src/preview/__tests__/pv-proxy.test.ts b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts new file mode 100644 index 000000000..5a083fb86 --- /dev/null +++ b/apps/server/api/src/preview/__tests__/pv-proxy.test.ts @@ -0,0 +1,850 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as net from "node:net"; +import { + type NodeCommandBody, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LANE_QUEUE_MAX, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { Elysia } from "elysia"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; +import { pvHttpRoute } from "@/preview/pv-http.js"; +import { previewWsRoute } from "@/preview/pv-ws.js"; +import { createApp } from "@/server.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { originRegistry } from "@/services/trusted-origins.js"; + +/** + * The same-origin `/pv` proxy (pane-preview spec 2026-10-21 §6, Task 10). + * + * Two halves, one path: the HTTP rewrite half (`pv-http.ts`) and the WS + * tunnel half (`pv-ws.ts`). The session ref in the path IS the credential, + * so these tests stand the whole surface up the way production does: an + * in-proc `Bun.serve` target answering on plane loopback (a client that + * bypassed the lane would still connect, so every byte assertion + * rides the fake node's COUNT, not just the response), and the REAL broker + * over faked wire seams, exactly like `preview-broker.test.ts`. + * + * The HTTP-side requests drive `createApp()`, the production composition, + * through `app.handle`, so an unmounted proxy cannot pass this file while a + * module-level unit test would; the WS refusals probe the same mounted app + * over a raw socket (a rejected upgrade never becomes a WebSocket client + * event, so the answer is an HTTP status line), and the tunnels run against + * the real listener. + */ + +const NODE = "n-pv"; +const PANE = "p-pv-pane"; +const USER = "u-pv"; +const DOC_ORIGIN = "http://203.0.113.77:3080"; +const PLUGIN = "pv-proxy-test"; + +/* ------------------------------------------------------------------ */ +/* the fake node */ +/* ------------------------------------------------------------------ */ + +/** + * A ~50-line node: it acks every `preview_open` (the broker's probe open and + * each lane open ride the SAME wire command, so the fake keys "dialed" on the + * first `in` byte, which only a real lane ever sends), pipes each lane's `in` + * frames to a fresh loopback socket pointed at the port the open named, + * streams the target's replies back as `out` frames through the real broker, + * and volunteers `target-gone` when a target socket dies. It COUNTS every + * byte it writes toward a target. That count is the lane-bypass guard: a + * proxy that dialed plane loopback directly would still satisfy a naive + * byte-equality check while leaving this at zero. + */ +interface FakeNode { + piped: number; + /** Every ref this fake served a lane for (lanes that actually sent bytes). */ + dialOrder: string[]; + /** Lane closes that reached this fake AFTER the lane did work (probe closes excluded). */ + closedLanes: { ref: string; reason: string }[]; + send(nodeId: string, cmd: NodeCommandBody): Promise; + linkSend(nodeId: string, frame: PreviewDataFrame): void; + /** Ports answer: the display screen is reported only when the test asks. */ + reportScreenRfbPort: number | null; +} + +function makeFakeNode(): FakeNode { + const lanes = new Map(); + const fake: FakeNode = { + piped: 0, + dialOrder: [], + closedLanes: [], + reportScreenRfbPort: null, + async send(_nodeId, cmd) { + if (cmd.type === "preview_open") { + lanes.set(cmd.ref, { port: cmd.port, sock: null, held: [] }); + return { ref: cmd.ref, pending: true }; + } + if (cmd.type === "preview_close") { + const lane = lanes.get(cmd.ref); + if (lane) { + lane.sock?.destroy(); + lanes.delete(cmd.ref); + if (lane.sock) fake.closedLanes.push({ ref: cmd.ref, reason: cmd.reason }); + } + return { ref: cmd.ref, closed: true }; + } + if (cmd.type === "preview_ports_get") { + return { + ports: [], + cdpPort: null, + screen: fake.reportScreenRfbPort + ? { display: ":7", viewer: { protocol: "vnc", port: fake.reportScreenRfbPort } } + : null, + }; + } + throw new Error(`pv proxy fake node: unexpected command ${cmd.type}`); + }, + linkSend(_nodeId, frame) { + if (frame.type !== "preview_data" || frame.dir !== "in") return; + const lane = lanes.get(frame.ref); + if (!lane) return; + const bytes = Buffer.from(frame.blob, "base64"); + if (bytes.byteLength === 0) return; + fake.piped += bytes.byteLength; + if (!lane.sock) { + fake.dialOrder.push(frame.ref); + lane.sock = net.connect(lane.port, "127.0.0.1", () => { + for (const h of lane.held.splice(0)) lane.sock?.write(h); + }); + lane.sock.on("data", (d: Buffer) => { + getPreviewBroker().onNodeLaneFrame(NODE, { + type: "preview_data", + ref: frame.ref, + dir: "out", + blob: d.toString("base64"), + }); + }); + lane.sock.on("close", () => { + if (lanes.delete(frame.ref)) { + void getPreviewBroker().onNodeReportedClose(NODE, frame.ref, "target-gone"); + } + }); + lane.sock.on("error", () => lane.sock?.destroy()); + } + if (lane.sock.connecting || !lane.sock.writable) { + lane.held.push(bytes); + return; + } + lane.sock.write(bytes); + }, + }; + return fake; +} + +/* ------------------------------------------------------------------ */ +/* the in-proc target */ +/* ------------------------------------------------------------------ */ + +/** What the target last saw, per lane: the assertions read this, not a guess. */ +interface TargetSeen { + method: string; + url: string; + host: string | null; + cookie: string | null; + identity: (string | null)[]; + body: string; +} +let lastSeen: TargetSeen = { method: "", url: "", host: null, cookie: null, identity: [], body: "" }; +let targetPort = 0; +let target: Bun.Server<{ path: string }> | undefined; + +/** Raw byte-echo TCP target for `raw` mode (an x11vnc stand-in). */ +let rfbPort = 0; +const rfbLog: Buffer[] = []; +const rfbAcceptList: net.Socket[] = []; +let rfbServer: net.Server | undefined; +/** TCP target that accepts and NEVER answers: stalls the http-mode handshake. */ +let silentPort = 0; +const silentSockets: net.Socket[] = []; +let silentServer: net.Server | undefined; + +beforeAll(async () => { + await ensureMigratedTestDb(); + target = Bun.serve<{ path: string }>({ + port: 0, + hostname: "127.0.0.1", + async fetch(req, server) { + const url = new URL(req.url); + if (url.pathname === "/hmr" || url.pathname.startsWith("/hmr")) { + // The /hmr-cookie path answers the 101 with a Set-Cookie: the + // upgrade-answer jar leg needs a target that sets one. + const upgraded = + url.pathname === "/hmr-cookie" + ? server.upgrade(req, { + data: { path: url.pathname }, + headers: { "set-cookie": "hsid=upgraded; Path=/deep; HttpOnly" }, + }) + : server.upgrade(req, { data: { path: url.pathname } }); + if (upgraded) return undefined; + return new Response("upgrade refused", { status: 400 }); + } + lastSeen = { + method: req.method, + url: url.pathname + url.search, + host: req.headers.get("host"), + cookie: req.headers.get("cookie"), + identity: [ + "authorization", + "x-api-key", + "cf-access-jwt-assertion", + "x-auth-request-user", + "x-forwarded-user", + "forwarded", + ].map((name) => req.headers.get(name)), + body: req.method === "GET" || req.method === "HEAD" ? "" : await req.text(), + }; + if (url.pathname === "/echo") { + return Response.json(lastSeen); + } + if (url.pathname === "/cookie") { + return new Response("ok", { + headers: [ + ["set-cookie", "sid=9; Path=/deep; HttpOnly; Max-Age=60"], + ["set-cookie", "theme=dark; Path=/app; Domain=example.com; Secure"], + ["clear-site-data", '"cookies", "storage"'], + ["www-authenticate", 'Basic realm="dashboard"'], + ["strict-transport-security", "max-age=31536000"], + ["alt-svc", 'h3=":443"'], + ["nel", '{"report_to":"target","max_age":86400}'], + ["report-to", '{"group":"target","max_age":86400,"endpoints":[{"url":"https://collector.example"}]}'], + ["reporting-endpoints", 'target="https://collector.example"'], + ["accept-ch", "Sec-CH-UA-Platform-Version"], + ["critical-ch", "Sec-CH-UA-Platform-Version"], + ], + }); + } + if (url.pathname === "/csp") { + return new Response("ok", { + headers: [ + ["content-security-policy", "default-src 'self'; frame-ancestors 'none'; img-src *"], + [ + "content-security-policy-report-only", + "default-src 'self'; frame-ancestors https://evil; script-src 'self'", + ], + ], + }); + } + if (url.pathname === "/redirect") { + return new Response(null, { status: 302, headers: { location: "/foo?x=1" } }); + } + return new Response("hello-target", { status: 200 }); + }, + websocket: { + open() {}, + message(ws, msg) { + // Echo verbatim: Bun hands bytes as Buffer and text as string, and + // re-sending either preserves the frame type. + ws.send(msg as string | Uint8Array); + }, + close() {}, + }, + }); + targetPort = target.port as number; + + rfbServer = net.createServer((sock) => { + rfbAcceptList.push(sock); + rfbLog.length = 0; + sock.on("data", (d: Buffer) => { + rfbLog.push(Buffer.from(d)); + sock.write(Buffer.from(d).reverse()); // echo reversed: direction is visible in the bytes + }); + }); + await new Promise((res, rej) => { + if (!rfbServer) return rej(new Error("no rfb server")); + rfbServer.listen(0, "127.0.0.1", res); + }); + rfbPort = (rfbServer.address() as net.AddressInfo).port; + + silentServer = net.createServer((sock) => { + silentSockets.push(sock); // accepted, then silence: the RFB/dev handshake stalls + }); + await new Promise((res, rej) => { + if (!silentServer) return rej(new Error("no silent server")); + silentServer.listen(0, "127.0.0.1", res); + }); + silentPort = (silentServer.address() as net.AddressInfo).port; +}); + +afterAll(async () => { + target?.stop(); + rfbServer?.close(); + for (const s of silentSockets) s.destroy(); + silentServer?.close(); + originRegistry().clearPlugin(PLUGIN); + shutdownPreviewBrokerForTests(); +}); + +/* ------------------------------------------------------------------ */ +/* per-test broker + session */ +/* ------------------------------------------------------------------ */ + +let fake: FakeNode; + +function freshBroker(nextFake: FakeNode): void { + resetForTests({ send: nextFake.send.bind(nextFake), linkSend: nextFake.linkSend.bind(nextFake) }); +} + +async function openSession(port: number): Promise { + const { ref } = await getPreviewBroker().openForward({ viewerUserId: USER, paneId: PANE, nodeId: NODE, port }); + return ref; +} + +beforeEach(() => { + fake = makeFakeNode(); + freshBroker(fake); + lastSeen = { method: "", url: "", host: null, cookie: null, identity: [], body: "" }; +}); + +afterEach(() => { + originRegistry().clearPlugin(PLUGIN); + for (const s of rfbAcceptList) s.destroy(); + rfbAcceptList.length = 0; + for (const s of silentSockets) s.destroy(); + silentSockets.length = 0; +}); + +/** Drive one request through the PRODUCTION composition. */ +async function proxyFetch(path: string, init?: RequestInit): Promise { + const app = createApp(); + return app.handle(new Request(`http://localhost:3080${path}`, init)); +} + +/* ------------------------------------------------------------------ */ +/* raw-socket upgrade probe */ +/* ------------------------------------------------------------------ */ + +interface UpgradeProbe { + status: number; + raw: string; +} + +/** + * Writes a WebSocket upgrade by hand. A REFUSED upgrade is an HTTP answer the + * browser's WebSocket client only sees as a failed connection, so the status + * line and body can only be read off the socket. + */ +async function probeUpgrade(port: number, path: string, origin?: string): Promise { + return new Promise((resolve, reject) => { + const sock = net.connect(port, "127.0.0.1", () => { + sock.write( + `GET ${path} HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + (origin ? `Origin: ${origin}\r\n` : "") + + "\r\n", + ); + }); + let raw = ""; + let settle: ReturnType | undefined; + const timeout = setTimeout(() => { + sock.destroy(); + reject(new Error(`upgrade probe timed out; saw: ${raw.slice(0, 200)}`)); + }, 4_000); + sock.on("data", (d) => { + raw += d.toString("latin1"); + // Status answers complete when the head is in and the body has settled; + // 101 has no body, refusals get a beat to flush theirs. + if (raw.includes("101 Switching Protocols")) { + clearTimeout(timeout); + sock.destroy(); + resolve({ status: 101, raw }); + } else if (raw.includes("\r\n\r\n") && !settle) { + settle = setTimeout(() => { + clearTimeout(timeout); + sock.destroy(); + resolve({ status: Number(raw.match(/^HTTP\/1\.\d (\d{3})/)?.[1] ?? 0), raw }); + }, 150); + } + }); + sock.on("error", (err) => { + clearTimeout(timeout); + if (settle) clearTimeout(settle); + reject(err); + }); + }); +} + +/* ------------------------------------------------------------------ */ +/* the HTTP rewrite half */ +/* ------------------------------------------------------------------ */ + +describe("pv-http: the same-origin rewrite", () => { + it("preserves escaped characters in the target request path", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/asset%20name%2Fpart?name=a%20b`); + expect(res.status).toBe(200); + await res.text(); + expect(lastSeen?.url).toBe("/asset%20name%2Fpart?name=a%20b"); + }); + + it("keeps target cookies out of the dashboard browser and isolates preview documents", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/cookie`); + expect(res.headers.getSetCookie()).toEqual([]); + for (const name of [ + "clear-site-data", + "www-authenticate", + "strict-transport-security", + "alt-svc", + "nel", + "report-to", + "reporting-endpoints", + "accept-ch", + "critical-ch", + ]) { + expect(res.headers.get(name)).toBeNull(); + } + expect(res.headers.get("content-security-policy")).toContain("sandbox allow-scripts"); + expect(res.headers.get("content-security-policy")).not.toContain("allow-same-origin"); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get("access-control-allow-credentials")).toBe("false"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.text()).toBe("ok"); + }); + + it("does not forward dashboard or edge authentication to the dev server", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`, { + headers: { + authorization: "Bearer dashboard-secret", + "x-api-key": "dashboard-key", + "cf-access-jwt-assertion": "edge-jwt", + "x-auth-request-user": "private-user", + "x-forwarded-user": "private-user", + forwarded: "for=192.0.2.1", + }, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as TargetSeen).identity).toEqual([null, null, null, null, null, null]); + }); + + it("answers opaque-origin preflights without dialing the target", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`, { + method: "OPTIONS", + headers: { + origin: "null", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-headers")).toBe("content-type"); + expect(["*", "null"]).toContain(res.headers.get("access-control-allow-origin") ?? ""); + expect(fake.dialOrder).toHaveLength(0); + }); + + it("never forwards the browser's Cookie; the jarred cookie rides a fresh lane's request", async () => { + const ref = await openSession(targetPort); + // Request one: the browser's own cookie must NOT reach the target, and + // the target's Set-Cookie lands in the jar. + const first = await proxyFetch(`/pv/${ref}/echo`, { headers: { cookie: "plane=supersecret" } }); + expect(first.status).toBe(200); + expect(((await first.json()) as TargetSeen).cookie).toBeNull(); + const setOne = await proxyFetch(`/pv/${ref}/cookie`); + expect(setOne.headers.getSetCookie()).toEqual([]); + // Request two on the SAME session: no Cookie header, but the jar carries + // the pairs. And it rode a FRESH lane: each of the four proxy requests + // dialed its own, so a lane quietly reused between requests fails here + // even if the jar itself works. + const second = await proxyFetch(`/pv/${ref}/echo`); + const seen = (await second.json()) as { cookie: string | null }; + expect(seen.cookie).toBe("sid=9; theme=dark"); + // A DIFFERENT session's proxy path carries nothing. + const ref2 = await openSession(targetPort); + const foreign = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as { cookie: string | null }; + expect(foreign.cookie).toBeNull(); + // echo, cookie, jar-echo, foreign-echo: four requests, four distinct lanes. + expect(fake.dialOrder).toHaveLength(4); + expect(new Set(fake.dialOrder).size).toBe(4); + }); + + it("a session reopened after closeForPane starts with an empty jar", async () => { + const ref = await openSession(targetPort); + await proxyFetch(`/pv/${ref}/cookie`); + await getPreviewBroker().closeForPane(PANE, "pane-gone"); + // The dead session is now unaddressable, and the fresh one has a clean jar. + expect((await proxyFetch(`/pv/${ref}/echo`)).status).toBe(404); + const ref2 = await openSession(targetPort); + const seen = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as { cookie: string | null }; + expect(seen.cookie).toBeNull(); + }); + + it("sets Host to the loopback literal the node dials", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`); + expect(((await res.json()) as TargetSeen).host).toBe(`127.0.0.1:${targetPort}`); + }); + + it("carries the method and the body through", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/echo`, { method: "POST", body: "lane-payload" }); + const seen = (await res.json()) as TargetSeen; + expect(seen.method).toBe("POST"); + expect(seen.body).toBe("lane-payload"); + }); + + it("keeps two of three CSP directives and drops frame-ancestors from both flavors", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/csp`); + const csp = res.headers.get("content-security-policy") ?? ""; + const report = res.headers.get("content-security-policy-report-only") ?? ""; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("img-src *"); + expect(csp).toContain("frame-ancestors 'self'"); + expect(report).toContain("default-src 'self'"); + expect(report).toContain("script-src 'self'"); + expect(report).not.toContain("frame-ancestors"); + }); + + it("prefixes a relative Location with the session's proxy path", async () => { + const ref = await openSession(targetPort); + const res = await proxyFetch(`/pv/${ref}/redirect`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`/pv/${ref}/foo?x=1`); + }); + + it("answers an unknown session ref with a 404 that leaks nothing", async () => { + const ref = await openSession(targetPort); + const ghost = ref.replace(/^.{6}/, "000000"); + const res = await proxyFetch(`/pv/${ghost}/anything/at/all`); + expect(res.status).toBe(404); + const body = await res.text(); + expect(body).not.toContain(NODE); + expect(body).not.toContain(PANE); + expect(body).not.toContain(ref); + expect(body.toLowerCase()).not.toContain("pane"); + }); + + it("answers a lane-cap refusal with a 503 that names preview-lane-over-cap", async () => { + const ref = await openSession(targetPort); + // Fill the pane's pool outside the proxy. The per-pane lanes are live + // and awaited; the queue waiters can only resolve once a live lane + // closes, so they are PARKED rather than awaited (awaiting one would + // await the very cap condition this test asks the proxy about). A + // parked rejection is the broker's reset word, which teardown may raise; + // catching it is the test's job, not a swallow. + const held: string[] = []; + for (let i = 0; i < PREVIEW_FORWARD_SESSIONS_PER_PANE; i += 1) { + held.push((await getPreviewBroker().openLaneForConnection(ref)).laneRef); + } + const parked: Promise[] = []; + for (let i = 0; i < PREVIEW_LANE_QUEUE_MAX; i += 1) { + parked.push( + getPreviewBroker() + .openLaneForConnection(ref) + .then( + ({ laneRef }) => void held.push(laneRef), + () => undefined, + ), + ); + } + const res = await proxyFetch(`/pv/${ref}/echo`); + expect(res.status).toBe(503); + expect(await res.text()).toContain("preview-lane-over-cap"); + // Closing a live lane drains one waiter into a fresh lane, which the + // sweep sees because `held` grows while it iterates. Two passes plus + // the waiter join, so every minted lane is closed and no double-close + // error escapes. + const sweep = async (): Promise => { + for (const laneRef of held) { + await getPreviewBroker() + .closeLaneForConnection(laneRef, "viewer-left") + .catch(() => undefined); + } + }; + await sweep(); + await Promise.all(parked); + await sweep(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the WS half: gates (refusals are HTTP, read off a raw socket) */ +/* ------------------------------------------------------------------ */ + +describe("pv-ws: the upgrade gates", () => { + let proxyPort = 0; + let stop: (() => void) | undefined; + + beforeAll(async () => { + const app = createApp(); + app.listen({ port: 0, hostname: "127.0.0.1" }); + proxyPort = app.server?.port ?? 0; + if (!proxyPort) throw new Error("the proxy did not report a bound port"); + stop = () => app.server?.stop(); + }); + afterAll(() => stop?.()); + + it("refuses a cross-site Origin and passes the plane origin, an opaque origin, a registry origin, and a missing Origin", async () => { + const ref = await openSession(targetPort); + const planeOrigin = originRegistry().current()[0]; + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, "null")).status).toBe(101); + expect(await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, "http://203.0.113.66:9999")).toMatchObject({ status: 403 }); + originRegistry().setPluginOrigins(PLUGIN, [DOC_ORIGIN]); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, planeOrigin)).status).toBe(101); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`, DOC_ORIGIN)).status).toBe(101); + expect((await probeUpgrade(proxyPort, `/pv/${ref}/hmr`)).status).toBe(101); + }); + + it("refuses an unknown session ref before the socket exists", async () => { + const ref = await openSession(targetPort); + const probe = await probeUpgrade(proxyPort, `/pv/${ref.replace(/^.{6}/, "000000")}/hmr`); + expect(probe.status).toBe(404); + }); + + it("refuses a lane-cap overflow upgrade with a 503 naming preview-lane-over-cap", async () => { + const ref = await openSession(targetPort); + // Same fill as the HTTP cap leg: live lanes awaited, queue waiters + // parked (see the comment there for why awaiting one is awaiting the + // cap itself). + const held: string[] = []; + for (let i = 0; i < PREVIEW_FORWARD_SESSIONS_PER_PANE; i += 1) { + held.push((await getPreviewBroker().openLaneForConnection(ref)).laneRef); + } + const parked: Promise[] = []; + for (let i = 0; i < PREVIEW_LANE_QUEUE_MAX; i += 1) { + parked.push( + getPreviewBroker() + .openLaneForConnection(ref) + .then( + ({ laneRef }) => void held.push(laneRef), + () => undefined, + ), + ); + } + const probe = await probeUpgrade(proxyPort, `/pv/${ref}/hmr`); + expect(probe.status).toBe(503); + expect(probe.raw).toContain("preview-lane-over-cap"); + const sweep = async (): Promise => { + for (const laneRef of held) { + await getPreviewBroker() + .closeLaneForConnection(laneRef, "viewer-left") + .catch(() => undefined); + } + }; + await sweep(); + await Promise.all(parked); + await sweep(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* the WS half: tunnels */ +/* ------------------------------------------------------------------ */ + +describe("pv-ws: the tunnels", () => { + let proxyPort = 0; + let stop: (() => void) | undefined; + + beforeAll(async () => { + const app = createApp(); + app.listen({ port: 0, hostname: "127.0.0.1" }); + proxyPort = app.server?.port ?? 0; + if (!proxyPort) throw new Error("the proxy did not report a bound port"); + stop = () => app.server?.stop(); + }); + afterAll(() => stop?.()); + + it("round-trips binary and text byte-exactly in http mode, all through the lane", async () => { + const ref = await openSession(targetPort); + const payload = Buffer.from([0, 7, 128, 255, 61, 10]); + const before = fake.piped; + const got = await new Promise<{ binary: number[]; text: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + ws.binaryType = "arraybuffer"; + const out: { binary?: number[]; text?: string } = {}; + ws.onopen = () => { + ws.send(payload); + ws.send("plain text, not JSON"); + }; + ws.onmessage = (e) => { + if (e.data instanceof ArrayBuffer) out.binary = [...new Uint8Array(e.data)]; + else out.text = String(e.data); + if (out.binary && out.text) { + ws.close(); + resolve(out as { binary: number[]; text: string }); + } + }; + ws.onerror = (e) => reject(new Error(`tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error(`tunnel stalled; got ${JSON.stringify(out)}`)), 5_000); + }); + expect(got.binary).toEqual([...payload]); + expect(got.text).toBe("plain text, not JSON"); + // The bypass guard: the handshake AND both payloads rode the fake node. + // The in-proc target answers on plane loopback, so a client that had + // dialed around the lane would look identical while this sat at `before`. + expect(fake.piped).toBeGreaterThan(before); + }); + + it("carries Elysia-coerced text frames as text: primitives ride their string form, JSON re-stringifies", async () => { + const ref = await openSession(targetPort); + const frames = [ + '{ plain: "text" }', + 'spaced {"a": 1} json', + "123", + "true", + "null", + '{ "type": "update", "updates": [] }', + '{ "buffer": 1, "byteOffset": 0 }', + ]; + const got = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + const back: string[] = []; + ws.onopen = () => { + for (const f of frames) ws.send(f); + }; + ws.onmessage = (e) => { + back.push(String(e.data)); + if (back.length === frames.length) { + ws.close(); + resolve(back); + } + }; + ws.onerror = (e) => reject(new Error(`coerced-frame tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error(`coerced-frame tunnel stalled; got ${JSON.stringify(back)}`)), 5_000); + }); + // Text that merely LOOKS JSONed but is not valid JSON stays a string at + // the handler and rides byte-exact, whitespace and all: the brace-object + // with unquoted keys and the word-wrapped JSON fragment both arrive + // exactly as written. + expect(got[0]).toBe('{ plain: "text" }'); + expect(got[1]).toBe('spaced {"a": 1} json'); + // Elysia's parser hands "123"/"true"/"null" over as the number/boolean/ + // null the JSON coercion made them. They must still arrive as the TEXT + // the browser wrote: the target sees bare digits and words. + expect(got[2]).toBe("123"); + expect(got[3]).toBe("true"); + expect(got[4]).toBe("null"); + // A frame that parses as real JSON arrives re-parsed; the promise is + // semantics, so pin the compact re-stringification. A parser change + // that starts delivering raw text still passes; one that eats the + // frame does not. + expect(got[5]).toBe(JSON.stringify({ type: "update", updates: [] })); + // A JSON object whose content happens to pair the key names `buffer` and + // `byteOffset` is still a plain object, not a buffer view: it rides + // re-stringified text. A duck-check of those key names would read the + // JSON values as view offsets, throw, and destroy the frame. + expect(got[6]).toBe(JSON.stringify({ buffer: 1, byteOffset: 0 })); + }); + + it("rides an upgrade-response Set-Cookie into the jar and onto the next HTTP request", async () => { + const ref = await openSession(targetPort); + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr-cookie`); + // The browser `onopen` is the PLANE's answer, not the lane's: the + // redial handshake is still in flight. Closing here would tear the + // duplex down before the 101 (and its cookie) lands. One echoed + // frame proves the redial completed, which is also the moment the + // jar took the cookie. + ws.onopen = () => ws.send("ping"); + ws.onmessage = () => { + ws.onclose = () => resolve(); + ws.close(); + }; + ws.onerror = (e) => reject(new Error(`cookie upgrade failed: ${String(e)}`)); + setTimeout(() => reject(new Error("cookie upgrade stalled")), 5_000); + }); + // Spec §6's jar sentence is unqualified about WHICH lane sets a cookie: + // the WS redial's upgrade answer rides the session like an HTTP answer's. + const seen = (await (await proxyFetch(`/pv/${ref}/echo`)).json()) as TargetSeen; + expect(seen.cookie).toContain("hsid=upgraded"); + // A different session's proxy path carries nothing. + const ref2 = await openSession(targetPort); + const other = (await (await proxyFetch(`/pv/${ref2}/echo`)).json()) as TargetSeen; + expect(other.cookie).toBeNull(); + }); + + it("pumps raw mode verbatim, answers as binary frames, and carries target-gone to the close event", async () => { + fake.reportScreenRfbPort = rfbPort; + const ref = await openSession(rfbPort); // the registered screen port => raw + const pipedBefore = fake.piped; + expect(getPreviewBroker().sessionFor(ref)?.mode).toBe("raw"); + const hello = Buffer.from([0x52, 0x46, 0x42, 0x00]); // "RFB\x00" + const closed = await new Promise<{ code: number; reason: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/`); + ws.binaryType = "arraybuffer"; + ws.onopen = () => ws.send(hello); + ws.onmessage = (e) => { + // The raw target echoed REVERSED bytes: the payload must come back + // intact, not as an HTTP-reformatted anything. + const bytes = new Uint8Array(e.data as ArrayBuffer); + expect([...bytes]).toEqual([...hello].reverse()); + // Kill the target's side; the fake node reports target-gone. + for (const s of rfbAcceptList) s.destroy(); + }; + ws.onclose = (e) => resolve({ code: e.code, reason: e.reason }); + ws.onerror = (e) => reject(new Error(`raw tunnel failed: ${String(e)}`)); + setTimeout(() => reject(new Error("raw tunnel stalled")), 5_000); + }); + expect(closed.code).toBe(1011); + expect(closed.reason).toBe("target-gone"); + // No HTTP upgrade ever reached the raw target: whatever it read starts + // with the RFB payload, never "GET ". + const seen = Buffer.concat(rfbLog).toString("latin1"); + expect(seen.startsWith("GET ")).toBe(false); + expect(seen).toContain("RFB"); + // The bypass guard, same doctrine as the http-mode leg: the rfb target + // answers on plane loopback, so only the pump counter proves the bytes + // rode the fake node rather than a direct dial. + expect(fake.piped).toBeGreaterThan(pipedBefore); + }); + + it("closes the lane viewer-left when the viewer's socket goes first", async () => { + const ref = await openSession(targetPort); + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/pv/${ref}/hmr`); + ws.onopen = () => ws.close(); + ws.onclose = () => resolve(); + ws.onerror = (e) => reject(new Error(String(e))); + }); + // The lane close rides the wire with the viewer-left reason. + await new Promise((res) => setTimeout(res, 200)); + expect(fake.closedLanes.some((c) => c.reason === "viewer-left")).toBe(true); + }); + + it("refuses slow links: bytes past preAckMaxBytes before the handshake closes slow-link", async () => { + // The seam: a small pre-ack budget so the boundary is drivable (the same + // doctrine as Task 5's laneBufferMaxBytes). Production default is + // PREVIEW_LANE_BUFFER_MAX_BYTES. + expect(PREVIEW_LANE_BUFFER_MAX_BYTES).toBe(1_048_576); + fake.reportScreenRfbPort = null; + const ref = await openSession(silentPort); + const app = new Elysia() + .use(errorHandlerPlugin) + .use(pvHttpRoute) + .use(previewWsRoute({ preAckMaxBytes: 64 })); + app.listen({ port: 0, hostname: "127.0.0.1" }); + const port = app.server?.port ?? 0; + if (!port) throw new Error("the slow-link proxy did not report a bound port"); + try { + const closed = await new Promise<{ code: number; reason: string }>((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/pv/${ref}/hmr`); + ws.binaryType = "arraybuffer"; + ws.onopen = () => { + // The silent target never answers the lane-side handshake: pump + // past the budget and wait for the named death. + const chunk = Buffer.alloc(32, 0x41); + for (let i = 0; i < 10; i += 1) ws.send(Buffer.from(chunk)); + }; + ws.onclose = (e) => resolve({ code: e.code, reason: e.reason }); + ws.onerror = (e) => reject(new Error(String(e))); + setTimeout(() => reject(new Error("slow-link never fired")), 5_000); + }); + expect(closed.code).toBe(1011); + expect(closed.reason).toBe("slow-link"); + } finally { + app.server?.stop(); + } + }); +}); diff --git a/apps/server/api/src/preview/lane-duplex.ts b/apps/server/api/src/preview/lane-duplex.ts new file mode 100644 index 000000000..06efb6e72 --- /dev/null +++ b/apps/server/api/src/preview/lane-duplex.ts @@ -0,0 +1,82 @@ +import { Duplex } from "node:stream"; +import { PREVIEW_LANE_BUFFER_MAX_BYTES, type PreviewCloseReason } from "@internal/subshell-protocol"; + +/** One delivery from a broker lane: bytes, or the named reason the lane died. */ +export type LaneEvent = { bytes: Uint8Array } | { closed: PreviewCloseReason }; + +/** What a LaneDuplex needs to stand in for a TCP socket on one broker lane. */ +export interface LaneDuplexDeps { + /** Ship bytes toward the pane-side target (the proxy: `sendLaneBytes`). */ + writeOut(bytes: Uint8Array): void; + /** Receive lane deliveries; returns the unsubscriber. */ + onLane(cb: (event: LaneEvent) => void): () => void; +} + +/** + * A Node `Duplex` whose far end is a broker preview lane rather than a TCP + * socket, so a stream consumer can speak over a lane. It would also be the + * socket a `createConnection`-style transport plugged in, except Bun's + * `node:http` client silently IGNORES that option (measured on 1.4.2: the + * request dials `host:port` itself with the option set, so the duplex never + * sees a byte); the proxy therefore speaks its protocols over this class + * directly (`lane-http.ts`, `lane-ws-client.ts`). + * + * The broker's lane delivery is a firehose (a Set of subscribers, no + * pause/resume), so unread bytes have a hard cap here as well as on the node. + * A slow browser must not turn node traffic into unbounded server memory. A lane `closed` + * ends the read side with `push(null)` (FIN semantics), never an `Error`: + * the reason WORD is what the proxy needs, and it reads it from + * `closedReason`, not from an exception that would flatten every death into + * one error. + */ +export class LaneDuplex extends Duplex { + /** The lane's close reason once the lane has closed; null while it lives. */ + closedReason: PreviewCloseReason | null = null; + + private readonly deps: LaneDuplexDeps; + private readonly unsubscribe: () => void; + + constructor(deps: LaneDuplexDeps) { + // Half-open stays closed: when the lane reports `closed` the read side + // ends, and the writable side ending with it (the default) is what makes + // the stream's `close` event fire, which is how `lane-wire.ts` and + // `streamBody` learn the far end is gone. + super({ allowHalfOpen: false }); + this.deps = deps; + this.unsubscribe = deps.onLane((event) => { + if ("closed" in event) { + this.closedReason = event.closed; + this.push(null); + return; + } + if (this.readableLength + event.bytes.byteLength > PREVIEW_LANE_BUFFER_MAX_BYTES) { + this.closedReason = "slow-link"; + this.destroy(); + return; + } + this.push(Buffer.from(event.bytes)); + }); + } + + // Public because `node:stream`'s types declare these overrides public; a + // `protected` one fails Liskov substitution even though nothing outside + // a stream should ever call them. + _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + try { + this.deps.writeOut(chunk); + callback(); + } catch (err) { + callback(err instanceof Error ? err : new Error(String(err))); + } + } + + _read(_size: number): void { + // The firehose above pushes whenever the lane delivers; `_read` exists + // only because Duplex requires one. + } + + _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this.unsubscribe(); + callback(error); + } +} diff --git a/apps/server/api/src/preview/lane-http.ts b/apps/server/api/src/preview/lane-http.ts new file mode 100644 index 000000000..88490be99 --- /dev/null +++ b/apps/server/api/src/preview/lane-http.ts @@ -0,0 +1,267 @@ +import type { Duplex } from "node:stream"; +import { PREVIEW_LANE_BUFFER_MAX_BYTES } from "@internal/subshell-protocol"; +import { LaneClosedError, LaneProtocolError, readLaneHead } from "@/preview/lane-wire.js"; + +/** + * One HTTP/1.1 request over a preview lane duplex, and its streamed answer + * (spec 2026-10-21 §6's HTTP half). + * + * This is hand-spoken because Bun's `node:http` client ignores + * `createConnection` (measured on 1.4.2 in three shapes: top-level option, + * `agent: false`, and through the `ws` client's socket override; the request + * dials `host:port` itself every time). A proxy whose transport is silently + * bypassed is worse than one that fails loudly, so the lane gets bytes + * written to it directly. The lane IS a TCP stream (the node pumps it to the + * target's loopback socket), so the wire format spoken here is exactly what + * a vhost-aware dev server expects to see. + */ + +export interface LaneRequestInit { + method: string; + /** Origin form including the query, already stripped of the proxy prefix. */ + path: string; + /** + * Header pairs, already transformed (no `host`, no `content-length`, no + * `transfer-encoding`, no `connection` family, no browser `cookie`: this + * function frames the body with its own `transfer-encoding: chunked` so + * streaming bodies cannot disagree with a content length the proxy cannot + * verify against the browser's stream. + */ + headers: [string, string][]; + /** The browser's body stream, chunk-encoded toward the target; null for none. */ + body: ReadableStream | null; +} + +export interface LaneResponse { + status: number; + statusText: string; + /** Raw target answer, NOT hop-by-hop filtered and NOT rewritten: pv-http does that. */ + headers: [string, string][]; + /** Decoded body (chunked folded out); null when the request was HEAD. */ + body: ReadableStream | null; +} + +/** Serialize the head, then pump the body chunk-encoded; the head is one write. */ +function writeRequest(duplex: Duplex, init: LaneRequestInit): void { + const lines = [`${init.method} ${init.path} HTTP/1.1`]; + for (const [name, value] of init.headers) lines.push(`${name}: ${value}`); + if (init.body) lines.push("transfer-encoding: chunked"); + duplex.write(Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1")); + const body = init.body; + if (!body) return; + const pump = async (): Promise => { + const reader = body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (duplex.destroyed) { + await reader.cancel(); + return; + } + if (value.byteLength === 0) continue; + duplex.write(Buffer.from(`${value.byteLength.toString(16)}\r\n`, "latin1")); + duplex.write(Buffer.from(value)); + duplex.write(Buffer.from("\r\n", "latin1")); + } + } catch { + // Never turn an interrupted upload into a valid, shorter request. + duplex.destroy(); + return; + } finally { + reader.releaseLock(); + } + duplex.write(Buffer.from("0\r\n\r\n", "latin1")); + }; + void pump(); +} + +/** + * Drive the body-framing reader and resolve as soon as the head is known. + * + * Framing rules, in the order a conforming HTTP/1.1 reader applies them: a + * `chunked` transfer-encoding wins; else `content-length` bytes; else the + * body runs to EOF. A 204/304 has no body whatever the headers say, and a + * HEAD answer never has one. + */ +function streamBody( + duplex: Duplex, + headers: [string, string][], + tail: Buffer, + onDone: () => void, +): ReadableStream { + let mode: "chunked" | "length" | "eof"; + let remaining = 0; + const transfer = headers.find(([n]) => n === "transfer-encoding")?.[1]?.toLowerCase() ?? ""; + const lengthRaw = headers.find(([n]) => n === "content-length")?.[1]; + if (transfer.includes("chunked")) mode = "chunked"; + else if (lengthRaw !== undefined && /^\d+$/.test(lengthRaw)) { + mode = "length"; + remaining = Number(lengthRaw); + } else mode = "eof"; // the close-delimited body of a connection: close answer + + let acc: Buffer = tail; + let done = false; + let chunkEnd = false; + let chunkSize: number | null = null; // current chunked part; null = awaiting size line + let controller: ReadableStreamDefaultController | undefined; + + const finish = (): void => { + if (done) return; + done = true; + duplex.removeListener("data", onData); + duplex.removeListener("close", onClose); + onDone(); + try { + controller?.close(); + } catch { + /* consumer already gone */ + } + }; + const emit = (bytes: Buffer): void => { + if (bytes.byteLength > (controller?.desiredSize ?? 0)) { + fail("preview response exceeded the slow-reader buffer limit"); + return; + } + try { + controller?.enqueue(new Uint8Array(bytes)); + } catch { + /* consumer already gone; the lane close path will still run */ + } + }; + const onData = (incoming: Buffer): void => { + acc = acc.byteLength === 0 ? incoming : Buffer.concat([acc, incoming]); + while (!done) { + if (mode === "eof") { + // Read to EOF means there is no framing: hand everything to the + // consumer and let the lane close be the terminator. + if (acc.byteLength > 0) { + emit(acc); + acc = Buffer.alloc(0); + } + return; + } + if (mode === "length") { + if (remaining === 0) { + finish(); + return; + } + const take = Math.min(remaining, acc.byteLength); + if (take > 0) { + emit(acc.subarray(0, take)); + acc = acc.subarray(take); + remaining -= take; + } + if (remaining === 0) finish(); + return; + } + // Stream chunk data as it arrives; a declared chunk size never reserves memory. + if (chunkEnd) { + if (acc.byteLength < 2) return; + if (acc[0] !== 13 || acc[1] !== 10) { + fail("malformed chunk terminator"); + return; + } + acc = acc.subarray(2); + chunkEnd = false; + } + if (chunkSize === null) { + const eol = acc.indexOf("\r\n"); + if (eol > 8192 || (eol === -1 && acc.byteLength > 8192)) { + fail("chunk size line exceeds 8 KiB"); + return; + } + if (eol === -1) return; + const sizeLine = acc.subarray(0, eol).toString("latin1").split(";")[0] ?? ""; + const size = Number.parseInt(sizeLine.trim(), 16); + acc = acc.subarray(eol + 2); + if (!/^[0-9a-f]+$/i.test(sizeLine) || !Number.isSafeInteger(size)) { + fail("malformed chunk size"); + return; + } + if (size === 0) { + // Trailers until the blank line; consuming them header-by-header + // would need a second parser, and every server we proxy sends bare + // `0\r\n\r\n`. Take the blank line and call it done; any bytes past + // it belong to a pipelined answer this one-shot lane will never read. + finish(); + return; + } + chunkSize = size; + continue; + } + const take = Math.min(chunkSize, acc.byteLength); + if (take > 0) emit(acc.subarray(0, take)); + acc = acc.subarray(take); + chunkSize -= take; + if (chunkSize > 0) return; + chunkSize = null; + chunkEnd = true; + } + }; + const onClose = (): void => { + // A truncated body is an error, not a complete response. This never + // retries the request: a POST may already have changed the target. + acc = Buffer.alloc(0); + if (mode !== "eof" && !done) fail("preview response ended before its body was complete"); + else finish(); + }; + const fail = (message: string): void => { + if (done) return; + done = true; + duplex.removeListener("data", onData); + duplex.removeListener("close", onClose); + onDone(); + try { + controller?.error(new LaneProtocolError(message)); + } catch { + /* consumer already gone */ + } + }; + duplex.on("data", onData); + duplex.on("close", onClose); + const stream = new ReadableStream( + { + start(c) { + controller = c; + }, + cancel() { + acc = Buffer.alloc(0); + finish(); + }, + }, + { highWaterMark: PREVIEW_LANE_BUFFER_MAX_BYTES, size: (bytes) => bytes?.byteLength ?? 0 }, + ); + if (mode === "length" && remaining === 0) finish(); + // The head reader handed us bytes past the head: drive the machine once so + // they are not stranded in `acc` until the next arrival. + else if (acc.byteLength > 0) onData(Buffer.alloc(0)); + return stream; +} + +/** + * Send one request on a lane duplex and resolve on the response head. + * `requestIsHead` answers a HEAD request with the head only: the target's + * framing would describe a body this response must not carry, and a 204/304 + * has no body whatever its headers say. `onBodyDone` runs once the body + * reader finishes, fails, or sees the lane die, and once immediately for the + * no-body cases: the caller owns lane release and must see exactly one call + * for every request. + */ +export async function sendLaneRequest( + duplex: Duplex, + init: LaneRequestInit, + requestIsHead: boolean, + onBodyDone: () => void, +): Promise { + writeRequest(duplex, init); + const head = await readLaneHead(duplex); + const status = Number(head.statusLine.slice(9, 12)); + const statusText = head.statusLine.slice(13).trim(); + const noBody = requestIsHead || status === 204 || status === 304; + const body = noBody ? null : streamBody(duplex, head.headers, head.tail, onBodyDone); + if (noBody) onBodyDone(); + return { status, statusText, headers: head.headers, body }; +} + +export { LaneClosedError, LaneProtocolError }; diff --git a/apps/server/api/src/preview/lane-wire.ts b/apps/server/api/src/preview/lane-wire.ts new file mode 100644 index 000000000..e597ec75b --- /dev/null +++ b/apps/server/api/src/preview/lane-wire.ts @@ -0,0 +1,136 @@ +import type { Duplex } from "node:stream"; +import type { PreviewCloseReason } from "@internal/subshell-protocol"; + +/** + * The shared HTTP/1.1 head reader for the lane clients (`lane-http.ts`, + * `lane-ws-client.ts`). + * + * On Bun 1.4.2 `node:http`'s `createConnection` option is IGNORED (measured: + * with `agent: false`, with an agent carrying `createConnection`, and through + * `ws`'s client, the request dials `host:port` itself every time, so + * `LaneDuplex` never sees a byte). The lane therefore gets hand-spoken + * HTTP/1.1: writing a request head is trivial; reading one back is this + * file. It resolves on the first complete head (status line + headers + the + * blank line) and hands the caller any bytes that arrived past the head, + * because a WebSocket upgrade response and its first frames can coalesce + * into one packet. Informational 1xx heads (the `103 Early Hints` a dev + * server may send) are consumed and the read continues. + */ +export interface LaneHead { + /** Raw status line, e.g. `HTTP/1.1 200 OK`. */ + statusLine: string; + /** Header name is lowercased; multi-values kept as separate entries. */ + headers: [string, string][]; + /** Bytes read past the head's blank line, ready to hand to a body reader. */ + tail: Buffer; +} + +/** The lane died before an answer head arrived. */ +export class LaneClosedError extends Error { + constructor(readonly reason: PreviewCloseReason | null) { + super(reason ? `the lane closed (${reason})` : "the lane closed"); + this.name = "LaneClosedError"; + } +} + +/** The far end wrote bytes that are not a usable HTTP/1.1 head. */ +export class LaneProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "LaneProtocolError"; + } +} + +/** Max head bytes before the answer is refused (dev-server heads are far under it). */ +const HEAD_MAX = 64 * 1024; + +/** + * Read one (or more, skipping 1xx) response head from a lane duplex. + * + * Rejects with `LaneClosedError` when the lane dies before the head lands, + * and `LaneProtocolError` for a status line that is not HTTP/1.x or a head + * past 64 KiB. Unsubscribes/tears listeners on any outcome; the caller owns + * the duplex. + */ +export function readLaneHead(duplex: Duplex): Promise { + return new Promise((resolve, reject) => { + let acc = Buffer.alloc(0); + const onData = (chunk: Buffer): void => { + acc = Buffer.concat([acc, chunk]); + for (;;) { + const end = acc.indexOf("\r\n\r\n"); + if (end > HEAD_MAX || (end === -1 && acc.byteLength > HEAD_MAX)) { + cleanup(); + reject(new LaneProtocolError("response head exceeds 64 KiB")); + return; + } + if (end === -1) return; + const headText = acc.subarray(0, end).toString("latin1"); + const tail = acc.subarray(end + 4); + const lines = headText.split("\r\n"); + const parsed: [string, string][] = []; + for (const line of lines.slice(1)) { + if (line === "") continue; + // `obs-fold` continuations (RFC 7230: a line starting with a space + // or tab) fold into the previous value. No proxied dev server emits + // them, but the parser handles them anyway, and the check runs + // BEFORE the colon check because a real continuation line carries no + // colon at all: checked after, it would be refused as malformed and + // the fold branch could never fire. + if (/^[ \t]/.test(line)) { + if (parsed.length === 0) { + cleanup(); + reject(new LaneProtocolError("folded header with no header to fold into")); + return; + } + parsed[parsed.length - 1][1] += ` ${line.trim()}`; + continue; + } + const colon = line.indexOf(":"); + if (colon === -1) { + cleanup(); + reject(new LaneProtocolError("malformed response header line")); + return; + } + const name = line.slice(0, colon).trim().toLowerCase(); + const value = line.slice(colon + 1).trim(); + parsed.push([name, value]); + } + if (!/^HTTP\/1\.[01] \d{3}/.test(lines[0] ?? "")) { + cleanup(); + reject(new LaneProtocolError(`bad status line ${JSON.stringify(lines[0]?.slice(0, 40))}`)); + return; + } + const status = Number(lines[0].slice(9, 12)); + // 101 is NOT informational here: it is a WebSocket upgrade's success + // answer, and swallowing it would hang the redial forever. + if (status >= 100 && status < 200 && status !== 101) { + // Informational (103 Early Hints, 100 Continue): consume and keep reading. + acc = tail; + continue; + } + cleanup(); + resolve({ statusLine: lines[0], headers: parsed, tail }); + return; + } + }; + const onClosed = (): void => { + cleanup(); + reject( + new LaneClosedError((duplex as unknown as { closedReason?: PreviewCloseReason | null }).closedReason ?? null), + ); + }; + const cleanup = (): void => { + duplex.removeListener("data", onData); + duplex.removeListener("close", onClosed); + }; + duplex.on("data", onData); + duplex.on("close", onClosed); + // Bytes that already arrived (the common case on a local link where the + // head lands in the same tick as the request write) sit in the Duplex's + // internal buffer, unread while paused in paused mode. Reading here + // starts flowing; if the head is already buffered, the next read emits + // it synchronously. + duplex.resume(); + }); +} diff --git a/apps/server/api/src/preview/lane-ws-client.ts b/apps/server/api/src/preview/lane-ws-client.ts new file mode 100644 index 000000000..19cab67f9 --- /dev/null +++ b/apps/server/api/src/preview/lane-ws-client.ts @@ -0,0 +1,190 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { Duplex } from "node:stream"; +import { PREVIEW_LANE_BUFFER_MAX_BYTES, serviceAuthority } from "@internal/subshell-protocol"; +import { readLaneHead } from "@/preview/lane-wire.js"; +// Bun 1.4.2 ships a BUILT-IN module named `ws` that shadows the pinned +// dependency for any import of the bare name (measured: its `Sender` +// constructor throws "Not supported yet in Bun" on the first frame setup, +// and it is what silently ignored `createConnection` when the ws client was +// imported the normal way). The real package's files are reached by path so +// the shadow cannot intercept them; the ship file is the installed package's. +import ReceiverFile from "../../node_modules/ws/lib/receiver.js"; +import SenderFile from "../../node_modules/ws/lib/sender.js"; + +/** + * One WebSocket client spoken by hand over a lane duplex (spec 2026-10-21 + * §6's http-mode redial). + * + * The brief's plan was the `ws` client itself with its top-level + * `createConnection` aimed at the lane. That option IS kept by ws + * (`node_modules/ws/lib/websocket.js` lines 766-767), and Bun 1.4.2's + * `node:http` client ignores it anyway (measured: the request dials + * `host:port` itself and the duplex never sees a byte), so the full client + * would tunnel plane-local loopback while believing it had used the lane. + * What the client genuinely needs from ws is its frame codec, and ws ships + * that as standalone classes: the handshake head is written and read here + * (`lane-wire.ts`, which the HTTP half already trusts), and `ws`'s + * `Receiver` (a Writable fed lane bytes) and `Sender` (framing onto the + * duplex, masked because we are the client) do every frame after it. They + * are imported by file path because Bun's built-in `ws` shadows the bare + * name (see the import); `@types/ws` does not describe them, hence the + * narrow structural cast below. + * + * What the handshake carries and why: `Host` is the literal loopback the + * node dials (same vhost rule as `pv-http.ts`); the browser's `Origin` rides + * along because a dev server that checks WS origins checks it against the + * SAME value it checked for the page (the plane's own origin, which the + * upgrade gate already vetted); `Sec-WebSocket-Protocol` is offered as the + * browser offered it, and a target that picks one has its answer logged and + * otherwise ignored (the browser's own negotiation already completed with + * Bun, and every dialect in use here is single-protocol); `Cookie` comes + * from the session jar, never the browser. No `Sec-WebSocket-Extensions` + * offer: permessage-deflate stays entirely off the lane, and the browser + * link's own compression is Bun's business alone. + */ + +/** The RFC 6455 magic string the `Sec-WebSocket-Accept` hash folds in. */ +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +interface FrameReceiver { + on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void; + on(event: "ping", listener: (data: Buffer) => void): void; + on(event: "conclude", listener: (code: number, reason: Buffer) => void): void; + on(event: "error", listener: (err: Error) => void): void; + on(event: "close" | "finish", listener: () => void): void; + write(chunk: Buffer): boolean; + end(): void; +} + +interface FrameSender { + send( + data: Buffer, + options: { binary: boolean; fin: boolean; mask: boolean }, + cb?: (err?: Error | null) => void, + ): void; + close(code: number | undefined, data: Buffer | undefined, mask: boolean, cb?: (err?: Error | null) => void): void; + pong(data: Buffer, mask: boolean, cb?: (err?: Error | null) => void): void; +} + +/** The untyped-but-real framing classes; see the casts and the import comment. */ +const Receiver = ReceiverFile as unknown as new (options: { isServer: false; maxPayload: number }) => FrameReceiver; +const Sender = SenderFile as unknown as new (socket: Duplex, extensions: Record) => FrameSender; + +/** The live end of a redialed WebSocket: what `pv-ws.ts` pumps frames through. */ +export interface LaneWsHandle { + /** One browser frame onward, masked. Text keeps its text opcode. */ + send(bytes: Buffer, isText: boolean): void; + /** + * Best-effort close frame before the lane dies. `undefined` code lets ws + * pick 1005 (no status); a frame that cannot be written is irrelevant: the + * lane close right behind it is the real news. + */ + close(code: number | undefined, reason: string | undefined): void; +} + +export interface LaneWsInit { + /** The port the node dials; it rides the `Host` header only. */ + port: number; + host?: string; + /** Origin-form path + query, proxy prefix already stripped. */ + path: string; + /** The browser-derived headers worth forwarding (origin, subprotocols). */ + headers: [string, string][]; + /** The upgrade answer's `Set-Cookie` values, for the session jar (spec §6). */ + onCookies(cookies: string[]): void; + onMessage(data: Buffer, isBinary: boolean): void; + /** + * Once, after the 101, with the live handle: the callback is called + * before the promise resolves, and frames parked while the handshake rode + * the lane can only be flushed against a handle that already exists. + */ + onOpen(handle: LaneWsHandle): void; + /** + * Once: the target refused the handshake, hung up, or framed something + * broken. No reason word here on purpose: when the node volunteers a + * better one through the lane, that word wins (`pv-ws.ts`'s guard), and + * `target-gone` is what the caller says when nothing else does. + */ + onDeath(): void; +} + +/** Returned when the redial died at the handshake: every pump call is a no-op. */ +const inertHandle: LaneWsHandle = { + send() { + /* dead */ + }, + close() { + /* dead */ + }, +}; + +/** + * Dial the redial: write the upgrade head, await the answer on the lane, + * and hand both directions to ws's framing classes. Rejects only for a + * head the lane could not answer (a `LaneClosedError` from `lane-wire.ts`) + * or a head that is not parseable; every OTHER refusal (a 404, a plain + * 200 HTML page, a wrong accept) settles as `onDeath`, mirroring the ws + * client's `unexpected-response`-then-close shape. + */ +export async function openLaneWebSocket(duplex: Duplex, init: LaneWsInit): Promise { + const key = randomBytes(16).toString("base64"); + const lines = [ + `GET ${init.path} HTTP/1.1`, + `host: ${serviceAuthority(init.host ?? "127.0.0.1", init.port)}`, + "connection: upgrade", + "upgrade: websocket", + ]; + for (const [name, value] of init.headers) lines.push(`${name}: ${value}`); + lines.push(`sec-websocket-key: ${key}`, "sec-websocket-version: 13"); + duplex.write(Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1")); + + const head = await readLaneHead(duplex); + const status = Number(head.statusLine.slice(9, 12)); + if (status !== 101) { + init.onDeath(); + return inertHandle; + } + const accept = head.headers.find(([n]) => n === "sec-websocket-accept")?.[1]; + if ( + accept !== + createHash("sha1") + .update(key + WS_GUID) + .digest("base64") + ) { + init.onDeath(); + return inertHandle; + } + const cookies = head.headers.filter(([n]) => n === "set-cookie").map(([, v]) => v); + if (cookies.length > 0) init.onCookies(cookies); + + const sender = new Sender(duplex, {}); + const receiver = new Receiver({ isServer: false, maxPayload: PREVIEW_LANE_BUFFER_MAX_BYTES }); + receiver.on("message", (data, isBinary) => init.onMessage(data, isBinary)); + receiver.on("ping", (data) => sender.pong(data, true)); + // `conclude` is ws's "a close frame arrived": the target is done talking. + receiver.on("conclude", () => init.onDeath()); + receiver.on("error", () => init.onDeath()); + receiver.on("close", () => init.onDeath()); + // The head reader may have handed bytes that are already frames; feed + // them before attaching the pipe so their order survives. + if (head.tail.byteLength > 0) receiver.write(head.tail); + // The cast is the structural one from the top of the file: ws's + // Receiver IS a `stream.Writable` at runtime; the narrow interface only + // names the events and `write` we use, and `pipe` wants the stream. + duplex.pipe(receiver as unknown as NodeJS.WritableStream); + + const handle: LaneWsHandle = { + send(bytes, isText) { + sender.send(bytes, { binary: !isText, fin: true, mask: true }); + }, + close(code, reason) { + try { + sender.close(code, reason === undefined ? undefined : Buffer.from(reason, "utf8"), true, () => undefined); + } catch { + /* a frame that cannot be written is noise; the lane close is the news */ + } + }, + }; + init.onOpen(handle); + return handle; +} diff --git a/apps/server/api/src/preview/local-preview.ts b/apps/server/api/src/preview/local-preview.ts new file mode 100644 index 000000000..03b9a5f84 --- /dev/null +++ b/apps/server/api/src/preview/local-preview.ts @@ -0,0 +1,195 @@ +import { + type BridgeDeps, + bridgeCount, + type LaneDeps, + type PanePortsScan, + PreviewDisplays, + PreviewForward, + type PreviewLaneEvent, + scanPanePorts, + startBridge, + stopBridge, + TmuxRunner, + type VirtualDisplayProvider, +} from "@internal/pane-runtime"; +import { + type NodeCommandBody, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_SESSIONS_PER_NODE, + PREVIEW_WATCH_FPS_DEFAULT, +} from "@internal/subshell-protocol"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { db } from "@/db/index.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import type { SubshellTable } from "@/db/types/subshells.db-types.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { + getAnyPreviewEnabled, + getEnabledDisplayProviders, + getVirtualDisplayEnabled, + getVirtualDisplayProvider, + getWebPreviewsEnabled, +} from "@/services/nodes/preview-enabled.js"; +import { getLogger } from "@/utils/logger.js"; + +export interface LocalPreviewDeps { + dataDir: string; + enabled: () => Promise; + webPreviewsEnabled?: () => Promise; + displayEnabled?: () => Promise; + pane: (id: string) => Promise; + panePid: (socket: string, id: string) => Promise; + scan?: typeof scanPanePorts; + forward?: LaneDeps; + bridge?: BridgeDeps; + displayProvider?: VirtualDisplayProvider; + getDisplayProviderId?: () => string | Promise; + getEnabledProviderIds?: () => string[] | Promise; + log?: (line: string) => void; +} + +/** Uses the same machine-local primitives as an enrolled node, with the DB as its gate. */ +export class LocalPreviewRuntime { + readonly forward: PreviewForward; + readonly displays: PreviewDisplays; + private chain: Promise = Promise.resolve(); + constructor(private readonly deps: LocalPreviewDeps) { + this.forward = new PreviewForward(deps.forward); + this.displays = new PreviewDisplays(deps.dataDir, { + provider: deps.displayProvider, + getProviderId: deps.getDisplayProviderId, + getEnabledProviderIds: deps.getEnabledProviderIds, + isEnabled: deps.displayEnabled ?? deps.enabled, + log: deps.log, + }); + } + + /** Serializes admission like the node's command chain, including in-flight CDP handshakes. */ + execute(cmd: NodeCommandBody, emit: (event: PreviewLaneEvent) => void): Promise { + const result = this.chain.then(() => this.run(cmd, emit)); + this.chain = result.catch(() => {}); + return result; + } + + private refuse(detail: string): never { + throw new NodeRpcError("failed", detail, LOCAL_NODE_ID, detail); + } + + private async run(cmd: NodeCommandBody, emit: (event: PreviewLaneEvent) => void): Promise { + // Closing remains available after the switch goes off or the subshell ends. + if (cmd.type === "preview_close") { + const { closed } = this.forward.closeLane(cmd.ref, cmd.reason); + return { ref: cmd.ref, closed: closed || stopBridge(cmd.ref, cmd.reason) }; + } + if (cmd.type !== "preview_open" && cmd.type !== "preview_ports_get") this.refuse("unsupported"); + if (!(await this.deps.enabled())) this.refuse("preview-node-off: turn on Preview for this node"); + const pane = await this.deps.pane(cmd.paneId); + if (!pane || pane.nodeId !== LOCAL_NODE_ID || pane.status !== "running" || pane.alive !== 1 || !pane.tmuxSocket) + this.refuse("preview-pane-gone"); + const panePid = await this.deps.panePid(pane.tmuxSocket, pane.id); + if (panePid === null) this.refuse("preview-pane-gone"); + if (cmd.type === "preview_ports_get") { + try { + const screen = this.displays.getFor(pane.id); + const answer: PanePortsScan = await (this.deps.scan ?? scanPanePorts)({ panePid, screen }); + const screenLost = screen === null ? this.displays.lostFor(pane.id) : null; + return { ...answer, ...(screenLost ? { screenLost } : {}) }; + } catch (err) { + this.refuse(err instanceof Error ? err.message : String(err)); + } + } + const isScreen = cmd.kind === "forward" && !cmd.service && this.displays.getFor(pane.id)?.viewer.port === cmd.port; + if ( + !(await (isScreen + ? (this.deps.displayEnabled ?? this.deps.enabled) + : (this.deps.webPreviewsEnabled ?? this.deps.enabled))()) + ) { + this.refuse(isScreen ? "virtual-display-disabled" : "preview-node-off"); + } + if (this.forward.laneCount() + bridgeCount() >= PREVIEW_SESSIONS_PER_NODE) + this.refuse(cmd.kind === "forward" ? "preview-lane-over-node-cap" : "preview-watch-over-node-cap"); + if (cmd.kind === "forward") { + if (this.forward.laneCount(cmd.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) + this.refuse("preview-lane-over-pane-cap"); + this.forward.openLane({ + ref: cmd.ref, + port: cmd.port, + service: cmd.service, + paneId: cmd.paneId, + lifetimeMs: cmd.lifetimeMs, + hooks: { emit, log: this.deps.log }, + }); + return { ref: cmd.ref, pending: true }; + } + try { + await startBridge({ + ref: cmd.ref, + cdpPort: cmd.port, + fps: cmd.fps ?? PREVIEW_WATCH_FPS_DEFAULT, + lifetimeMs: cmd.lifetimeMs, + emitStream: (event) => { + emit(event); + }, + emitClosed: emit, + deps: this.deps.bridge, + log: this.deps.log, + }); + return { ref: cmd.ref, subscribers: 1 }; + } catch { + this.refuse("not-cdp"); + } + } +} + +let runtime: LocalPreviewRuntime | undefined; +let sweepTimer: ReturnType | undefined; +let sweptAt = 0; +let sweep: Promise | undefined; + +/** Lazy: importing the CLI graph must not create directories or start child processes. */ +export function getLocalPreviewRuntime(): LocalPreviewRuntime { + if (!runtime) { + const panes = new SubshellsRepository(db); + const tmux = new TmuxRunner(); + sweepTimer ??= setInterval(() => { + void sweepLocalPreviewDisplays().catch((err: unknown) => + getLogger().warn(`Local display sweep failed: ${String(err)}`), + ); + }, 60_000); + sweepTimer.unref(); + runtime = new LocalPreviewRuntime({ + dataDir: SUBSHELL_SERVER_DATA_DIR, + getDisplayProviderId: () => getVirtualDisplayProvider(LOCAL_NODE_ID), + getEnabledProviderIds: () => getEnabledDisplayProviders(LOCAL_NODE_ID), + enabled: () => getAnyPreviewEnabled(LOCAL_NODE_ID), + webPreviewsEnabled: () => getWebPreviewsEnabled(LOCAL_NODE_ID), + displayEnabled: () => getVirtualDisplayEnabled(LOCAL_NODE_ID), + pane: (id) => panes.findById(id), + panePid: (socket, id) => tmux.panePid(socket, id), + log: (line) => getLogger().debug(line), + }); + } + return runtime; +} + +/** Rehydrate surviving screens after a server restart and reap orphaned display children. */ +export async function sweepLocalPreviewDisplays(): Promise { + if (sweep) { + await sweep; + return; + } + if (Date.now() - sweptAt < 60_000) return; + sweptAt = Date.now(); + sweep = (async () => { + const panes = await new SubshellsRepository(db).listRunning(); + await getLocalPreviewRuntime().displays.sweepOrphans( + panes.filter((pane) => pane.nodeId === LOCAL_NODE_ID && pane.alive === 1).map((pane) => pane.id), + ); + })(); + try { + await sweep; + } finally { + sweep = undefined; + } +} diff --git a/apps/server/api/src/preview/pv-http.ts b/apps/server/api/src/preview/pv-http.ts new file mode 100644 index 000000000..35d7319e7 --- /dev/null +++ b/apps/server/api/src/preview/pv-http.ts @@ -0,0 +1,311 @@ +import { serviceAuthority } from "@internal/subshell-protocol"; +import { Elysia } from "elysia"; +import { HttpError } from "@/api/auth-guard.js"; +import { LaneDuplex } from "@/preview/lane-duplex.js"; +import { LaneClosedError, LaneProtocolError, sendLaneRequest } from "@/preview/lane-http.js"; +import { getPreviewBroker, PreviewRefusal } from "@/services/preview-broker.js"; + +/** + * The same-origin `/pv//...` HTTP half of the pane-preview proxy + * (spec 2026-10-21 §6). + * + * One session, one response, one lane: every browser request under the + * session's proxy path opens its own forward lane (the browser reconnects + * per request; lanes are cheap and one-shot), carries the session's cookies + * from the jar instead of the browser's, and rewrites the answer so the + * target believes it lives under `/pv//`. Its cookies stay in the + * server's session jar rather than the browser's dashboard cookie store. + * The session ref in the path is the whole credential (spec §10), so an unknown one is a plain 404 saying nothing + * else. + * + * The request itself is hand-framed over the lane (`lane-http.ts`): Bun's + * `node:http` client silently ignores `createConnection` (measured on 1.4.2 + * three ways), so a proxy built on it would answer with bytes from a + * plane-local dial while believing it had used the lane. The transforms, and + * why each one exists: + * + * - INBOUND: drop the browser's `Cookie` and attach the jar (spec §6: the + * jar reaches lane requests only, and every request the proxy makes is + * one); set `Host` to the literal `127.0.0.1:` the node dials (a + * vhost-aware dev server must not see the plane's hostname, and `Host` is + * exactly what a Host-checking dev server checks); drop `Connection`/ + * `Keep-Alive` (the lane is one-shot; a keep-alive promise to the target + * would outlive the lane). + * - OUTBOUND: keep target cookies only in the session jar, rewrite relative + * redirects, and replace target framing restrictions with our own. A CSP + * sandbox gives every document an opaque origin, including direct tabs; + * CORS permits its modules and requests only under the capability URL. + * Preview responses cannot set cookies on the dashboard origin. + */ + +/** Header names describing the lane's transport rather than the proxied exchange. */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** Browser-context headers the jar/Host/one-shot rules replace or re-frame. */ +const INBOUND_DROP = new Set([ + "cookie", + "host", + "content-length", + "authorization", + "x-api-key", + "forwarded", + "remote-user", + ...HOP_BY_HOP, +]); + +// Edge authentication belongs to the dashboard, never to a pane's dev server. +function isIdentityHeader(name: string): boolean { + return /^(cf-access-|x-auth-|x-auth-request-|x-amzn-oidc-|x-forwarded-)/.test(name); +} + +// A target may describe its document, but cannot clear dashboard storage, +// install transport policy, or request HTTP authentication for our host. +const OUTBOUND_DROP = new Set([ + ...HOP_BY_HOP, + "set-cookie", + "content-length", + "x-frame-options", + "clear-site-data", + "www-authenticate", + "strict-transport-security", + "alt-svc", + "nel", + "report-to", + "reporting-endpoints", + "accept-ch", + "critical-ch", +]); + +/** The 404 text. Deliberately about the link, never about the pane behind it. */ +const DEAD_LINK = "This preview link is no longer valid. Reopen the preview to get a fresh one."; + +/** + * Store the name=value pair of every `Set-Cookie` in the session jar, keyed + * by cookie name with last-write-wins. Attributes are stripped because the + * jar's values ARE what gets attached to later requests, and a value still + * wearing `Path=` or `Expires=` would ship to the target as a bogus cookie + * (spec §6). Name-only keying across paths is the deliberate v1 + * simplification. Shared with `pv-ws.ts` so the upgrade-set cookie (the + * redial's response) lands in the SAME jar: §6's jar sentence is unqualified + * about which lane sets it. + */ +export function storeCookiesInJar(jar: Map, setCookies: readonly string[]): void { + for (const raw of setCookies) { + const pair = raw.split(";")[0]?.trim(); + if (!pair) continue; + const eq = pair.indexOf("="); + if (eq < 1) continue; + const name = pair.slice(0, eq); + // The jar is target-controlled and outlives individual HTTP requests. + // Bound both entry count and bytes, evicting the oldest cookie first. + if (Buffer.byteLength(pair) > 4096) continue; + jar.delete(name); + jar.set(name, pair); + let bytes = [...jar.values()].reduce((sum, value) => sum + Buffer.byteLength(value), 0); + while (jar.size > 128 || bytes > 64 * 1024) { + const oldest = jar.entries().next().value; + if (!oldest) break; + bytes -= Buffer.byteLength(oldest[1]); + jar.delete(oldest[0]); + } + } +} + +/** The jar as a `Cookie` header value, or "" when the jar is empty. */ +export function jarCookieHeader(jar: Map): string { + return [...jar.values()].join("; "); +} + +/** Enforce the browser boundary on embedded documents and direct preview tabs. */ +export function isolatedPreviewHeaders(headers = new Headers()): Headers { + headers.append( + "content-security-policy", + "sandbox allow-scripts allow-forms allow-modals allow-downloads allow-popups; frame-ancestors 'self'", + ); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-allow-credentials", "false"); + headers.set("referrer-policy", "no-referrer"); + headers.set("cache-control", "no-store"); + headers.set("x-content-type-options", "nosniff"); + return headers; +} + +/** + * Drop only the `frame-ancestors` directive from a CSP value. Splitting on + * `;` treats a quoted semicolon inside a directive as a separator; the + * directive families that could carry one are not the ones iframe-embedding + * dev servers ship, so v1 takes the simple split and keeps everything else + * byte-identical. + */ +export function stripFrameAncestors(csp: string): string { + return csp + .split(";") + .map((directive) => directive.trim()) + .filter((directive) => !/^frame-ancestors(\s|$)/i.test(directive)) + .join("; "); +} + +/** Prefix a root-relative `Location` so the redirect stays inside the proxy. */ +export function rewriteLocation(location: string, ref: string): string { + if (!location.startsWith("/") || location.startsWith("//")) return location; + return `/pv/${ref}${location}`; +} + +/** Transform the raw lane answer's header pairs into the browser-facing `Headers`. */ +function outHeadersToWeb(pairs: [string, string][], jar: Map, ref: string): Headers { + const headers = new Headers(); + const setCookies = pairs.filter(([n]) => n === "set-cookie").map(([, v]) => v); + for (const [name, value] of pairs) { + if (OUTBOUND_DROP.has(name)) continue; + if (name === "content-security-policy" || name === "content-security-policy-report-only") { + headers.append(name, stripFrameAncestors(value)); + } else if (name === "location") { + headers.append(name, rewriteLocation(value, ref)); + } else { + headers.append(name, value); + } + } + if (setCookies.length > 0) { + storeCookiesInJar(jar, setCookies); + } + return isolatedPreviewHeaders(headers); +} + +/** + * One proxied request: lane, hand-framed request, streamed answer, lane + * release. The lane closes exactly once, `viewer-left`, whatever the ending + * (complete body, client abort, dead lane, refusal), because every one of + * those is the proxy finishing its one-shot. A lane the NODE closed keeps + * its own reason word inside `LaneDuplex`, and the 502 quotes it. A lane + * that dies mid-body ends the stream: a POST may already have landed, and + * there is never a mid-stream retry. + */ +async function proxyRequest(request: Request, sessionRef: string, rest: string): Promise { + const broker = getPreviewBroker(); + const session = broker.sessionFor(sessionRef); + if (!session) throw new HttpError(404, DEAD_LINK); + + // Opaque-origin scripts need CORS for modules and fetch. The unguessable + // session path authorizes these reads; dashboard API CORS remains unchanged. + if (request.method === "OPTIONS" && request.headers.has("access-control-request-method")) { + const headers = isolatedPreviewHeaders(); + headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"); + const requested = request.headers.get("access-control-request-headers"); + if (requested) headers.set("access-control-allow-headers", requested); + return new Response(null, { status: 204, headers }); + } + + let laneRef: string; + try { + ({ laneRef } = await broker.openLaneForConnection(sessionRef)); + } catch (err) { + if (err instanceof PreviewRefusal) { + // The named refusal, verbatim: the SPA turns this word into "close a + // preview first". Never an anonymous 500, never a hang. + throw new HttpError( + 503, + `${err.code}: this pane is already carrying too many preview lanes. Try again once one closes.`, + ); + } + throw err; + } + + let laneReleased = false; + const releaseLane = (): void => { + if (laneReleased) return; + laneReleased = true; + void broker.closeLaneForConnection(laneRef, "viewer-left").catch(() => undefined); + }; + + const duplex = new LaneDuplex({ + writeOut: (bytes) => broker.sendLaneBytes(laneRef, bytes), + onLane: (cb) => broker.subscribeLane(laneRef, cb), + }); + // The browser giving up ends the lane stream; whatever the target answers + // after that has nowhere to go, and the body reader's finish/close path + // still runs `releaseLane`. + request.signal.addEventListener("abort", () => duplex.destroy()); + + const url = new URL(request.url); + const headers: [string, string][] = [ + ["host", serviceAuthority(session.service?.host ?? "127.0.0.1", session.port)], + ["connection", "close"], + ]; + for (const [name, value] of request.headers) { + const lower = name.toLowerCase(); + if (INBOUND_DROP.has(lower) || isIdentityHeader(lower)) continue; + headers.push([lower, value]); + } + const jarCookie = jarCookieHeader(session.cookieJar); + if (jarCookie) headers.push(["cookie", jarCookie]); + + const isHead = request.method === "HEAD"; + try { + const res = await sendLaneRequest( + duplex, + { + method: request.method, + path: `/${rest}${url.search}`, + headers, + // A request body rides chunk-encoded (the browser's own framing is + // dropped with `content-length` above, and an unknown-length fetch + // stream cannot promise a length to the target). GET/HEAD carry none. + body: isHead || request.method === "GET" ? null : (request.body ?? null), + }, + isHead, + () => { + duplex.destroy(); + releaseLane(); + }, + ); + const headersOut = outHeadersToWeb(res.headers, session.cookieJar, sessionRef); + if (res.body === null) { + duplex.destroy(); + releaseLane(); + } + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: headersOut, + }); + } catch (err) { + duplex.destroy(); + releaseLane(); + if (err instanceof LaneClosedError) { + throw new HttpError(502, `The preview target could not answer: ${err.message}.`); + } + if (err instanceof LaneProtocolError) { + throw new HttpError(502, `The preview target answered with something unparseable: ${err.message}.`); + } + throw err; + } +} + +/** + * The seven plain verbs for the session's proxy path (the WS upgrade for the + * same wildcard lives in `pv-ws.ts`; spike-verified that a GET route and a + * `.ws()` route coexist on one dynamic pattern). Mounted by `server.ts` + * BEFORE the static SPA plugin (that file's `installScriptRoute` comment: a + * root-level path mounted after it is 404'd by the file branch). + */ +const PATTERN = "/pv/:session/*"; +const pvHandler = ({ params, request }: { params: { session: string; "*": string }; request: Request }) => + proxyRequest(request, params.session, params["*"] ?? ""); + +export const pvHttpRoute = new Elysia({ name: "pv-http" }) + .get(PATTERN, pvHandler) + .post(PATTERN, pvHandler) + .put(PATTERN, pvHandler) + .patch(PATTERN, pvHandler) + .delete(PATTERN, pvHandler) + .head(PATTERN, pvHandler) + .options(PATTERN, pvHandler); diff --git a/apps/server/api/src/preview/pv-ws.ts b/apps/server/api/src/preview/pv-ws.ts new file mode 100644 index 000000000..4acefeffc --- /dev/null +++ b/apps/server/api/src/preview/pv-ws.ts @@ -0,0 +1,343 @@ +import { PREVIEW_LANE_BUFFER_MAX_BYTES, type PreviewCloseReason } from "@internal/subshell-protocol"; +import { Elysia } from "elysia"; +import { HttpError } from "@/api/auth-guard.js"; +import { browserOriginAllowed } from "@/lib/browser-origin.js"; +import { LaneDuplex } from "@/preview/lane-duplex.js"; +import { type LaneWsHandle, openLaneWebSocket } from "@/preview/lane-ws-client.js"; +import { jarCookieHeader, storeCookiesInJar } from "@/preview/pv-http.js"; +import { getPreviewBroker, PreviewRefusal, type PreviewSession } from "@/services/preview-broker.js"; + +/** + * The WS half of the `/pv//...` proxy (pane-preview spec 2026-10-21 §6), + * mounted on the SAME wildcard the HTTP half serves (`pv-http.ts`): Elysia + * dispatches an upgrade GET to the `.ws()` route and plain GETs to the plain + * routes (spike-verified for one dynamic pattern across two modules). + * + * The session ref in the path authorized this upgrade already (spec §10), so + * any suffix is the tunnel root: noVNC dials its own path and Vite's HMR + * dials its own. Gates run at UPGRADE (before any socket exists, the /ws + * doctrine): `browserOriginAllowed` from `@/lib/browser-origin.js` (PR #424: + * a foreign browser Origin is refused, a missing Origin is a non-browser + * client and passes, registry origins pass; an exact plane-origin equality + * would break a dashboard reached over a published Tailscale/NetBird + * address), then the session lookup, then the broker's per-pane lane cap. A + * queue-overflow refusal answers `503` carrying `preview-lane-over-cap` as + * an HTTP status, never a hang or a socket that dies later. + * + * Two modes, because one pump cannot serve both consumers (decision 6): + * `raw` websockify-pumps frame payloads straight onto the lane for a raw TCP + * target (x11vnc would read an HTTP upgrade as a bogus RFB version), while + * `http` REDIALS a real WebSocket handshake aimed at the lane. The brief's + * shape here was the `ws` client's top-level `createConnection` option, + * which ws itself does keep (`node_modules/ws/lib/websocket.js` lines + * 766-767) but Bun 1.4.2's `node:http` client IGNORES (measured: the request + * dials `host:port` itself and the lane sees no byte), so the redial rides + * `lane-ws-client.ts` instead: the handshake spoken here, ws's own + * `Receiver`/`Sender` doing the frames. It carries the jar's cookies and + * captures the target's upgrade response `Set-Cookie` into the same jar. + * + * Close words reach the user, not only the audit: anything except the + * viewer's own leaving closes the browser socket `1011` with the + * `PreviewCloseReason` verbatim in the reason field (there is no "server + * policy" close code, and every word in the set fits the 123-byte limit), + * so Task 13's disconnect handling reads `revoked`/`pane-gone`/`target-gone` + * off the close event. + * + * A text-frame caveat worth stating because it is the one byte-exactness + * exception, and the shapes Elysia's `.ws()` parser leaves behind are the + * ones `message` below has to sort out: a text frame that looks like JSON + * arrives parsed, so one with significant whitespace rides re-stringified + * (semantics kept, whitespace not); a text frame like `123`, `true`, or + * `null` arrives as the coerced primitive and rides on as its string form; + * plain strings, binary frames, and the dev-server dialects that matter + * (Vite HMR's compact JSON, VNC's binary) ride exact. Being fully raw would + * mean a hand-rolled `server.upgrade` route, which is a design change from + * the `.ws()` mount the brief registers, not a line to quietly take. + */ + +/** One live browser↔lane tunnel, stashed on `ws.data` at upgrade (socket identity is not state). */ +interface PvTunnel { + sessionRef: string; + laneRef: string; + mode: "http" | "raw"; + port: number; + /** Suffix + query the browser dialed (http-mode redial path; raw ignores it). */ + rest: string; + search: string; + /** Browser headers the redial re-offers (origin, subprotocols); raw ignores it. */ + forward: [string, string][]; + session: PreviewSession; + /** The browser side, filled in `open` (the upgrade ran before it existed). */ + ws?: { close: (code?: number, reason?: string) => void }; + /** http mode: the redialed target end, filled when the 101 lands. */ + handle?: LaneWsHandle; + duplex?: LaneDuplex; + unsub: () => void; + /** http mode: the redial handshake has not completed yet. */ + pending: boolean; + /** browser frames waiting on that handshake, and their running byte total. */ + queue: { text: boolean; bytes: Buffer }[]; + queuedBytes: number; + closed: boolean; +} + +const DEAD_LINK = "This preview link is no longer valid. Reopen the preview to get a fresh one."; + +// The return type is left to inference: `.ws()` deepens the instance type, and an +// explicit `Elysia` annotation on the way out makes `server.ts` reject `.use()` +// on a variance the builder guarantees. +export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { + // The seam the tests drive small: the same doctrine as Task 5's + // `laneBufferMaxBytes`. Past this much browser bytes parked while the + // target-side handshake has not completed, the link is not a slow link, + // it is a dead one, and spec §6's no-phantom-success says say so. + const preAckMax = options.preAckMaxBytes ?? PREVIEW_LANE_BUFFER_MAX_BYTES; + + /** + * The single teardown: one owner, one reason word, both ends closed. + * + * `from` says who died: `browser` (their close or unmount: the lane gets + * `viewer-left`, the browser socket needs no explanation), `lane` (the + * node already named the death; re-closing the lane is not ours to do), + * `target` (the far end hung up or refused before the lane said anything; + * we own the lane and the honest word is `target-gone`). Everyone but the + * browser gets `1011` + the word verbatim: there is no "server policy" + * close code, and the word is the contract Task 13 renders. + */ + function teardown(t: PvTunnel, reason: PreviewCloseReason, from: "browser" | "lane" | "target"): void { + if (t.closed) return; + t.closed = true; + t.queue.length = 0; + t.queuedBytes = 0; + // A close frame before the lane dies: best-effort courtesy, a frame + // that cannot be written is noise under the lane close right behind it. + t.handle?.close(undefined, undefined); + t.unsub(); + t.duplex?.destroy(); + if (from !== "lane") { + void getPreviewBroker() + .closeLaneForConnection(t.laneRef, from === "browser" ? "viewer-left" : reason) + .catch(() => undefined); + } + try { + if (from === "browser") t.ws?.close(); + else if (reason === "viewer-left") t.ws?.close(1000, "viewer-left"); + else t.ws?.close(1011, reason); + } catch { + /* the browser socket is already gone; the lane close was the point */ + } + } + + return new Elysia({ name: "pv-ws" }).ws("/pv/:session/*", { + async upgrade(context) { + const { request, params } = context as { request: Request; params: Record }; + // Sandboxed preview documents have an opaque Origin. This exception + // belongs only to capability-bound preview tunnels, never dashboard WS. + if (request.headers.get("origin") !== "null" && !browserOriginAllowed(request)) { + throw new HttpError(403, "This request came from an untrusted origin."); + } + const broker = getPreviewBroker(); + const session = broker.sessionFor(params.session); + if (!session) throw new HttpError(404, DEAD_LINK); + let laneRef: string; + try { + // AWAITED here, before the socket: the cap decision is the upgrade's + // answer, so the SPA sees a named 503 instead of a dying connection. + ({ laneRef } = await broker.openLaneForConnection(params.session)); + } catch (err) { + if (err instanceof PreviewRefusal) { + throw new HttpError( + 503, + `${err.code}: this pane is already carrying too many preview lanes. Try again once one closes.`, + ); + } + throw err; + } + const url = new URL(request.url); + // The two browser headers worth re-offering on the redial: a + // dev server that checks WS origins sees the same value it vetted + // against the page (the plane origin the gate just passed), and a + // dialect that names its subprotocols gets them offered. The + // answer-back pick is ignored, not negotiated again: the browser's + // own negotiation already completed with Bun. + const forward: [string, string][] = []; + const originHeader = request.headers.get("origin"); + if (originHeader) forward.push(["origin", originHeader]); + const protocols = request.headers.get("sec-websocket-protocol"); + if (protocols) forward.push(["sec-websocket-protocol", protocols]); + Object.assign(context as Record, { + pv: { + sessionRef: session.ref, + laneRef, + mode: session.mode, + port: session.port, + rest: params["*"] ?? "", + search: url.search, + forward, + session, + unsub: () => undefined, + pending: session.mode === "http", + queue: [], + queuedBytes: 0, + closed: false, + } satisfies PvTunnel, + }); + }, + open(ws) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + t.ws = ws; + const broker = getPreviewBroker(); + if (t.mode === "raw") { + // Raw mode: lane bytes out ARE binary frames, payloads verbatim, + // nothing HTTP about it (spec §5's blind pump, exactly right for + // RFB and exactly wrong for a target that is itself a WS server). + t.unsub = broker.subscribeLane(t.laneRef, (event) => { + if ("closed" in event) { + teardown(t, event.closed, "lane"); + return; + } + if (t.closed) return; + if ( + (ws.raw as unknown as { getBufferedAmount(): number }).getBufferedAmount() + event.bytes.byteLength > + PREVIEW_LANE_BUFFER_MAX_BYTES + ) { + teardown(t, "slow-link", "target"); + return; + } + try { + ws.send(Buffer.from(event.bytes)); + } catch { + /* the browser socket is racing its own close */ + } + }); + return; + } + + // http mode subscribes the LaneDuplex FIRST, before anything else can + // lose a byte: a separate "watch for closed" subscriber opened ahead of + // the duplex would swallow out-bytes in the gap. The duplex owns the + // lane subscription; its close event (with `closedReason`) is the lane + // death signal. + const duplex = new LaneDuplex({ + writeOut: (bytes) => broker.sendLaneBytes(t.laneRef, bytes), + onLane: (cb) => broker.subscribeLane(t.laneRef, cb), + }); + t.duplex = duplex; + t.unsub = () => duplex.destroy(); + duplex.on("close", () => { + const word = duplex.closedReason; + if (word && !t.closed) teardown(t, word, "lane"); + }); + duplex.on("error", () => { + if (!t.closed) teardown(t, "target-gone", "target"); + }); + const targetDied = (): void => { + // The target hung up or refused the handshake. When the node + // volunteers a better word (`target-gone` via the lane), the guard + // keeps this path from overwriting it; when it never does (a refusal + // answering a WS dial), `target-gone` is still the honest word: + // nothing on the far end speaks WS. + if (!t.closed) teardown(t, "target-gone", "target"); + }; + const jarCookie = jarCookieHeader(t.session.cookieJar); + const redial = openLaneWebSocket(duplex, { + host: t.session.service?.host, + port: t.port, + path: `/${t.rest}${t.search}`, + headers: jarCookie ? [...t.forward, ["cookie", jarCookie]] : t.forward, + onCookies: (cookies) => { + // Spec §6's jar sentence is unqualified: an upgrade-set cookie + // rides the session like an HTTP-lane one. (No Path rewrite here: + // the browser never sees this handshake.) + storeCookiesInJar(t.session.cookieJar, cookies); + }, + onMessage: (data, isBinary) => { + if (t.closed) return; + if ( + (ws.raw as unknown as { getBufferedAmount(): number }).getBufferedAmount() + data.byteLength > + PREVIEW_LANE_BUFFER_MAX_BYTES + ) { + teardown(t, "slow-link", "target"); + return; + } + try { + ws.send(isBinary ? data : data.toString("utf8")); + } catch { + /* browser racing its close; the close handler reaps the lane */ + } + }, + onOpen: (handle) => { + if (t.closed) return; + t.handle = handle; + t.pending = false; + for (const frame of t.queue.splice(0)) { + t.queuedBytes -= frame.bytes.byteLength; + handle.send(frame.bytes, frame.text); + } + }, + onDeath: targetDied, + }); + // A head the lane never answered (LaneClosedError from the wire + // reader) is a death like any other; the lane's own close event may + // well have run first, and the guards make the double-call harmless. + void redial.catch(targetDied); + }, + message(ws, message) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + if (t.closed) return; + // Elysia's parser hands the frame over in whatever shape its coercion + // left it, and the shape decides both the bytes and the frame type + // back out. A string rides verbatim. A coerced PRIMITIVE (the parser + // turns "123" into 123, "true" into true, "null" into null) rides as + // text in its string form: the target receives the bare digits or + // word it would receive unwrapped, which is reversible. A parsed JSON + // object can only ride re-stringified: semantics kept, whitespace + // not. Anything buffer-shaped rides byte-exact as binary. + let bytes: Buffer; + let text: boolean; + if (typeof message === "string") { + bytes = Buffer.from(message); + text = true; + } else if (message === null || typeof message !== "object") { + bytes = Buffer.from(String(message)); + text = true; + } else if (Buffer.isBuffer(message)) { + bytes = Buffer.from(message.buffer, message.byteOffset, message.byteLength); + text = false; + } else if (ArrayBuffer.isView(message)) { + // isView, not a duck-check of the key names: a text frame whose whole + // JSON content happens to pair `buffer` and `byteOffset` parses to a + // plain object, and the duck would read the JSON values as view + // offsets and destroy the frame. + const view = message as ArrayBufferView; + bytes = Buffer.from(view.buffer, view.byteOffset, view.byteLength); + text = false; + } else { + bytes = Buffer.from(JSON.stringify(message)); + text = true; + } + if (bytes.byteLength > PREVIEW_LANE_BUFFER_MAX_BYTES) { + teardown(t, "over-cap", "target"); + return; + } + if (t.mode === "raw") { + getPreviewBroker().sendLaneBytes(t.laneRef, bytes); + return; + } + if (!t.pending && t.handle) { + t.handle.send(bytes, text); + return; + } + // Pre-ack window: the redial handshake has not completed. Park, but + // never park past the budget: spec §6 refuses phantom success. The + // teardown carries the named death to the browser (1011 `slow-link`). + t.queue.push({ text, bytes }); + t.queuedBytes += bytes.byteLength; + if (t.queuedBytes > preAckMax) teardown(t, "slow-link", "target"); + }, + close(ws) { + const t = (ws.data as unknown as { pv: PvTunnel }).pv; + teardown(t, "viewer-left", "browser"); + }, + }); +} diff --git a/apps/server/api/src/schema/virtual-display.ts b/apps/server/api/src/schema/virtual-display.ts new file mode 100644 index 000000000..c24d37120 --- /dev/null +++ b/apps/server/api/src/schema/virtual-display.ts @@ -0,0 +1,15 @@ +import { t } from "elysia"; +export const DisplayProviderIdSchema = t.String({ + minLength: 1, + maxLength: 64, + pattern: "^[a-z][a-z0-9-]{0,63}$", + description: "Virtual display provider identifier", +}); +export const VirtualDisplayRequestSchema = t.Object( + { + width: t.Integer({ minimum: 320, maximum: 4096, description: "Display width in pixels" }), + height: t.Integer({ minimum: 320, maximum: 4096, description: "Display height in pixels" }), + provider: t.Optional(DisplayProviderIdSchema), + }, + { description: "Virtual display request; omitted provider uses the node default at creation" }, +); diff --git a/apps/server/api/src/server.ts b/apps/server/api/src/server.ts index 40798b813..9806245ec 100644 --- a/apps/server/api/src/server.ts +++ b/apps/server/api/src/server.ts @@ -9,6 +9,8 @@ import { authPlugin } from "@/plugins/auth.plugin.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import { openApiPlugin } from "@/plugins/openapi.plugin.js"; import { selectStaticPlugin } from "@/plugins/static.plugin.js"; +import { pvHttpRoute } from "@/preview/pv-http.js"; +import { previewWsRoute } from "@/preview/pv-ws.js"; import { apiModels } from "@/schema/index.js"; import { originRegistry } from "@/services/trusted-origins.js"; import { logger } from "@/utils/logger.js"; @@ -25,7 +27,8 @@ const FRONTEND_DIST = new URL("../../web/dist", import.meta.url).pathname; * echoes that header back with `vary: Origin`. Exported so the integration * test mounts the very predicate the app runs, not a copy. * - * Exact match on purpose. The plugin's own string branch also accepted a + * Opaque-origin preview resources have a capability-path exception below; + * dashboard APIs never inherit it. Exact match otherwise. The plugin's own string branch also accepted a * SCHEMELESS entry by stripping the scheme off the incoming header — which * made `box.local:3080` a scheme wildcard there (security-context.md). The * operator writer already refuses schemeless entries; this closes the branch @@ -33,6 +36,9 @@ const FRONTEND_DIST = new URL("../../web/dist", import.meta.url).pathname; */ export function corsOriginAllowed(request: Request): boolean { const origin = request.headers.get("origin"); + // Opaque preview documents may load their capability-bound resources. + // Never extend this exception to API routes or the dashboard sockets. + if (origin === "null" && /^\/pv\/[0-9a-f]{32}\//.test(new URL(request.url).pathname)) return true; return origin !== null && originRegistry().has(origin); } @@ -61,11 +67,22 @@ export function createApp() { // top-level path the static plugin's file branch would otherwise 404 // (it is not a dist file). spec 2026-08-31 §8. .use(installScriptRoute) + // Root-level `/pv//...` like install.sh above, and for the same + // reason: mounted AFTER the static plugin, every proxy path the pane + // browser loads would hit the file branch and 404. The session ref in + // the path is the credential (spec 2026-10-21 §6/§10); no cookie or + // bearer gate stands in front of it, and REQUEST_LOG_IGNORE keeps the + // secret out of request lines. + .use(pvHttpRoute) .use(authPlugin) // Disk dist wins when present (dev and checkout deployments behave alike); // else the SPA embedded by scripts/embed-web.ts; else boot fails loudly. .use(selectStaticPlugin(FRONTEND_DIST, EMBEDDED)) .use(wsPlugin) + // The proxy's WS half rides the same wildcard as pvHttpRoute; Elysia + // dispatches upgrade GETs here and plain GETs to the HTTP routes + // (spike-verified for one dynamic pattern across two modules). + .use(previewWsRoute()) .use(routes); return app; diff --git a/apps/server/api/src/services/__tests__/preview-broker.test.ts b/apps/server/api/src/services/__tests__/preview-broker.test.ts new file mode 100644 index 000000000..a086e6118 --- /dev/null +++ b/apps/server/api/src/services/__tests__/preview-broker.test.ts @@ -0,0 +1,1556 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + type NodeCommandBody, + type NodeEvent, + type NodePreviewPortsResult, + type NodePreviewScreenWire, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LANE_QUEUE_MAX, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewCloseReason, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { sql } from "kysely"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; +import { db } from "@/db/index.js"; +import type { ShareEntry } from "@/db/repositories/subshell-shares.repository.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import type { Access } from "@/lib/subshell-access.js"; +import { NodeRpcError } from "@/services/nodes/node-rpc.js"; +import { + configurePreviewStreamSender, + getPreviewBroker, + PreviewRefusal, + type PreviewStreamSender, + resetForTests, +} from "@/services/preview-broker.js"; +import { SubshellsService } from "@/services/subshells.service.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The plane-side preview broker (spec 2026-10-21 §5-§7, Task 8). Every seam + * is faked except the audit rows: those land in the file's own migrated DB, + * because the audit vocabulary is a deliverable operators read. + * + * The wire fakes record every command so a test can assert both what rode + * the link and what deliberately did not: the idempotent DELETE, the + * wire-only lane close, the node-reported close answered with nothing. + */ + +const NODE = "n-preview"; +const PANE = "p-preview-pane"; + +/** One valid 32-hex ref, for events naming a lane that never existed. */ +const GHOST_REF = "ab".repeat(16); + +interface Harness { + clock: { now: number }; + sent: { nodeId: string; cmd: NodeCommandBody }[]; + linked: { nodeId: string; frame: PreviewDataFrame }[]; + delivered: { socket: unknown; frame: Parameters[1] }[]; + accessCalls: { userId: string; paneId: string }[]; + /** Held acks: one entry per command whose ack is parked (what "in flight" counts). */ + held: { cmd: NodeCommandBody; release: () => void; refuse: (err: Error) => void }[]; +} + +interface FakeSeams { + cdpPort?: number; + ack?: (cmd: NodeCommandBody) => unknown; + /** Name the commands whose ack parks until the test releases/refuses it. */ + hold?: (cmd: NodeCommandBody) => boolean; + accessFor?: (userId: string, paneId: string) => Promise; + /** Pass `null` to build a broker with NO fan-out sender at all. */ + sendToSockets?: PreviewStreamSender | null; +} + +/** + * Builds the broker over named seams. The default ack answers every preview + * command: `preview_open` echoes the ref back (`pending` for a forward lane, + * a lie of `subscribers: 1` for the bridge, which the broker must ignore and + * count itself), `preview_close` accepts, and `preview_ports_get` reports an + * empty machine with no screen. + */ +function makeHarness(over: FakeSeams = {}): Harness { + const clock = { now: 1_780_000_000_000 }; + const h: Harness = { clock, sent: [], linked: [], delivered: [], accessCalls: [], held: [] }; + const defaultSendToSockets: PreviewStreamSender = (socket, frame) => { + h.delivered.push({ socket, frame }); + }; + resetForTests({ + now: () => clock.now, + send: async (nodeId: string, cmd: NodeCommandBody): Promise => { + h.sent.push({ nodeId, cmd }); + const ackFor = (): unknown => { + if (over.ack) return over.ack(cmd); + switch (cmd.type) { + case "preview_open": + return cmd.kind === "forward" ? { ref: cmd.ref, pending: true } : { ref: cmd.ref, subscribers: 1 }; + case "preview_close": + return { ref: cmd.ref, closed: true }; + case "preview_ports_get": + return { ports: [], cdpPort: over.cdpPort ?? null, screen: null }; + default: + throw new Error(`preview broker test: unexpected command ${cmd.type}`); + } + }; + if (over.hold?.(cmd)) { + return new Promise((resolve, reject) => { + h.held.push({ cmd, release: () => resolve(ackFor()), refuse: (err) => reject(err) }); + }); + } + return ackFor(); + }, + linkSend: (nodeId: string, frame: PreviewDataFrame): void => { + h.linked.push({ nodeId, frame }); + }, + accessFor: over.accessFor + ? async (userId: string, paneId: string): Promise => { + h.accessCalls.push({ userId, paneId }); + return (await over.accessFor?.(userId, paneId)) ?? "none"; + } + : undefined, + // `null` means "no sender configured", which is what production looks + // like until Task 11 calls configurePreviewStreamSender. + ...(over.sendToSockets === null ? {} : { sendToSockets: over.sendToSockets ?? defaultSendToSockets }), + }); + return h; +} + +/** Parked-ack helpers for the concurrency discriminators (no non-null assertions). */ +function releaseHeld(h: Harness): void { + h.held.shift()?.release(); +} + +function refuseHeld(h: Harness, err: Error): void { + h.held.shift()?.refuse(err); +} + +function cmds(h: Harness, type: T): Extract[] { + return h.sent.filter((c) => c.cmd.type === type).map((c) => c.cmd) as Extract[]; +} + +/** Lets fire-and-forget audit writes (real DB) land before the read. */ +async function settle(): Promise { + await new Promise((r) => setTimeout(r, 10)); +} + +async function auditRows(action: string): Promise<{ action: string; metadataJson: string | null }[]> { + return await db.selectFrom("auditEvents").where("action", "=", action).select(["action", "metadataJson"]).execute(); +} + +async function metadataOf(action: string): Promise[]> { + const rows = await auditRows(action); + return rows.map((r) => JSON.parse(String(r.metadataJson)) as Record); +} + +const SCREEN: NodePreviewScreenWire = { display: ":7", viewer: { protocol: "vnc", port: 5900 } }; + +function portsAnswer(over: Partial = {}): NodePreviewPortsResult { + return { ports: [{ proto: "tcp", port: 8080, pid: 11 }], cdpPort: 9222, screen: null, ...over }; +} + +/** + * A sendable fake of the exported structural socket interface. `withDebtMethod: + * false` builds one WITHOUT `getBufferedAmount` at all (the raw Bun socket + * carries the method, the Elysia wrapper carries neither form; Task 11's + * adapter supplies it, and a sibling fake without it must read as zero debt). + */ +function fakeSocket(opts: { debt?: () => number; withDebtMethod?: boolean } = {}): { + frames: string[]; + send(data: string): unknown; + getBufferedAmount?: () => number; +} { + const frames: string[] = []; + const socket: { frames: string[]; send(data: string): unknown; getBufferedAmount?: () => number } = { + frames, + send: (data: string) => { + frames.push(data); + return data.length; + }, + }; + if (opts.withDebtMethod === false) return socket; + socket.getBufferedAmount = opts.debt ?? (() => 0); + return socket; +} + +const broker = getPreviewBroker; + +function streamEvent(ref: string, seq: number): Extract { + return { type: "preview_stream", ref, seq, w: 100, h: 50, ts: 1_780_000_000_000 + seq, blob: `blob-${seq}` }; +} + +/** The pane's live bridge (screencast session), for tests that never saw its ref. */ +function bridgeFor(paneId: string): ReturnType["sessionForPane"]> { + return broker().sessionForPane(paneId); +} + +beforeEach(async () => { + await ensureMigratedTestDb(); + await db.deleteFrom("auditEvents").execute(); +}); + +describe("openForward: the probe, the mint, the session", () => { + it("sends the probe at the lifetime cap, mints a 32-hex ref, closes the probe wire-only", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + + expect(ref).toMatch(/^[0-9a-f]{32}$/); + const opens = cmds(h, "preview_open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toEqual({ + type: "preview_open", + ref, + kind: "forward", + paneId: PANE, + port: 8080, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + // The probe lane closes on the wire (the node must not park a dead lane), + // and the close is wire-only: the audit pair names SESSIONS, not lanes. + expect(cmds(h, "preview_close")).toEqual([{ type: "preview_close", ref, reason: "viewer-left" }]); + + const openAudits = await metadataOf("node.preview_forward.open"); + expect(openAudits).toHaveLength(1); + expect(openAudits[0]).toEqual({ paneId: PANE, nodeId: NODE, port: 8080, kind: "forward" }); + // The ref is the /pv path credential: it appears in no audit row. + for (const row of await auditRows("node.preview_forward.open")) { + expect(row.metadataJson).not.toContain(ref); + } + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + + const session = broker().sessionFor(ref); + expect(session).not.toBeNull(); + expect(session?.kind).toBe("forward"); + expect(session?.mode).toBe("http"); + expect(session?.createdBy).toBe("u-open"); + expect(session?.port).toBe(8080); + expect(session?.cookieJar.size).toBe(0); + }); + + it("two opens for one pane mint DIFFERENT refs (the ref is a secret, not a slot)", async () => { + makeHarness(); + const a = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const b = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(a.ref).not.toBe(b.ref); + expect(b.ref).toMatch(/^[0-9a-f]{32}$/); + }); + + it("a node refusal propagates named and records nothing", async () => { + const h = makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_open") { + throw new NodeRpcError("failed", 'node "x" reported: refused', NODE, "preview-lane-over-node-cap"); + } + return { ports: [], cdpPort: null, screen: null }; + }, + }); + await expect( + broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }), + ).rejects.toMatchObject({ name: "NodeRpcError", detail: "preview-lane-over-node-cap" }); + expect(cmds(h, "preview_close")).toHaveLength(0); + expect(await auditRows("node.preview_forward.open")).toHaveLength(0); + }); + + it("mode is raw exactly at the screen's rfbPort, by the live ports answer first", async () => { + const h = makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const raw = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + const http = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(broker().sessionFor(raw.ref)?.mode).toBe("raw"); + expect(broker().sessionFor(http.ref)?.mode).toBe("http"); + // The screen came from getPorts alone: no display memory existed this run. + expect(cmds(h, "preview_open")).toHaveLength(2); + }); + + it("plane-restart regression: after resetForTests the ports answer ALONE still lands raw", async () => { + // Memory-only discrimination would ship `http` here and re-handshake raw + // RFB over an http proxy lane (decision 6). + makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const first = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + expect(broker().sessionFor(first.ref)?.mode).toBe("raw"); + + // Fresh broker (fresh memory), SAME fake ports answer. + makeHarness({ + ack: (cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer({ screen: SCREEN }) + : cmd.type === "preview_open" + ? { ref: cmd.ref, pending: true } + : {}, + }); + const second = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + expect(broker().sessionFor(second.ref)?.mode).toBe("raw"); + }); + + it("mac leg: a refused ports walk falls back to the onDisplayOpened memory", async () => { + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + throw new NodeRpcError("failed", "refused", NODE, "preview-ports-unsupported"); + } + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + return {}; + }, + }); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const raw = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5900 }); + const http = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + expect(broker().sessionFor(raw.ref)?.mode).toBe("raw"); + expect(broker().sessionFor(http.ref)?.mode).toBe("http"); + }); +}); + +describe("lane pools: the per-pane cap, the queue, the close", () => { + it("four lanes open, the fifth through twelfth queue, the thirteenth refuses; a close promotes a waiter", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + + const lanes: string[] = []; + for (let i = 0; i < 4; i += 1) { + const lane = await broker().openLaneForConnection(ref); + expect(lane.laneRef).toMatch(/^[0-9a-f]{32}$/); + lanes.push(lane.laneRef); + } + expect(lanes).toEqual([...new Set(lanes)]); + + // Eight queued waiters: pending, and they mint no lane while queued. + const settled: string[] = []; + const waiters = Array.from({ length: PREVIEW_LANE_QUEUE_MAX }, () => + broker() + .openLaneForConnection(ref) + .then((l) => { + settled.push(l.laneRef); + return l.laneRef; + }), + ); + await settle(); + expect(settled).toHaveLength(0); + expect(cmds(h, "preview_open")).toHaveLength(5); // probe + 4 lanes + + // The next in the door past the cap AND past the queue refuses named. + await expect(broker().openLaneForConnection(ref)).rejects.toBeInstanceOf(PreviewRefusal); + await expect(broker().openLaneForConnection(ref)).rejects.toMatchObject({ code: "preview-lane-over-cap" }); + + // Releasing one lane frees the node slot AND promotes the first waiter. + await broker().closeLaneForConnection(lanes[0], "viewer-left"); + await settle(); + expect(settled).toHaveLength(1); + const promoted = await waiters[0]; + expect(promoted).toMatch(/^[0-9a-f]{32}$/); + expect(promoted).not.toBe(lanes[0]); + + expect(cmds(h, "preview_open")).toHaveLength(6); + expect(cmds(h, "preview_close")).toContainEqual({ type: "preview_close", ref: lanes[0], reason: "viewer-left" }); + // Lane closes audit nothing; the pair belongs to the session. + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + // Seven waiters stay queued when this test ends, and the NEXT test's + // resetForTests shutdown rejects them. Marking those rejections handled + // here is what keeps the reset from surfacing as an unhandled error + // against a test that never touched the queue. + for (const w of waiters.slice(1)) w.catch(() => {}); + }); + + it("closeLaneForConnection frees the node slot immediately and ends subscribers with the reason", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + await broker().closeLaneForConnection(laneRef, "viewer-left"); + expect(cmds(h, "preview_close")).toContainEqual({ type: "preview_close", ref: laneRef, reason: "viewer-left" }); + expect(events).toEqual([{ closed: "viewer-left" }]); + // The lane is gone: a late byte send drops instead of resurrecting it. + h.linked.length = 0; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(h.linked).toHaveLength(0); + // And a completed one-shot response's close left the session open for + // its next connection, with no close audit. + expect(broker().sessionFor(ref)).not.toBeNull(); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + }); + + it("a closed pane's queued waiters are rejected, not left pending forever", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + for (let i = 0; i < 4; i += 1) await broker().openLaneForConnection(ref); + const waiter = broker().openLaneForConnection(ref); + await broker().closeForPane(PANE, "pane-gone"); + await expect(waiter).rejects.toBeInstanceOf(Error); + expect(cmds(h, "preview_open")).toHaveLength(5); + }); + + it("the lane cap is per PANE, counted across the pane's sessions", async () => { + const h = makeHarness(); + const a = await broker().openForward({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 8080 }); + const b = await broker().openForward({ viewerUserId: "u-b", paneId: PANE, nodeId: NODE, port: 8081 }); + await broker().openLaneForConnection(a.ref); + await broker().openLaneForConnection(a.ref); + const b1 = await broker().openLaneForConnection(b.ref); + await broker().openLaneForConnection(b.ref); + const settled: string[] = []; + void broker() + .openLaneForConnection(a.ref) + .then((l) => { + settled.push(l.laneRef); + }); + await settle(); + expect(settled).toHaveLength(0); + // A lane released under session B still promotes session A's waiter: + // the cap is the pane's, not the session's. + await broker().closeLaneForConnection(b1.laneRef, "viewer-left"); + await settle(); + expect(settled).toHaveLength(1); + void h; + }); + + it("sendLaneBytes rides the byte sender, not the command RPC; traffic keeps the idle clock off", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const got: Uint8Array[] = []; + broker().subscribeLane(laneRef, (e) => { + if ("bytes" in e) got.push(e.bytes); + }); + + h.clock.now += 60_000; + broker().sendLaneBytes(laneRef, new Uint8Array([0x48, 0x69])); + expect(h.linked).toHaveLength(1); + expect(h.linked[0]?.nodeId).toBe(NODE); + expect(h.linked[0]?.frame).toEqual({ type: "preview_data", ref: laneRef, dir: "in", blob: "SGk=" }); + // No command rode the RPC path for bytes: the split mirrors the relay's. + expect(h.sent.filter((c) => c.cmd.type === ("preview_data" as NodeCommandBody["type"]))).toHaveLength(0); + expect(got).toHaveLength(0); // an `in` send does not echo to subscribers + + // Keep traffic alive across one idle window: the sweep must skip it. + h.clock.now += PREVIEW_IDLE_MS; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(await broker().sweepIdle()).toBe(0); + expect(broker().sessionFor(ref)).not.toBeNull(); + + // And the OUT leg decodes to bytes for the lane's subscribers. + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "SGk=" }); + expect(Array.from(got[0] ?? [])).toEqual([0x48, 0x69]); + }); + + it("bytes over the frame cap: nothing rides the link, the lane dies over-cap, the session survives", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + broker().sendLaneBytes(laneRef, new Uint8Array(PREVIEW_FRAME_MAX_BYTES + 1)); + await settle(); + expect(h.linked).toHaveLength(0); + expect(events).toEqual([{ closed: "over-cap" }]); + expect(broker().sessionFor(ref)).not.toBeNull(); + // The ONE lane-level audit, named, carrying no ref and no byte content. + const rows = await metadataOf("node.preview_lane.over_cap"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, nodeId: NODE, port: 8080, kind: "forward", reason: "over-cap" }); + for (const row of await auditRows("node.preview_lane.over_cap")) { + expect(row.metadataJson).not.toContain(laneRef); + } + // A dead lane's later sends drop quietly. + h.linked.length = 0; + broker().sendLaneBytes(laneRef, new Uint8Array([1])); + expect(h.linked).toHaveLength(0); + }); + + it("a node lane frame naming an unknown ref, the wrong direction, or another node is dropped", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const got: Uint8Array[] = []; + broker().subscribeLane(laneRef, (e) => { + if ("bytes" in e) got.push(e.bytes); + }); + // An `in` leg from the node is nonsense (the plane owns that direction). + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "in", blob: "AQ==" }); + // A frame naming another node's ref: dropped (the socket's node is the fence). + broker().onNodeLaneFrame("n-other", { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: GHOST_REF, dir: "out", blob: "AQ==" }); + expect(got).toHaveLength(0); + expect(h.linked).toHaveLength(0); + }); + + it("a node-reported lane close ends subscribers with its reason, audits nothing, sends nothing back", async () => { + const h = makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + const events: ({ bytes: Uint8Array } | { closed: PreviewCloseReason })[] = []; + broker().subscribeLane(laneRef, (e) => events.push(e)); + + await broker().onNodeReportedClose(NODE, laneRef, "target-gone"); + await settle(); + expect(events).toEqual([{ closed: "target-gone" }]); + expect(broker().sessionFor(ref)).not.toBeNull(); + // Wire-only: the only preview_close on the wire is openForward's own probe + // close from the open (which names the session ref); nothing new is sent + // back for the lane the node already closed, and nothing is audited. + expect(cmds(h, "preview_close").filter((c) => c.ref !== ref)).toHaveLength(0); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + }); + + it("retains target greetings until the proxy subscribes, without replaying them twice", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5910 }); + const { laneRef } = await broker().openLaneForConnection(ref); + for (const text of ["RFB ", "003.008\n"]) { + broker().onNodeLaneFrame(NODE, { + type: "preview_data", + ref: laneRef, + dir: "out", + blob: Buffer.from(text).toString("base64"), + }); + } + const chunks: string[] = []; + broker().subscribeLane(laneRef, (event) => { + if ("bytes" in event) chunks.push(Buffer.from(event.bytes).toString()); + }); + expect(chunks.join("")).toBe("RFB 003.008\n"); + const replay: unknown[] = []; + broker().subscribeLane(laneRef, (event) => replay.push(event)); + expect(replay).toEqual([]); + }); + + it("closes rather than retaining unbounded data before the proxy subscribes", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 5910 }); + const { laneRef } = await broker().openLaneForConnection(ref); + for (let n = 0; n <= PREVIEW_LANE_BUFFER_MAX_BYTES; n += PREVIEW_FRAME_MAX_BYTES) { + broker().onNodeLaneFrame(NODE, { + type: "preview_data", + ref: laneRef, + dir: "out", + blob: Buffer.alloc(PREVIEW_FRAME_MAX_BYTES).toString("base64"), + }); + } + const events: unknown[] = []; + broker().subscribeLane(laneRef, (event) => events.push(event)); + expect(events).toEqual([{ closed: "target-gone" }]); + }); + + it("subscribeLane unsubscribes by the returned function and ends a missing lane immediately", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const { laneRef } = await broker().openLaneForConnection(ref); + let count = 0; + const off = broker().subscribeLane(laneRef, () => { + count += 1; + }); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + expect(count).toBe(1); + off(); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: laneRef, dir: "out", blob: "AQ==" }); + expect(count).toBe(1); + const late = broker().subscribeLane(GHOST_REF, (event) => { + expect(event).toEqual({ closed: "target-gone" }); + count += 1; + }); + late(); + expect(count).toBe(2); + }); + + it("lane callbacks never cross duplexes: one session's two lanes pump independently", async () => { + makeHarness(); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + const a = await broker().openLaneForConnection(ref); + const b = await broker().openLaneForConnection(ref); + const seen: string[] = []; + broker().subscribeLane(a.laneRef, () => seen.push("a")); + broker().subscribeLane(b.laneRef, () => seen.push("b")); + broker().onNodeLaneFrame(NODE, { type: "preview_data", ref: b.laneRef, dir: "out", blob: "AQ==" }); + expect(seen).toEqual(["b"]); + }); +}); + +describe("watch bridges: one per pane, refcounted, idempotent", () => { + it("zero->one sends preview_open and audits once; joins ride no wire and no audit", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const opens = cmds(h, "preview_open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toMatchObject({ type: "preview_open", kind: "screencast", paneId: PANE, port: 9222 }); + expect(opens[0]?.type === "preview_open" ? opens[0].ref : "").toMatch(/^[0-9a-f]{32}$/); + expect((await auditRows("node.preview_watch.open")).length).toBe(1); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(1); + + await broker().startWatch({ viewerUserId: "u-b", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(cmds(h, "preview_open")).toHaveLength(1); // the ack's `subscribers: 1` is a lie the broker ignores + expect((await auditRows("node.preview_watch.open")).length).toBe(1); + expect(bridgeFor(PANE)?.viewers.get("u-b")).toBe(1); + + await broker().stopWatch("u-a", PANE); + expect(cmds(h, "preview_close")).toHaveLength(0); + await broker().stopWatch("u-b", PANE); + const closes = cmds(h, "preview_close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ reason: "viewer-left" }); + const watchCloses = await metadataOf("node.preview_watch.close"); + expect(watchCloses).toHaveLength(1); + expect(watchCloses[0]).toMatchObject({ + paneId: PANE, + nodeId: NODE, + port: 9222, + kind: "screencast", + reason: "viewer-left", + }); + expect(bridgeFor(PANE)).toBeNull(); + }); + + it("two POSTs from ONE user are count 2, and the first DELETE leaves the bridge open", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(bridgeFor(PANE)?.viewers.get("u-one")).toBe(2); + + await broker().stopWatch("u-one", PANE); + expect(cmds(h, "preview_close")).toHaveLength(0); + expect(bridgeFor(PANE)?.viewers.get("u-one")).toBe(1); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + + await broker().stopWatch("u-one", PANE); + expect(cmds(h, "preview_close")).toHaveLength(1); + }); + + it("stopWatch after the zero-close, and after a node-reported teardown, answer ok and send nothing", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + await broker().stopWatch("u-one", PANE); + const after = h.sent.length; + expect(await broker().stopWatch("u-one", PANE)).toEqual({ ok: true }); + expect(h.sent.length).toBe(after); + expect(await auditRows("node.preview_watch.close")).toHaveLength(1); // not 2 + + // The post-teardown twin: the node's close zeroes the counts, and a + // DELETE arriving after it must not send a second preview_close into a + // dead bridge. + await broker().startWatch({ viewerUserId: "u-two", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = bridgeFor(PANE); + expect(bridge).not.toBeNull(); + await broker().onNodeReportedClose(NODE, String(bridge?.ref), "target-gone"); + await settle(); + const sentNow = h.sent.length; + expect(await broker().stopWatch("u-two", PANE)).toEqual({ ok: true }); + expect(h.sent.length).toBe(sentNow); + expect((await auditRows("node.preview_watch.close")).length).toBe(2); // the report's row, not a new one + }); + + it("a POST naming another port is refused watch-port-conflict and touches no count", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + await expect( + broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9333 }), + ).rejects.toMatchObject({ code: "watch-port-conflict" }); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(1); + expect(bridgeFor(PANE)?.viewers.size).toBe(1); + expect(cmds(h, "preview_open")).toHaveLength(1); + await settle(); + expect(await auditRows("node.preview_watch.open")).toHaveLength(1); + }); + + it("the subscriber cap is PER BRIDGE: eight across three users fills it, the ninth refuses touching no count", async () => { + const h = makeHarness(); + const at = (u: string) => broker().startWatch({ viewerUserId: u, paneId: PANE, nodeId: NODE, port: 9222 }); + await at("u-a"); + await at("u-a"); + await at("u-a"); + await at("u-b"); + await at("u-b"); + await at("u-b"); + await at("u-c"); + await at("u-c"); + let sum = 0; + for (const n of bridgeFor(PANE)?.viewers.values() ?? []) sum += n; + expect(sum).toBe(PREVIEW_WATCH_SUBSCRIBERS_MAX); + + await expect(at("u-d")).rejects.toMatchObject({ code: "preview-watch-over-cap" }); + expect(bridgeFor(PANE)?.viewers.has("u-d")).toBe(false); + expect(cmds(h, "preview_open")).toHaveLength(1); + }); + + it("a node refusal on the bridge open propagates and records nothing", async () => { + const h = makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_open" && cmd.kind === "screencast") { + throw new NodeRpcError("failed", "refused", NODE, "preview-watch-over-node-cap"); + } + return {}; + }, + }); + await expect( + broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }), + ).rejects.toMatchObject({ detail: "preview-watch-over-node-cap" }); + expect(bridgeFor(PANE)).toBeNull(); + expect(await auditRows("node.preview_watch.open")).toHaveLength(0); + expect(cmds(h, "preview_close")).toHaveLength(0); + }); + + it("a node-reported BRIDGE close tears the plane's half down and the next startWatch re-opens", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = bridgeFor(PANE); + const socket = fakeSocket(); + expect(broker().attachStream(PANE, "k-a", "u-a", socket)).toEqual({ ok: true }); + + await broker().onNodeReportedClose(NODE, String(bridge?.ref), "target-gone"); + await settle(); + expect(bridgeFor(PANE)).toBeNull(); + const rows = await metadataOf("node.preview_watch.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, kind: "screencast", reason: "target-gone" }); + // The plane sends no preview_close for a bridge the node already killed. + expect(cmds(h, "preview_close")).toHaveLength(0); + + // The next startWatch hits the zero->one edge and re-sends preview_open, + // because "a matching port just joins" may only ever join a LIVE bridge. + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + expect(cmds(h, "preview_open")).toHaveLength(2); + const fresh = bridgeFor(PANE); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(fresh?.ref), 1)); + expect(h.delivered).toHaveLength(0); // the old socket stays detached; re-watch to see frames + broker().attachStream(PANE, "k-b", "u-a", fakeSocket()); + broker().onStreamEvent(NODE, streamEvent(String(fresh?.ref), 2)); + expect(h.delivered).toHaveLength(1); + }); +}); + +describe("stream sockets: the attach cap, the keying, the byte debt", () => { + it("never delivers another viewer's browser to a socket without a watch registration", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-editor", paneId: PANE, nodeId: NODE, port: 9333 }); + broker().attachStream(PANE, "unregistered", "u-viewer", fakeSocket()); + broker().onStreamEvent(NODE, streamEvent(String(bridgeFor(PANE)?.ref), 1)); + expect(h.delivered).toEqual([]); + }); + + it("revokes a downgraded editor watching a port outside the subshell's discovered browser", async () => { + const h = makeHarness({ accessFor: async (user) => (user === "u-editor" ? "view" : "owner") }); + await broker().startWatch({ viewerUserId: "u-editor", paneId: PANE, nodeId: NODE, port: 9333 }); + await broker().startWatch({ viewerUserId: "u-owner", paneId: PANE, nodeId: NODE, port: 9333 }); + const denied = fakeSocket(); + const owner = fakeSocket(); + broker().attachStream(PANE, "downgraded", "u-editor", denied); + broker().attachStream(PANE, "owner", "u-owner", owner); + await broker().closeUnauthorizedForPane(PANE); + broker().onStreamEvent(NODE, streamEvent(String(bridgeFor(PANE)?.ref), 1)); + expect(h.delivered.map((delivery) => delivery.socket)).toEqual([owner]); + expect(bridgeFor(PANE)?.viewers.has("u-editor")).toBe(false); + }); + + it("eight attach; the ninth is refused at attach with a viewer_drop audit while eight keep streaming", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-holder", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const sockets = Array.from({ length: PREVIEW_WATCH_SUBSCRIBERS_MAX }, () => fakeSocket()); + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker().attachStream(PANE, `k-${i}`, "u-holder", sockets[i])).toEqual({ ok: true }); + } + const ninth = fakeSocket(); + expect(broker().attachStream(PANE, "k-8", "u-holder", ninth)).toEqual({ + ok: false, + code: "preview-stream-over-cap", + }); + await settle(); + const drops = await metadataOf("node.preview_watch.viewer_drop"); + expect(drops).toHaveLength(1); + expect(drops[0]).toMatchObject({ paneId: PANE, reason: "over-cap" }); + expect(drops[0]).not.toHaveProperty("ref"); + // Nothing closed: the bridge still streams to the first eight. + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + expect(h.delivered).toHaveLength(PREVIEW_WATCH_SUBSCRIBERS_MAX); + expect(h.delivered.some((d) => d.socket === ninth)).toBe(false); + }); + + it("attach is key-based, never identity: two sockets under different keys fan out; detach stops frames", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-one", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const s1 = fakeSocket(); + const s2 = fakeSocket(); + // Two tabs, ONE holder: decision 7's two-tab case on the socket half. + expect(broker().attachStream(PANE, "conn-1", "u-one", s1)).toEqual({ ok: true }); + expect(broker().attachStream(PANE, "conn-2", "u-one", s2)).toEqual({ ok: true }); + + broker().onStreamEvent(NODE, streamEvent(bridge, 7)); + expect(h.delivered).toHaveLength(2); + expect(h.delivered[0]?.frame).toEqual({ type: "preview-stream", id: PANE, seq: 7, w: 100, h: 50, blob: "blob-7" }); + + // Elysia hands the NEXT callback a fresh wrapper; the caller passes the + // SAME per-connection id. An identity-keyed map finds nothing here. + broker().detachStream(PANE, "conn-1"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 8)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(s2); + + // detachSocketsFor walks every pane by key alone (the socket's `close` + // path hands the broker nothing but the id). + broker().detachSocketsFor("conn-2"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 9)); + expect(h.delivered).toHaveLength(0); + // And the detached keys freed their cap slots: eight can attach again. + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker().attachStream(PANE, `re-${i}`, "u-one", fakeSocket())).toEqual({ ok: true }); + } + }); + + it("byte debt over the max detaches THAT socket with a viewer_drop audit; the bridge and siblings keep running", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const slow = fakeSocket({ debt: () => PREVIEW_STREAM_DEBT_MAX + 1 }); + // The sibling WITHOUT the method at all: the `?? 0` read keeps its + // frames flowing (the property spelling is the trap the platform lacks). + const sibling = fakeSocket({ withDebtMethod: false }); + broker().attachStream(PANE, "k-slow", "u-a", slow); + broker().attachStream(PANE, "k-fast", "u-a", sibling); + + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + await settle(); + const drops = await metadataOf("node.preview_watch.viewer_drop"); + expect(drops).toHaveLength(1); + expect(drops[0]).toMatchObject({ paneId: PANE, nodeId: NODE, port: 9222, kind: "screencast", reason: "slow-link" }); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(bridge, 2)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(sibling); + // The lane and bridge untouched: the session still stands, and the drop + // closes nothing on the wire (spec §9 drops slow VIEWERS, never the bridge). + expect(bridgeFor(PANE)).not.toBeNull(); + expect(cmds(h, "preview_close")).toHaveLength(0); + }); + + it("an unconfigured broker never fans out: send never reaches a socket by itself", async () => { + makeHarness({ sendToSockets: null }); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + const socket = fakeSocket(); + expect(broker().attachStream(PANE, "k-1", "u-a", socket)).toEqual({ ok: true }); + broker().onStreamEvent(NODE, streamEvent(bridge, 1)); + await settle(); + // The broker imports nothing from ws/ and never touches socket.send. + expect(socket.frames).toHaveLength(0); + }); + + it("configurePreviewStreamSender fills the fan-out slot for a broker built without a fake sender", async () => { + const seen: unknown[] = []; + // Boot order, as Task 11 calls it: the slot is filled first, and the + // broker built after it inherits the sender. The slot is first-call-wins: + // in a serial run the winner is decided by module-load order (this file's + // spy fills it before live-ws.ts's module init can), and a directory move + // would flip it. Under the parallel regime every file gets its own + // process, so there is no contest there. + configurePreviewStreamSender((_socket, frame) => { + seen.push(frame); + }); + resetForTests({ + send: async (_n, cmd) => + cmd.type === "preview_ports_get" + ? portsAnswer() + : cmd.type === "preview_open" + ? { ref: cmd.ref, subscribers: 1 } + : {}, + }); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const bridge = String(bridgeFor(PANE)?.ref); + broker().attachStream(PANE, "k-1", "u-a", fakeSocket()); + broker().onStreamEvent(NODE, streamEvent(bridge, 3)); + expect(seen).toHaveLength(1); + }); + + it("an event naming an unknown ref, or another node's bridge, fans out to nobody", async () => { + const h = makeHarness(); + await broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + broker().attachStream(PANE, "k-1", "u-a", fakeSocket()); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(GHOST_REF, 1)); + broker().onStreamEvent("n-other", streamEvent(String(bridgeFor(PANE)?.ref), 2)); + expect(h.delivered).toHaveLength(0); + }); +}); + +describe("idle sweep: forward only", () => { + it("closes the idle forward session with `idle` and leaves a watched bridge with zero frames untouched", async () => { + const h = makeHarness(); + const fwd = await broker().openForward({ viewerUserId: "u-open", paneId: "p-fwd", nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-watch", paneId: PANE, nodeId: NODE, port: 9222 }); + + h.clock.now += PREVIEW_IDLE_MS; + const closed = await broker().sweepIdle(); + await settle(); + expect(closed).toBe(1); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor(PANE)).not.toBeNull(); // watched bridge: zero frames, zero closes + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: "p-fwd", reason: "idle" }); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + // A wire close rode for the lane with the named reason (the session ref + // was the probe, already closed on the node at open). + expect(cmds(h, "preview_close").filter((c) => c.reason === "idle")).toHaveLength(1); + }); +}); + +describe("closeForPane and the node/user sweeps", () => { + it("closeForPane(pane-gone) closes forward sessions, the bridge, and the display, auditing every pair", async () => { + const h = makeHarness(); + const fwd = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-watch", paneId: PANE, nodeId: NODE, port: 9222 }); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const other = await broker().openForward({ viewerUserId: "u-open", paneId: "p-other", nodeId: NODE, port: 8081 }); + + await broker().closeForPane(PANE, "pane-gone"); + await settle(); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor(PANE)).toBeNull(); + expect(broker().sessionFor(other.ref)).not.toBeNull(); // no bleed + + expect(await metadataOf("node.preview_forward.close")).toEqual([ + { paneId: PANE, nodeId: NODE, port: 8080, kind: "forward", reason: "pane-gone" }, + ]); + expect((await metadataOf("node.preview_watch.close"))[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + expect((await metadataOf("node.preview_display.close"))[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + // Lane and bridge rode the wire with the named reason. The forward + // SESSION adds no frame: its ref was the probe, already closed at open, + // so the node holds live state only for the lane. + expect(cmds(h, "preview_close").filter((c) => c.reason === "pane-gone")).toHaveLength(2); + }); + + it("closeAllForNode sweeps the node's sessions and display with node-dropped", async () => { + const h = makeHarness(); + const mine = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(mine.ref); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const otherNode = await broker().openForward({ + viewerUserId: "u-open", + paneId: "p-n2", + nodeId: "n-different", + port: 8080, + }); + + await broker().closeAllForNode(NODE, "node-dropped"); + await settle(); + expect(broker().sessionFor(mine.ref)).toBeNull(); + expect(broker().sessionFor(otherNode.ref)).not.toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, nodeId: NODE, reason: "node-dropped" }); + expect((await metadataOf("node.preview_display.close"))[0]).toMatchObject({ paneId: PANE, reason: "node-dropped" }); + // The lane frame (the session ref was the probe, closed at open). + expect(cmds(h, "preview_close").filter((c) => c.reason === "node-dropped")).toHaveLength(1); + }); + + it("closeAllForUser sweeps the user's forwards, watch counts, and held streams with revoked", async () => { + const h = makeHarness(); + const theirs = await broker().openForward({ viewerUserId: "u-victim", paneId: PANE, nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(theirs.ref); + const mine = await broker().openForward({ + viewerUserId: "u-bystander", + paneId: "p-other", + nodeId: NODE, + port: 8080, + }); + // Their watch count on a bridge someone else started: it decrements out, + // and the bridge lives on under its remaining watcher. + await broker().startWatch({ viewerUserId: "u-bystander", paneId: "p-shared", nodeId: NODE, port: 9222 }); + await broker().startWatch({ viewerUserId: "u-victim", paneId: "p-shared", nodeId: NODE, port: 9222 }); + // Their stream socket on a bridge they did not create. + await broker().startWatch({ viewerUserId: "u-holder", paneId: "p-stream", nodeId: NODE, port: 9222 }); + broker().attachStream("p-stream", "k-v", "u-victim", fakeSocket()); + + await broker().closeAllForUser("u-victim", "revoked"); + await settle(); + expect(broker().sessionFor(theirs.ref)).toBeNull(); + expect(broker().sessionFor(mine.ref)).not.toBeNull(); + const shared = bridgeFor("p-shared"); + expect(shared).not.toBeNull(); + expect(shared?.viewers.has("u-victim")).toBe(false); + expect(shared?.viewers.get("u-bystander")).toBe(1); + // The victim's stream socket is detached, never closed: frames stop. + const streamBridge = bridgeFor("p-stream"); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(streamBridge?.ref), 1)); + expect(h.delivered).toHaveLength(0); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: PANE, reason: "revoked" }); + void h; + }); +}); + +describe("getPorts: the 5 second cache and the screenLost marker", () => { + it("caches for the window per pane, then re-RPCs", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + return portsAnswer(); + } + return {}; + }, + }); + await broker().getPorts(NODE, PANE); + await broker().getPorts(NODE, PANE); + expect(rpcs).toBe(1); + // Per-pane key: a different pane is a different cache slot. + await broker().getPorts(NODE, "p-other"); + expect(rpcs).toBe(2); + }); + + it("the cached answer carries screenLost through, marker and all", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + return { ...portsAnswer(), screenLost: "x11vnc" as const }; + } + return {}; + }, + }); + const first = await broker().getPorts(NODE, PANE); + expect(first.screenLost).toBe("x11vnc"); + const second = await broker().getPorts(NODE, PANE); + expect(second.screenLost).toBe("x11vnc"); + expect(rpcs).toBe(1); + }); + + it("no screenLost key at all when the RPC answer carried none (absent, never null)", async () => { + makeHarness({ + ack: (cmd) => (cmd.type === "preview_ports_get" ? portsAnswer() : {}), + }); + const answer = await broker().getPorts(NODE, PANE); + expect("screenLost" in answer).toBe(false); + const cached = await broker().getPorts(NODE, PANE); + expect("screenLost" in cached).toBe(false); + }); + + it("a node refusal rejects named and caches NOTHING; a malformed answer rejects", async () => { + let rpcs = 0; + makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") { + rpcs += 1; + if (rpcs === 1) throw new NodeRpcError("offline", "down", NODE); + return { not: "a ports answer" }; + } + return {}; + }, + }); + await expect(broker().getPorts(NODE, PANE)).rejects.toBeInstanceOf(NodeRpcError); + await expect(broker().getPorts(NODE, PANE)).rejects.toBeInstanceOf(Error); + expect(rpcs).toBe(2); // neither failure left a cache entry + }); +}); + +describe("display memory: onDisplayOpened / onDisplayClosed", () => { + it("audits open with {display, rfbPort} and close with the reason; double-close is idempotent", async () => { + makeHarness(); + broker().onDisplayOpened(NODE, PANE, SCREEN); + const opens = await metadataOf("node.preview_display.open"); + expect(opens).toHaveLength(1); + expect(opens[0]).toEqual({ paneId: PANE, nodeId: NODE, display: ":7", viewer: { protocol: "vnc", port: 5900 } }); + + await broker().onDisplayClosed(PANE, "pane-gone"); + const closes = await metadataOf("node.preview_display.close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ paneId: PANE, reason: "pane-gone" }); + + await broker().onDisplayClosed(PANE, "idle"); + expect(await metadataOf("node.preview_display.close")).toHaveLength(1); + // And a close for a pane with no display record audits nothing either. + await broker().onDisplayClosed("p-never-opened", "pane-gone"); + expect(await metadataOf("node.preview_display.close")).toHaveLength(1); + }); +}); + +describe("closeUnauthorizedForPane: the recompute, one case per transition", () => { + function accessMap(map: Record) { + return async (userId: string, paneId: string): Promise => map[`${userId}@${paneId}`] ?? "none"; + } + + it("an edit->view DOWNGRADE closes the grantee's forward session (lanes in, revoked) and keeps their watch streaming", async () => { + const h = makeHarness({ cdpPort: 9222, accessFor: accessMap({ "u-owner@p1": "owner", "u-grantee@p1": "view" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-grantee", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + await broker().startWatch({ viewerUserId: "u-grantee", paneId: "p1", nodeId: NODE, port: 9222 }); + broker().attachStream("p1", "k-g", "u-grantee", fakeSocket()); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + + expect(broker().sessionFor(fwd.ref)).toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: "p1", reason: "revoked" }); + // The view tier they still hold: watch count, bridge, and socket untouched. + const bridge = bridgeFor("p1"); + expect(bridge).not.toBeNull(); + expect(bridge?.viewers.get("u-grantee")).toBe(1); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(bridge?.ref), 1)); + expect(h.delivered).toHaveLength(1); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + expect(cmds(h, "preview_close").filter((c) => c.reason === "revoked")).toHaveLength(1); // the lane + }); + + it("a full removal sweeps BOTH tiers, empties the bridge to zero with revoked, and never closes the socket", async () => { + const h = makeHarness({ accessFor: accessMap({ "u-owner@p1": "owner", "u-stranger@p1": "none" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-stranger", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().startWatch({ viewerUserId: "u-stranger", paneId: "p1", nodeId: NODE, port: 9222 }); + const socket = fakeSocket(); + broker().attachStream("p1", "k-s", "u-stranger", socket); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(fwd.ref)).toBeNull(); + expect(bridgeFor("p1")).toBeNull(); + const closes = await metadataOf("node.preview_watch.close"); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ paneId: "p1", reason: "revoked" }); // not viewer-left + + // A NEW owner-run bridge proves the revoked holder's socket is gone from + // the pane's attach set while the socket object itself was never closed + // (the /ws/live feed outlives a single lost grant). + await broker().startWatch({ viewerUserId: "u-owner", paneId: "p1", nodeId: NODE, port: 9222 }); + const survivor = fakeSocket(); + broker().attachStream("p1", "k-o", "u-owner", survivor); + h.delivered.length = 0; + broker().onStreamEvent(NODE, streamEvent(String(bridgeFor("p1")?.ref), 1)); + expect(h.delivered).toHaveLength(1); + expect(h.delivered[0]?.socket).toBe(survivor); + }); + + it("the Everyone row's edit->view write closes every non-owner forward session; the owner's and a still-edit grantee's sit untouched", async () => { + makeHarness({ + accessFor: accessMap({ + "u-owner@p1": "owner", + "u-downgraded@p1": "view", + "u-still-edit@p1": "edit", + }), + }); + const owner = await broker().openForward({ viewerUserId: "u-owner", paneId: "p1", nodeId: NODE, port: 8080 }); + const downgraded = await broker().openForward({ + viewerUserId: "u-downgraded", + paneId: "p1", + nodeId: NODE, + port: 8081, + }); + const stillEdit = await broker().openForward({ + viewerUserId: "u-still-edit", + paneId: "p1", + nodeId: NODE, + port: 8082, + }); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(owner.ref)).not.toBeNull(); + expect(broker().sessionFor(stillEdit.ref)).not.toBeNull(); + expect(broker().sessionFor(downgraded.ref)).toBeNull(); + expect(await metadataOf("node.preview_forward.close")).toHaveLength(1); + }); + + it("a view->edit UPGRADE closes nothing (the recompute fires on every shares write, even widening ones)", async () => { + const h = makeHarness({ accessFor: accessMap({ "u-up@p1": "edit" }) }); + const fwd = await broker().openForward({ viewerUserId: "u-up", paneId: "p1", nodeId: NODE, port: 8080 }); + await broker().startWatch({ viewerUserId: "u-up", paneId: "p1", nodeId: NODE, port: 9222 }); + broker().attachStream("p1", "k-u", "u-up", fakeSocket()); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(fwd.ref)).not.toBeNull(); + expect(bridgeFor("p1")).not.toBeNull(); + // Nothing beyond the open's probe close: the sweep closes nothing on a + // widening write. + expect(cmds(h, "preview_close").filter((c) => c.ref !== fwd.ref)).toHaveLength(0); + expect(await auditRows("node.preview_forward.close")).toHaveLength(0); + expect(await auditRows("node.preview_watch.close")).toHaveLength(0); + }); + + it("no transition reaches a different pane's sessions", async () => { + const h = makeHarness({ accessFor: async () => "none" }); + const other = await broker().openForward({ + viewerUserId: "u-gone", + paneId: "p-elsewhere", + nodeId: NODE, + port: 8080, + }); + await broker().openForward({ viewerUserId: "u-gone", paneId: "p1", nodeId: NODE, port: 8080 }); + + await broker().closeUnauthorizedForPane("p1"); + await settle(); + expect(broker().sessionFor(other.ref)).not.toBeNull(); + expect(h.accessCalls.every((c) => c.paneId === "p1")).toBe(true); + }); +}); + +describe("the shares write performs the cut (real SubshellsService.setShares)", () => { + it("setShares fires closeUnauthorizedForPane: the downgraded grantee's forward session closes revoked", async () => { + const ctx = getRequestlessContext(); + const service = new SubshellsService({ db: ctx.db, log: getLogger(), repos: ctx.repos }); + const paneId = crypto.randomUUID(); + const owner = "u-shares-owner"; + const grantee = "u-shares-grantee"; + // setShares validates every grantee against the users table for real (the + // access seam fakes only the revocation recompute), so both principals + // must exist as rows. + for (const id of [owner, grantee]) { + await sql`INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) + VALUES (${id}, ${id}, ${`${id}@example.test`}, 1, datetime('now'), datetime('now'))`.execute(db); + } + await ctx.repos.subshells.create({ + id: paneId, + userId: owner, + harnessId: "terminal", + name: "preview shares row", + workingDir: "/tmp", + tmuxSocket: `subshell-${paneId.slice(0, 8)}`, + status: "running", + alive: 1, + nodeId: "n-preview", + ssh: null, + }); + // The fake answers the POST-WRITE access (the row is already replaced): + // the grantee resolves to `view`, under the forward tier they hold. + const h = makeHarness({ + accessFor: async (userId) => (userId === owner ? "owner" : userId === grantee ? "view" : "none"), + }); + const fwd = await broker().openForward({ viewerUserId: grantee, paneId, nodeId: "n-preview", port: 8080 }); + await broker().openLaneForConnection(fwd.ref); + + const entries: ShareEntry[] = [{ granteeUserId: grantee, permission: "view" }]; + await service.setShares(owner, paneId, entries, "cookie"); + await settle(); + + expect(broker().sessionFor(fwd.ref)).toBeNull(); + const rows = await metadataOf("node.preview_forward.close"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId, reason: "revoked" }); + expect(h.sent.some((c) => c.cmd.type === "preview_close" && c.cmd.reason === "revoked")).toBe(true); + await ctx.repos.subshells.delete(paneId).catch(() => {}); + }); +}); + +describe("concurrency: synchronous reservations and the one-bridge-per-pane edge (review 2026-10-23)", () => { + it("pane at three lanes: two concurrent opens send the node ONE lane open; the second queues and a close promotes it", async () => { + let probeRef = ""; + let holdLanes = false; + const h = makeHarness({ + hold: (cmd) => holdLanes && cmd.type === "preview_open" && cmd.kind === "forward" && cmd.ref !== probeRef, + }); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + probeRef = ref; + const lanes: string[] = []; + for (let i = 0; i < 3; i += 1) { + const lane = await broker().openLaneForConnection(ref); + lanes.push(lane.laneRef); + } + holdLanes = true; + + const pA = broker().openLaneForConnection(ref); + const pB = broker().openLaneForConnection(ref); + await settle(); + // Exactly ONE open in flight (A's, held). B reserved its slot + // synchronously and queued without ever reaching the node: two opens in + // `held` would mean both calls passed the check before either registered. + expect(h.held).toHaveLength(1); + + releaseHeld(h); // A's ack lands; A owns the fourth slot + await expect(pA).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + expect(h.held).toHaveLength(0); // B is queued, not flying + + // Closing one lane frees the slot AND promotes B: its open is now the + // one in flight. The close itself awaits the promoted open (dequeue runs + // inside the release), so the release comes before awaiting the close. + const closing = broker().closeLaneForConnection(lanes[0], "viewer-left"); + await settle(); + expect(h.held).toHaveLength(1); + releaseHeld(h); + await closing; + await expect(pB).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + // Exactly four live lanes and an empty queue: six lane-shaped opens ever + // reached the node (probe + three + A + B), and a seventh caller cannot + // sneak one past: it queues without touching the wire. + const extra = broker().openLaneForConnection(ref); + extra.catch(() => {}); // the reset shutdown will reject it; that is this test's own teardown + await settle(); + expect(h.held).toHaveLength(0); + expect(cmds(h, "preview_open").filter((c) => c.kind === "forward")).toHaveLength(6); + }); + + it("two concurrent startWatch on a cold pane: ONE preview_open reaches the node, both callers resolve ok, ONE close follows two stops", async () => { + const h = makeHarness({ hold: (cmd) => cmd.type === "preview_open" && cmd.kind === "screencast" }); + const p1 = broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + const p2 = broker().startWatch({ viewerUserId: "u-a", paneId: PANE, nodeId: NODE, port: 9222 }); + await settle(); + // One cold open per pane, ever: the second caller awaits the winner. Two + // held opens here is the orphan bridge (loser registered nowhere + // reachable, uncounted by sweepIdle) the review named. + expect(h.held).toHaveLength(1); + expect(cmds(h, "preview_open").filter((c) => c.kind === "screencast")).toHaveLength(1); + releaseHeld(h); + await expect(p1).resolves.toEqual({ ok: true }); + await expect(p2).resolves.toEqual({ ok: true }); + const bridge = String(bridgeFor(PANE)?.ref); + expect(bridge).toMatch(/^[0-9a-f]{32}$/); + expect(bridgeFor(PANE)?.viewers.get("u-a")).toBe(2); // the joiner incremented the SAME bridge + expect(await metadataOf("node.preview_watch.open")).toHaveLength(1); // not one row per caller + + await broker().stopWatch("u-a", PANE); + await broker().stopWatch("u-a", PANE); + const closes = cmds(h, "preview_close").filter((c) => c.ref === bridge); + expect(closes).toHaveLength(1); + expect(closes[0]).toMatchObject({ reason: "viewer-left" }); + }); + + it("a refused lane open releases its reservation, and the freed slot still promotes the queued waiter", async () => { + let probeRef = ""; + let holdLanes = false; + const h = makeHarness({ + hold: (cmd) => holdLanes && cmd.type === "preview_open" && cmd.kind === "forward" && cmd.ref !== probeRef, + }); + const { ref } = await broker().openForward({ viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }); + probeRef = ref; + for (let i = 0; i < 3; i += 1) await broker().openLaneForConnection(ref); + holdLanes = true; + + const pA = broker().openLaneForConnection(ref); + const pB = broker().openLaneForConnection(ref); + await settle(); + expect(h.held).toHaveLength(1); // B is queued behind A's reservation + + // A's node-side refusal must release the reservation through the lane + // close path: the slot frees and B is promoted at once. A's rejection + // waits on B's open behind it (the release chain runs the promotion), so + // both held acks are answered before awaiting either caller. + refuseHeld(h, new NodeRpcError("failed", "refused", NODE, "preview-sessions-exhausted")); + await settle(); + expect(h.held).toHaveLength(1); // B's promoted open, in flight + releaseHeld(h); + await expect(pA).rejects.toBeInstanceOf(NodeRpcError); + await expect(pB).resolves.toMatchObject({ laneRef: expect.stringMatching(/^[0-9a-f]{32}$/) }); + // No phantom lane holds a slot: A's refused reservation left nothing + // behind (probe + three + A's refusal + B's promotion = six forward opens + // ever sent, four of them live), so the next caller simply queues with + // no wire activity. + expect(cmds(h, "preview_open").filter((c) => c.kind === "forward")).toHaveLength(6); + const extra = broker().openLaneForConnection(ref); + extra.catch(() => {}); // the reset shutdown will reject it; that is this test's own teardown + await settle(); + expect(h.held).toHaveLength(0); + }); +}); + +describe("revocation during a preview open", () => { + it("removing a saved service cancels its pending open before a late node acknowledgement", async () => { + const h = makeHarness({ hold: (cmd) => cmd.type === "preview_open" }); + const b = getPreviewBroker(); + const service = { host: "example.com", protocol: "https" } as const; + const opening = b + .openForward({ viewerUserId: "u-race", paneId: PANE, nodeId: NODE, port: 443, serviceId: "saved", service }) + .then( + () => "opened", + () => "refused", + ); + await settle(); + expect(h.held).toHaveLength(1); + const command = cmds(h, "preview_open")[0]; + expect(command?.service).toEqual(service); + await b.removeService(PANE, "saved"); + releaseHeld(h); + expect(await opening).toBe("refused"); + expect(b.sessionFor(command.ref)).toBeNull(); + }); + for (const kind of ["forward", "screencast"] as const) { + for (const revoke of ["pane", "node", "account", "share"] as const) { + it(`${revoke} revocation cancels a pending ${kind} before its late acknowledgement`, async () => { + const h = makeHarness({ hold: (cmd) => cmd.type === "preview_open", accessFor: async () => "none" }); + const b = getPreviewBroker(); + const input = { viewerUserId: "u-race", paneId: PANE, nodeId: NODE, port: 8080 }; + const opening = (kind === "forward" ? b.openForward(input) : b.startWatch(input)).then( + () => "opened", + () => "refused", + ); + await settle(); + expect(h.held).toHaveLength(1); + const ref = cmds(h, "preview_open")[0]?.ref; + if (!ref) throw new Error("Missing pending ref"); + if (revoke === "pane") await b.closeForPane(PANE, "pane-gone"); + if (revoke === "node") await b.closeAllForNode(NODE, "node-dropped"); + if (revoke === "account") await b.closeAllForUser("u-race", "revoked"); + if (revoke === "share") await b.closeUnauthorizedForPane(PANE); + releaseHeld(h); + expect(await opening).toBe("refused"); + expect(b.sessionFor(ref)).toBeNull(); + expect(cmds(h, "preview_close").some((cmd) => cmd.ref === ref)).toBe(true); + }); + } + } +}); + +describe("preview authorization at publication", () => { + for (const kind of ["forward", "screencast"] as const) { + it(`rechecks authorization before publishing a ${kind} that already reached the node`, async () => { + const h = makeHarness({ hold: (cmd) => cmd.type === "preview_open" }); + let allowed = true; + let checks = 0; + const input = { + viewerUserId: "u-race", + paneId: PANE, + nodeId: NODE, + port: 8080, + authorize: async () => { + checks++; + if (!allowed) throw new Error("access removed"); + }, + }; + const b = getPreviewBroker(); + const outcome = (kind === "forward" ? b.openForward(input) : b.startWatch(input)).then( + () => "opened", + () => "refused", + ); + await settle(); + expect(h.held).toHaveLength(1); + allowed = false; + const ref = cmds(h, "preview_open")[0]?.ref; + if (!ref) throw new Error("Missing ref"); + releaseHeld(h); + expect(await outcome).toBe("refused"); + expect(checks).toBe(2); + expect(b.sessionFor(ref)).toBeNull(); + expect(cmds(h, "preview_close").some((cmd) => cmd.ref === ref)).toBe(true); + }); + } +}); + +it("retries authorization when a share sweep overtakes its permission read", async () => { + makeHarness({ accessFor: async () => "view" }); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + const b = broker(); + const result = b + .startWatch({ + viewerUserId: "u-race", + paneId: PANE, + nodeId: NODE, + port: 9333, + authorize: async () => { + reads++; + if (reads === 2) await gate; // stale editor answer after the node opened + if (reads > 2) throw new Error("port no longer allowed"); + }, + }) + .then( + () => "opened", + () => "refused", + ); + await settle(); + expect(reads).toBe(2); + await b.closeUnauthorizedForPane(PANE); + release(); + expect(await result).toBe("refused"); + expect(reads).toBe(3); +}); + +describe("uncertain preview opens", () => { + for (const failure of ["timeout", "malformed"] as const) { + for (const kind of ["probe", "bridge", "lane"] as const) { + it(`closes the allocated ref after a ${failure} ${kind} acknowledgement`, async () => { + let failOpen = false; + const h = makeHarness({ + ack: (cmd) => { + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + if (cmd.type === "preview_open") { + if (failOpen) { + if (failure === "timeout") throw new NodeRpcError("timeout", "late node", NODE); + return {}; + } + return { ref: cmd.ref, pending: true }; + } + throw new Error("unexpected command"); + }, + }); + const input = { viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }; + const session = kind === "lane" ? await broker().openForward(input) : null; + const sentBefore = h.sent.length; + failOpen = true; + const result = + kind === "lane" + ? broker().openLaneForConnection(session?.ref ?? "") + : kind === "bridge" + ? broker().startWatch(input) + : broker().openForward(input); + await expect(result).rejects.toBeInstanceOf(Error); + const sent = h.sent.slice(sentBefore).map((entry) => entry.cmd); + const open = sent.find((cmd) => cmd.type === "preview_open"); + expect(open).toBeDefined(); + expect(sent.some((cmd) => cmd.type === "preview_close" && cmd.ref === open?.ref)).toBe(true); + }); + } + } +}); + +describe("preview capability capacity", () => { + it("bounds retained forwarding capabilities even when they have no active lane", async () => { + makeHarness(); + const input = { viewerUserId: "u-open", paneId: PANE, nodeId: NODE, port: 8080 }; + for (let i = 0; i < 64; i++) await broker().openForward(input); + await expect(broker().openForward(input)).rejects.toMatchObject({ code: "preview-session-over-cap" }); + await broker().closeForPane(PANE, "pane-gone"); + await expect(broker().openForward(input)).resolves.toHaveProperty("ref"); + }); + it("bounds pending authorization before any node resource exists", async () => { + makeHarness(); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const input = { + viewerUserId: "u-open", + paneId: PANE, + nodeId: NODE, + port: 8080, + authorize: async () => { + await gate; + }, + }; + const pending = Array.from({ length: 32 }, () => broker().openForward(input)); + try { + await expect(broker().openForward(input)).rejects.toMatchObject({ code: "preview-session-over-cap" }); + } finally { + release(); + await Promise.all(pending); + } + }); +}); diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index 87a4ca3b9..ba13ef3f6 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -704,10 +704,10 @@ describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptan }); describe("grammar/version posture", () => { - it("relays a protocol-22-only world: the constant is pinned here as the broker's precondition", () => { + it("relays a protocol-24 world: the constant is pinned here as the broker's precondition", () => { // The handler refuses every relay frame from a pre-18 agent upstream; // this tripwire documents the dependency at the broker. - expect(NODE_PROTOCOL_VERSION).toBe(22); + expect(NODE_PROTOCOL_VERSION).toBe(24); }); }); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts index f173845b0..36fc6880a 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-view.test.ts @@ -69,3 +69,10 @@ describe("toSubshellView notification fields", () => { expect(toSubshellView(row(), "running", [], "edit").access).toBe("edit"); }); }); + +it("reports a virtual display only while its subshell is running", () => { + expect(toSubshellView(row(), "running", [], "owner", false, ":91").virtualDisplay).toBe(":91"); + expect(toSubshellView(row({ alive: 0 }), "running", [], "owner", false, ":91").virtualDisplay).toBeNull(); + expect(toSubshellView(row(), "terminated", [], "owner", false, ":91").virtualDisplay).toBeNull(); + expect(toSubshellView(row(), "running").virtualDisplay).toBeNull(); +}); diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index e77bcda48..e2f5a463f 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -7,6 +7,7 @@ import type { NodeCommandBody, NodeProbeEntry } from "@internal/subshell-protoco import { spawnSync } from "bun"; import { CamelCasePlugin, Kysely } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import * as initMigration from "@/db/migrations/0001-init.js"; import * as operatorUxMigration from "@/db/migrations/0002-operator-ux.js"; @@ -45,6 +46,7 @@ import { } from "@/services/nodes/preview-cache.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; import { defaultSubshellName, RestartInFlightSwapError, @@ -1776,6 +1778,166 @@ describe("applyRemoteExit — shared death transition (idempotent against sweep } }); + /* The preview belt over the shared death transition: the exit hook's close + * is the fast path, not the only close. If a crash loses the hook report, + * the sweep and the exit census land in #applyDeath, and THAT transition — + * not the route — must tear down the broker's state too, or a forward + * session and its browser stream sockets outlive the pane to the idle and + * lifetime caps. These tests fake the broker's wire seams but keep the + * real audit seam: the `node.preview_forward.close` row is the observable + * the belt is asserted through, and the same one an operator reads. */ + + /** The minimal broker seams: record the preview commands, answer like a node. */ + function previewBeltHarness(): { sent: NodeCommandBody[] } { + const sent: NodeCommandBody[] = []; + resetForTests({ + send: async (_nodeId: string, cmd: NodeCommandBody): Promise => { + sent.push(cmd); + switch (cmd.type) { + case "preview_open": + return { ref: cmd.ref, pending: true }; + case "preview_close": + return { ref: cmd.ref, closed: true }; + case "preview_ports_get": + return { ports: [], cdpPort: null, screen: null }; + default: + throw new Error(`manager preview belt test: unexpected command ${cmd.type}`); + } + }, + linkSend: () => {}, + }); + return { sent }; + } + + /** The pane's `node.preview_forward.close` audit metadata as landed. */ + async function forwardCloseRows(paneId: string): Promise[]> { + const rows = await db + .selectFrom("auditEvents") + .where("action", "=", "node.preview_forward.close") + .select(["metadataJson"]) + .execute(); + return rows + .map((r) => JSON.parse(String(r.metadataJson)) as Record) + .filter((m) => m.paneId === paneId); + } + + /** Polls a condition over 1 s: the belt is fire-and-forget by design. */ + async function untilDone(cond: () => Promise): Promise { + for (let i = 0; i < 100; i++) { + if (await cond()) return true; + await new Promise((r) => setTimeout(r, 10)); + } + return cond(); + } + + for (const action of ["terminate", "delete", "restart"] as const) { + it(`${action} revokes preview access before stopping the pane, without an exit hook`, async () => { + const nodeId = `explicit-${action}-preview`; + const off = nodeOnline(nodeId, []); + const f = remoteFixture(); + previewBeltHarness(); + const id = await seedNodeRow(nodeId, { alive: 1, presetId: null }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: "u-recon", + paneId: id, + nodeId, + port: 8080, + }); + let stopped = false; + f.launcher.killSubshell = async () => { + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + stopped = true; + if (action === "restart") throw new Error("stop test before revival"); + }; + // A foreign caller cannot revoke the owner's preview either. + if (action === "restart") { + expect(await f.manager.restartSubshell("foreign", id)).toBeNull(); + } else if (action === "delete") { + expect(await f.manager.deleteSubshell("foreign", id)).toBe(false); + } else { + expect(await f.manager.terminateSubshell("foreign", id)).toBe(false); + } + expect(getPreviewBroker().sessionFor(ref)).not.toBeNull(); + if (action === "restart") { + await expect(f.manager.restartSubshell("u-recon", id)).rejects.toThrow("stop test before revival"); + } else if (action === "delete") { + await f.manager.deleteSubshell("u-recon", id); + } else { + await f.manager.terminateSubshell("u-recon", id); + } + expect(stopped).toBe(true); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect(await forwardCloseRows(id)).toHaveLength(1); + } finally { + off(); + shutdownPreviewBrokerForTests(); + await subshellsRepo.delete(id); + } + }); + } + + it("a forward session closes with pane-gone when death lands WITHOUT the exit hook", async () => { + const nodeId = "exit-node-preview-belt"; + const off = nodeOnline(nodeId, []); + const f = remoteFixture(); + previewBeltHarness(); + const id = await seedNodeRow(nodeId, { alive: 1 }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: "u-recon", + paneId: id, + nodeId, + port: 8080, + }); + expect(getPreviewBroker().sessionFor(ref)?.kind).toBe("forward"); + // The crash case: the pane-died hook never reaches the plane, and the + // death is discovered by the sweep/census path — the shared transition. + await f.manager.applyRemoteExit(nodeId, id, 1, new Date().toISOString()); + const landed = await untilDone(async () => (await forwardCloseRows(id)).length > 0); + expect(landed).toBe(true); // the belt closed the session with an audit row + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + await expect(getPreviewBroker().openLaneForConnection(ref)).rejects.toThrow(/no live preview session/); + const rows = await forwardCloseRows(id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ paneId: id, nodeId, port: 8080, kind: "forward", reason: "pane-gone" }); + } finally { + off(); + shutdownPreviewBrokerForTests(); + await subshellsRepo.delete(id); + } + }); + + it("the hook fast path plus the death belt close ONCE: double-close is a no-op", async () => { + const nodeId = "exit-node-preview-double"; + const off = nodeOnline(nodeId, []); + const f = remoteFixture(); + previewBeltHarness(); + const id = await seedNodeRow(nodeId, { alive: 1 }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: "u-recon", + paneId: id, + nodeId, + port: 8080, + }); + // The exit hook's fast-path close, exactly as the route performs it... + await getPreviewBroker().closeForPane(id, "pane-gone"); + // ...then the death lands through the shared transition, and the belt + // re-closes a pane already closed. The close must not double-audit and + // must not disturb the death's own effects. + await f.manager.applyRemoteExit(nodeId, id, 1, new Date().toISOString()); + expect(f.pushes).toEqual([[id, "exited"]]); + await new Promise((r) => setTimeout(r, 50)); + expect(await forwardCloseRows(id)).toHaveLength(1); // ONE close row, not two + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + } finally { + off(); + shutdownPreviewBrokerForTests(); + await subshellsRepo.delete(id); + } + }); + it("a foreign node cannot report a death it does not own", async () => { const nodeId = "exit-node-owner"; const off = nodeOnline(nodeId, []); diff --git a/apps/server/api/src/services/backups/database.ts b/apps/server/api/src/services/backups/database.ts index 13264c310..3b5805894 100644 --- a/apps/server/api/src/services/backups/database.ts +++ b/apps/server/api/src/services/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0061-node-system-specs"; +export const LATEST_BACKUP_MIGRATION = "0067-display-provider-choices"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/services/nodes/__tests__/local-preview-launch.test.ts b/apps/server/api/src/services/nodes/__tests__/local-preview-launch.test.ts new file mode 100644 index 000000000..b6917c470 --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/local-preview-launch.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { getHarness, PreviewDisplays, TmuxRunner } from "@internal/pane-runtime"; +import { SUBSHELL_SERVER_DATA_DIR } from "@/constants.js"; +import { LocalLauncher } from "@/services/nodes/local-launcher.js"; +import type { LaunchPlan } from "@/services/nodes/node-launcher.js"; + +class Screens extends PreviewDisplays { + opened: string[] = []; + closed: string[] = []; + failure: Error | null = null; + envFailure: Error | null = null; + constructor() { + super(SUBSHELL_SERVER_DATA_DIR); + } + override async openFor(id: string) { + this.opened.push(id); + if (this.failure) throw this.failure; + return { display: ":91", viewer: { protocol: "vnc" as const, port: 5911 } }; + } + override launchEnv() { + if (this.envFailure) throw this.envFailure; + return { DISPLAY: ":91", XAUTHORITY: "/provider-owned/auth", CUSTOM_DISPLAY: "example" }; + } + override async closeFor(id: string) { + this.closed.push(id); + } +} +class Tmux extends TmuxRunner { + command = ""; + fail = false; + override newSubshell(_socket: string, _id: string, _cwd: string, cmd: string) { + this.command = cmd; + if (this.fail) throw new Error("spawn failed"); + } + override pipePane() {} + override killSubshell() {} +} +function plan(): LaunchPlan { + const harness = getHarness("terminal"); + if (!harness) throw new Error("terminal missing"); + return { + id: crypto.randomUUID(), + socket: "test", + harness, + binary: "/bin/sh", + cwd: tmpdir(), + subshellName: "test", + subshellEnv: {}, + preset: { + name: "test", + env: { DISPLAY: ":wrong", XAUTHORITY: "/wrong" }, + flags: [], + settings: null, + configIsolation: false, + }, + screen: { width: 1280, height: 800 }, + }; +} + +describe("local screen launch", () => { + it("passes the allocated display above preset env, and tears it down with the subshell", async () => { + const tmux = new Tmux(); + const displays = new Screens(); + const input = plan(); + const launcher = new LocalLauncher({ tmux, displays }); + await launcher.launch(input); + expect(displays.opened).toEqual([input.id]); + expect(tmux.command).toContain("DISPLAY=':91'"); + expect(tmux.command).toContain("XAUTHORITY='/provider-owned/auth'"); + expect(tmux.command).not.toContain(":wrong"); + await launcher.killSubshell(input.socket, input.id); + expect(displays.closed).toEqual([input.id]); + }); + it("releases the allocated screen if tmux fails", async () => { + const tmux = new Tmux(); + tmux.fail = true; + const displays = new Screens(); + const input = plan(); + await expect(new LocalLauncher({ tmux, displays }).launch(input)).rejects.toThrow("spawn failed"); + expect(displays.closed).toEqual([input.id]); + }); + it("names missing display dependencies before spawning a subshell", async () => { + const tmux = new Tmux(); + const displays = new Screens(); + displays.failure = new Error("preview-display-missing-requirements: Xvfb"); + await expect(new LocalLauncher({ tmux, displays }).launch(plan())).rejects.toThrow( + "Check the requirements in node settings", + ); + expect(tmux.command).toBe(""); + }); + it("releases the screen when its provider cannot supply launch settings", async () => { + const tmux = new Tmux(); + const displays = new Screens(); + displays.envFailure = new Error("provider launch settings unavailable"); + const input = plan(); + await expect(new LocalLauncher({ tmux, displays }).launch(input)).rejects.toThrow( + "provider launch settings unavailable", + ); + expect(displays.closed).toEqual([input.id]); + expect(tmux.command).toBe(""); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts index 2e901edef..361b46e9b 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-events.test.ts @@ -256,6 +256,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onSubshellsReport: () => {}, onMaintenance: () => {}, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -282,6 +283,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onExit: () => {}, onMaintenance: () => {}, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts index fa685e227..adf511142 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-ws-handler.test.ts @@ -6,6 +6,7 @@ import { NODE_CLOSE_UPDATE_REQUIRED, NODE_MAX_FRAME_BYTES, NODE_PROTOCOL_VERSION, + type NodeCommandBody, type NodeEvent, type RelayFrame, SSH_RELAY_FRAME_MAX_BYTES, @@ -22,6 +23,7 @@ import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.r import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { NodeKind, NodeTable } from "@/db/types/nodes.db-types.js"; import { subscribeLive } from "@/services/live-bus.js"; +import { getPreviewBroker, resetForTests as resetPreviewBrokerForTests } from "@/services/preview-broker.js"; import { type RelayBroker, setRelayBrokerForTests } from "@/services/ssh-relay.service.js"; import { resetInputHoldsForTests } from "@/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/ws/input-window.js"; @@ -51,7 +53,15 @@ import { type NodeWsDeps, type NodeWsSocket, } from "../node-ws-handler.js"; -import { relayFramesSeenFor, relayOverCapsSeenFor, resetRelayFramesForTests } from "../relay-frames.js"; +import { + previewClosesSeenFor, + previewFramesSeenFor, + previewStreamEventsSeenFor, + relayFramesSeenFor, + relayOverCapsSeenFor, + resetPreviewFramesForTests, + resetRelayFramesForTests, +} from "../relay-frames.js"; import { beginUpdate, readView, resetForTests, updateSwapped } from "../update-tracker.js"; /* ---------------------------- fakes ----------------------------- */ @@ -763,6 +773,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => seen.push([nodeId, reported]); }, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -820,6 +831,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => seen.push([nodeId, reported]); }, onSshEnabled: () => {}, + onPreviewEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -902,6 +914,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onSshEnabled: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onPreviewEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -964,6 +977,96 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The preview mirror (pane-preview spec Task 3) walks the same two frames + * into the same shape of hook as SSH: one reconciler for connect-time state + * and a mid-session report, and the rows below pin the DOOR: both carriers, + * the socket's nodeId, the lenient field. What the hook does with what it + * hears ("never adopt") lives in `preview-enabled.test.ts`. The switch in + * `node-ws-handler.ts` has no default branch, so an event arm that is simply + * missing passes every other suite, and these rows are the only thing the + * `preview_enabled` arm and the ready-path read answer to. + */ + describe("preview_enabled → the lifecycle hook (pane-preview spec Task 3)", () => { + /** Install a recording preview hook and return both the log and the uninstaller. */ + function recordPreviewEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: () => {}, + onPreviewEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING previewEnabled reconciles it", async () => { + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ previewEnabled: { on: true, changedAt: "2026-10-21T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-10-21T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles: silence is the fail-closed answer, not a skipped beat", async () => { + // A node with no mirror file reads REFUSED, which is precisely when the + // plane's row (say, ON from a flip during the outage) must push. This + // `undefined` report IS the fail-closed beat connect-time mirror repair + // lives or dies on, the same load-bearing cell the ssh quartet pins. + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the preview_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_enabled", on: false, changedAt: "2026-10-21T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-10-21T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose previewEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient exactly like `maintenance` and `sshEnabled`: the plane learns + // the drop as silence, and silence reconciles toward the row. + const { seen, off } = recordPreviewEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ previewEnabled: { on: "yes" } })), + ); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` @@ -2187,3 +2290,102 @@ describe("handleNodeMessage + handleNodeClose → the update tracker", () => { expect(readView().nodes.n1?.phase).toBe("working"); // the real one was never told }); }); + +/* Preview intake and the link-drop cut (pane-preview Task 8). The event + switch has NO default branch, so these rows are the only evidence the + three preview events reach the broker at all: a broker test that calls + the verbs directly cannot see a missing `case`, and a seam test that + calls relay-frames directly cannot see a switch that never routes. */ +describe("preview intake through the event switch", () => { + const LANE_REF = "ab".repeat(16); + + beforeEach(() => { + resetPreviewFramesForTests(); + }); + + it("preview_data reaches the lane-pump seam, keyed by the socket's nodeId", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_data", ref: LANE_REF, dir: "out", blob: "QUJD" }), + ); + expect(previewFramesSeenFor("n1")).toEqual({ frames: 1, lastRef: LANE_REF, lastDir: "out" }); + // The summary records counts, never byte content. + expect(JSON.stringify(previewFramesSeenFor("n1"))).not.toContain("QUJD"); + }); + + it("preview_stream reaches the fan-out seam", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_stream", ref: LANE_REF, seq: 3, w: 80, h: 24, ts: 1, blob: "QUJD" }), + ); + expect(previewStreamEventsSeenFor("n1")).toEqual({ events: 1, lastRef: LANE_REF, lastSeq: 3 }); + }); + + it("preview_closed reaches the close-report seam with its reason", async () => { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_closed", ref: LANE_REF, reason: "target-gone" }), + ); + expect(previewClosesSeenFor("n1")).toEqual({ reports: 1, lastRef: LANE_REF, lastReason: "target-gone" }); + }); + + it("an unknown preview reason is refused by the grammar and reaches no seam", async () => { + // The reason set is closed (plan decision 3); a half-sent refusal is + // dropped whole, like every other grammar refusal this file pins. + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "preview_closed", ref: LANE_REF, reason: "someone-liked-it" }), + ); + expect(previewClosesSeenFor("n1")).toBeUndefined(); + }); + + it("handleNodeClose cuts the node's preview sessions with node-dropped", async () => { + // The relay's `a-dropped` witness already lives in this hook; the broker + // needs the same one, or a link flap leaves sessions and lanes + // plane-side against refs the node has already forgotten. + const sent: NodeCommandBody[] = []; + resetPreviewBrokerForTests({ + send: async (_nodeId, cmd) => { + sent.push(cmd); + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: null, screen: null }; + if (cmd.type === "preview_open") return { ref: cmd.ref, pending: true }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + throw new Error(`unexpected command ${cmd.type}`); + }, + }); + try { + const { ref } = await getPreviewBroker().openForward({ + viewerUserId: "u-prev", + paneId: "p-prev", + nodeId: "n1", + port: 3000, + }); + const lane = await getPreviewBroker().openLaneForConnection(ref); + + const h = makeHarness(); + const ws = fakeSocket("n1"); + handleNodeOpen(OPEN_DEPS, ws); + await handleNodeMessage(h.deps, ws, readyFrame()); + expect(getLive("n1")).toBeDefined(); + + await handleNodeClose(h.deps, ws); + // The hook is fire-and-forget (a link-drop must not block the close + // path), so let its sweep land before reading. + await new Promise((r) => setTimeout(r, 20)); + expect(getPreviewBroker().sessionFor(ref)).toBeNull(); + expect( + sent.some((c) => c.type === "preview_close" && c.ref === lane.laneRef && c.reason === "node-dropped"), + ).toBe(true); + } finally { + resetPreviewBrokerForTests(); + } + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts new file mode 100644 index 000000000..2229866fd --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/preview-enabled.test.ts @@ -0,0 +1,405 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/api/__tests__/helpers/auth-tables.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { + decidePreviewEnabled, + getVirtualDisplayEnabled, + getWebPreviewsEnabled, + pushSetPreviewEnabled, + reconcilePreviewEnabled, + setPreviewEnabled, +} from "@/services/nodes/preview-enabled.js"; +import { attachScriptedNode, ok } from "@/test-helpers/scripted-node.js"; + +/** + * The preview switch's plane half (pane-preview spec Task 3): the decide table + * (pure, no DB, no socket, the ssh-enabled suite's pattern) and the hook + * body with one real row and one scripted node (its seam: no RPC mocks, the + * push travels the real `sendCommand` chain and lands in the scripted wire, + * which is what proves the `set_preview_enabled` frame parses against the + * shipped parser). + * + * The property this suite exists to pin is the DIRECTION, cloned verbatim from + * the SSH twin: the plane is the sole writer, so a reconcile NEVER adopts. + * Every disagreement ends with the row's value pushed down and the row itself + * unmoved: that push IS the corruption repair. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `preview-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("preview-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket. Every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `preview-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.preview_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +/** + * Narrow a stamp read back from the row to the `string` the wire shape wants. + * A null here IS a failure of the test's own subject, so it throws loudly + * rather than asserting `!` over a value that could be null (or absent). + */ +function stamped(value: string | null | undefined): string { + if (typeof value !== "string") throw new Error(`the row carries no preview stamp: ${String(value)}`); + return value; +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decidePreviewEnabled: the plane is authoritative, so stamps are never read", () => { + const row = (webPreviewsEnabled: 0 | 1, previewEnabledAt: string | null) => ({ + webPreviewsEnabled, + previewEnabledAt, + virtualDisplayEnabled: webPreviewsEnabled, + }); + + it("agreement is a noop: reported matches the row, either value", () => { + expect( + decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), { on: false, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight: there is no node-side writer to bargain with. + expect( + decidePreviewEnabled(row(1, "2026-10-21T12:00:00.000Z"), { on: true, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means OFF here: row-off agrees (noop), row-on pushes", () => { + // An absent mirror reads REFUSED on the machine, so a never-written or + // off row and a silent node already answer alike; the row saying ON faces + // a machine that refuses, and the row wins: that push is the repair. + expect(decidePreviewEnabled(row(0, null), undefined)).toBe("noop"); + expect(decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), undefined)).toBe("push-plane"); + }); + + it("disagreement pushes the row, in both directions, and never adopts: not even a NEWER report", () => { + // With no node-side writer, even a future-dated report is just bytes the + // row overwrites. This is the direction rule the task exists to pin. + expect( + decidePreviewEnabled(row(0, "2026-10-21T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-plane"); + expect( + decidePreviewEnabled(row(1, "2026-10-21T10:00:00.000Z"), { on: false, changedAt: "2026-10-21T09:00:00.000Z" }), + ).toBe("push-plane"); + }); + + it("the one disagreement with nothing to send is a noop, not an invented stamp", () => { + // Row never written + a node mirror saying ON (a data dir carried across + // from another plane, say): the row IS off, every plane-side gate reads + // the row, so the disagreement is inert, and a push would have to mint a + // `changedAt` for a write that never happened. + expect(decidePreviewEnabled(row(0, null), { on: true, changedAt: "2026-10-21T09:00:00.000Z" })).toBe("noop"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setPreviewEnabled: write, audit, push, one function", () => { + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + try { + await setPreviewEnabled(nodeId, ownerId, true); + const row = await nodes.findById(nodeId); + expect(row?.webPreviewsEnabled).toBe(1); + expect(row?.previewEnabledAt).not.toBeNull(); + await settle(); + // Exactly one audit row: the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: true, feature: "web-previews" }); + // The push is the row's OWN bytes: the wire frame the shipped parser + // accepted, the stored stamp included, not a re-stamped copy. + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { + type: "set_preview_enabled", + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + on: true, + webPreviewsOn: true, + displayOn: false, + changedAt: stamped(row?.previewEnabledAt), + }, + ]); + } finally { + scripted.detach(); + } + }); + + it("an OFF flip lands too: the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + try { + await setPreviewEnabled(nodeId, ownerId, false); + const row = await nodes.findById(nodeId); + expect(row?.webPreviewsEnabled).toBe(0); + expect(row?.previewEnabledAt).not.toBeNull(); + await settle(); + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { + type: "set_preview_enabled", + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + on: false, + changedAt: stamped(row?.previewEnabledAt), + }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node: the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect(setPreviewEnabled(nodeId, ownerId, true)).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.webPreviewsEnabled).toBe(1); + }); +}); + +/* ------------------ the fail-closed read -------------------------- */ + +describe("getWebPreviewsEnabled: the row is the record, read fail-CLOSED", () => { + it("a row that does not exist reads OFF", async () => { + await expect(getWebPreviewsEnabled("no-such-node")).resolves.toBe(false); + }); + + it("a fresh row reads OFF until someone flips it", async () => { + const nodeId = await mkNode(); + await expect(getWebPreviewsEnabled(nodeId)).resolves.toBe(false); + await setPreviewEnabled(nodeId, ownerId, true); + await expect(getWebPreviewsEnabled(nodeId)).resolves.toBe(true); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcilePreviewEnabled: decide, push the row, write nothing", () => { + it("agreement pushes NOTHING: the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcilePreviewEnabled(nodeId, { on: true, webPreviewsOn: true, displayOn: false, changedAt }); + await settle(); + expect(scripted.countOf("set_preview_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + try { + // The machine reports REFUSED (its mirror corrupted, say) under an ON + // row: the answer is the row pushed down, never the report pulled up. + await reconcilePreviewEnabled(nodeId, { on: false, changedAt: "2026-10-21T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_preview_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ + type: "set_preview_enabled", + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + on: true, + webPreviewsOn: true, + displayOn: false, + changedAt, + }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.previewEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row ON + node silent → push; and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + await setPreviewEnabled(nodeId, ownerId, true); + const changedAt = stamped((await nodes.findById(nodeId))?.previewEnabledAt); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's value is + // re-armed by the push (the decide table's same cell). + await reconcilePreviewEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_preview_enabled")).toEqual([ + { + type: "set_preview_enabled", + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + on: true, + webPreviewsOn: true, + displayOn: false, + changedAt, + }, + { + type: "set_preview_enabled", + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + on: true, + webPreviewsOn: true, + displayOn: false, + changedAt, + }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a future-dated report does not outrank anything: there is nothing to outrank", async () => { + // No adopt means nothing to clamp: an adopted stamp is the failure mode + // clamps guard against, and preview never adopts one to begin with. + const nodeId = await mkNode(); + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: "2026-10-21T10:00:00.000Z" }); + await reconcilePreviewEnabled(nodeId, { on: true, changedAt: "2099-01-01T00:00:00.000Z" }); + await settle(); + const row = await nodes.findById(nodeId); + expect(row?.webPreviewsEnabled).toBe(0); + expect(row?.previewEnabledAt).toBe("2026-10-21T10:00:00.000Z"); + expect(await auditFor(nodeId)).toHaveLength(0); // reconcile records nothing, ever + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect( + reconcilePreviewEnabled("not-a-node", { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetPreviewEnabled is a NO-OP for local: resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE: its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetPreviewEnabled(LOCAL_NODE_ID, { on: true, changedAt: "2026-10-21T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setPreviewEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await setPreviewEnabled(LOCAL_NODE_ID, ownerId, true); + expect((await nodes.findById(LOCAL_NODE_ID))?.webPreviewsEnabled).toBe(1); + // Restore whatever the shared test DB had, so no later suite inherits a + // server account with preview open (the switch ships OFF). The restore + // goes through the service, so this test leaves two + // node.preview_enabled.update audit rows on LOCAL_NODE_ID in THIS file's + // DB - harmless by design: each test file gets its own database, and + // audit-counting tests filter by targetId. + await setPreviewEnabled(LOCAL_NODE_ID, ownerId, false); + expect((await nodes.findById(LOCAL_NODE_ID))?.webPreviewsEnabled).toBe(0); + }); +}); + +it("keeps apps and display permissions independent across writes and reconciliation", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_preview_enabled: ok }); + try { + await setPreviewEnabled(nodeId, ownerId, true, "display"); + expect(await getWebPreviewsEnabled(nodeId)).toBe(false); + expect(await getVirtualDisplayEnabled(nodeId)).toBe(true); + await settle(); + const displayOnly = scripted.cmdsOf("set_preview_enabled").at(-1); + expect(displayOnly).toMatchObject({ on: true, webPreviewsOn: false, displayOn: true }); + const row = await nodes.findById(nodeId); + await reconcilePreviewEnabled(nodeId, { + on: true, + webPreviewsOn: true, + displayOn: false, + changedAt: stamped(row?.previewEnabledAt), + }); + await settle(); + expect(scripted.cmdsOf("set_preview_enabled").at(-1)).toEqual(displayOnly); + await setPreviewEnabled(nodeId, ownerId, true); + await setPreviewEnabled(nodeId, ownerId, false, "display"); + expect(await getWebPreviewsEnabled(nodeId)).toBe(true); + expect(await getVirtualDisplayEnabled(nodeId)).toBe(false); + } finally { + scripted.detach(); + } +}); + +it("reconciles provider selection independently of the permissions", () => { + const row = { + webPreviewsEnabled: 0, + virtualDisplayEnabled: 1, + virtualDisplayProvider: "native-example", + previewEnabledAt: "2026-10-25T00:00:00Z", + }; + const reported = { + on: true, + webPreviewsOn: false, + displayOn: true, + changedAt: row.previewEnabledAt, + displayProvider: "xvfb-x11vnc", + displayProviders: ["xvfb-x11vnc"], + }; + expect(decidePreviewEnabled(row, reported)).toBe("push-plane"); + expect(decidePreviewEnabled(row, { ...reported, displayProvider: "native-example" })).toBe("noop"); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/preview-frames.test.ts b/apps/server/api/src/services/nodes/__tests__/preview-frames.test.ts new file mode 100644 index 000000000..6a0e6c80c --- /dev/null +++ b/apps/server/api/src/services/nodes/__tests__/preview-frames.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { attachConnection, resetNodeRegistryForTests } from "@/services/nodes/node-registry.js"; +import { sendPreviewFrameOverNodeSocket } from "@/services/nodes/preview-frames.js"; + +afterEach(resetNodeRegistryForTests); +const frame = { type: "preview_data", ref: "a".repeat(32), dir: "in", blob: "QUJD" } as const; + +describe("preview transport queue", () => { + it("refuses queued bytes without advancing encryption state", () => { + let sealed = 0; + let sent = 0; + const conn = attachConnection("preview-queue", { + getBufferedAmount: () => 1_048_576, + send: () => { + sent++; + }, + close: () => {}, + }); + conn.link = { + sealFrame: () => { + sealed++; + return new Uint8Array(); + }, + } as never; + expect(() => sendPreviewFrameOverNodeSocket("preview-queue", frame)).toThrow(/buffer budget/); + expect(sealed).toBe(0); + expect(sent).toBe(0); + }); + for (const throws of [false, true]) + it(`closes a link after a sealed frame ${throws ? "throws" : "is dropped"}`, () => { + let closed = false; + const conn = attachConnection("preview-drop", { + send: () => { + if (throws) throw new Error("dropped"); + return 0; + }, + close: () => { + closed = true; + }, + }); + conn.link = { sealFrame: () => new Uint8Array([1]) } as never; + expect(() => sendPreviewFrameOverNodeSocket("preview-drop", frame)).toThrow(/dropped/); + expect(closed).toBe(true); + }); +}); diff --git a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts index ad7b86b21..09579db24 100644 --- a/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/remote-launcher.test.ts @@ -176,6 +176,9 @@ function nodeRow(over: Partial = {}): NodeTable { sshEnabled: 0, sshWideForwards: 0, sshEnabledAt: null, + webPreviewsEnabled: 0, + virtualDisplayEnabled: 0, + previewEnabledAt: null, // The 0049 mirror slot: NULL until the runtime-report task fills it. sshFingerprint: null, createdAt: "2026-09-01T00:00:00.000Z", diff --git a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts index e395f127d..6cf737767 100644 --- a/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/ssh-enabled.test.ts @@ -66,6 +66,15 @@ async function auditFor(targetId: string): Promise<{ actorUserId: string | null; /** Let the void-fired pushes land (the maintenance-apply posture). */ const settle = () => new Promise((r) => setTimeout(r, 25)); +// Signing can take longer than a fixed sleep on a loaded CI worker. +async function waitForPushes(scripted: ReturnType, count: number) { + const deadline = Date.now() + 2_000; + while (scripted.countOf("set_ssh_enabled") < count && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(scripted.countOf("set_ssh_enabled")).toBe(count); +} + beforeAll(async () => { await setupAuthTables(); ownerId = await mkUser(); @@ -139,7 +148,7 @@ describe("setNodeSshEnabled — write, audit, push, one function", () => { const row = await nodes.findById(nodeId); expect(row?.sshEnabled).toBe(1); expect(row?.sshEnabledAt).toBe(changedAt); - await settle(); + await waitForPushes(scripted, 1); // Exactly one audit row — the push adds nothing, and a second writer of // this act is precisely what the one-function shape exists to prevent. const rows = await auditFor(nodeId); @@ -162,7 +171,7 @@ describe("setNodeSshEnabled — write, audit, push, one function", () => { const row = await nodes.findById(nodeId); expect(row?.sshEnabled).toBe(0); expect(row?.sshEnabledAt).toBe("2026-10-07T11:00:00.000Z"); - await settle(); + await waitForPushes(scripted, 1); expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ { type: "set_ssh_enabled", on: false, changedAt: "2026-10-07T11:00:00.000Z" }, ]); @@ -188,7 +197,7 @@ describe("reconcileSshEnabled — decide, push the row, write nothing", () => { const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); const changedAt = "2026-10-07T10:00:00.000Z"; await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); - await settle(); + await waitForPushes(scripted, 1); try { // The `ready` that follows the push carries exactly what was written. await reconcileSshEnabled(nodeId, { on: true, changedAt }); @@ -204,12 +213,12 @@ describe("reconcileSshEnabled — decide, push the row, write nothing", () => { const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); const changedAt = "2026-10-07T10:00:00.000Z"; await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); - await settle(); + await waitForPushes(scripted, 1); try { // The machine reports REFUSED (its mirror corrupted, say) under an ON // row: the answer is the row pushed down — never the report pulled up. await reconcileSshEnabled(nodeId, { on: false, changedAt: "2026-10-07T13:00:00.000Z" }); - await settle(); + await waitForPushes(scripted, 2); const pushes = scripted.cmdsOf("set_ssh_enabled"); expect(pushes).toHaveLength(2); expect(pushes[1]).toEqual({ type: "set_ssh_enabled", on: true, changedAt }); // the row's bytes, stamp included @@ -224,13 +233,13 @@ describe("reconcileSshEnabled — decide, push the row, write nothing", () => { const scripted = attachScriptedNode(nodeId, { set_ssh_enabled: ok }); const changedAt = "2026-10-07T10:00:00.000Z"; await setNodeSshEnabled({ nodeId, on: true, changedAt, actorUserId: ownerId }); - await settle(); + await waitForPushes(scripted, 1); const auditsBefore = (await auditFor(nodeId)).length; try { // A wiped data dir: no mirror, no stamp to offer, the row's value is // re-armed by the push (the maintenance-decide table's same cell). await reconcileSshEnabled(nodeId, undefined); - await settle(); + await waitForPushes(scripted, 2); expect(scripted.cmdsOf("set_ssh_enabled")).toEqual([ { type: "set_ssh_enabled", on: true, changedAt }, { type: "set_ssh_enabled", on: true, changedAt }, diff --git a/apps/server/api/src/services/nodes/display-selection.ts b/apps/server/api/src/services/nodes/display-selection.ts new file mode 100644 index 000000000..bd2f3899b --- /dev/null +++ b/apps/server/api/src/services/nodes/display-selection.ts @@ -0,0 +1,37 @@ +import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + parseVirtualDisplayRequest, + storedDisplayProviderIds, + VIRTUAL_DISPLAY_PROVIDERS, + type VirtualDisplayRequest, +} from "@internal/subshell-protocol"; +import { HttpError } from "@/api/auth-guard.js"; +import { getRequestlessContext } from "@/lib/context.js"; + +/** Resolve once before saving a subshell; restart supplies its pinned provider. */ +export async function resolveNodeDisplayRequest( + nodeId: string, + request: VirtualDisplayRequest, + status = 400, +): Promise { + if (!parseVirtualDisplayRequest(request)) + throw new HttpError(status, "The virtual display settings are invalid. Choose a provider and display size again."); + const row = await getRequestlessContext().repos.nodes.findById(nodeId); + if (row?.virtualDisplayEnabled !== 1) + throw new HttpError( + status, + "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings before starting this subshell.", + ); + const provider = request.provider ?? row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; + if (!VIRTUAL_DISPLAY_PROVIDERS.some((p) => p.id === provider)) + throw new HttpError( + status, + "This virtual display provider is unavailable. Choose an available provider for a new subshell.", + ); + if (!storedDisplayProviderIds(row.virtualDisplayProviders).includes(provider)) + throw new HttpError( + status, + "This virtual display provider is disabled on the node. Enable it in node settings or choose another provider for a new subshell.", + ); + return { ...request, provider }; +} diff --git a/apps/server/api/src/services/nodes/local-launcher.ts b/apps/server/api/src/services/nodes/local-launcher.ts index 9752bb730..71a096b34 100644 --- a/apps/server/api/src/services/nodes/local-launcher.ts +++ b/apps/server/api/src/services/nodes/local-launcher.ts @@ -9,9 +9,15 @@ import { type HarnessPlugin, paneEnvFor, TmuxRunner, + type VirtualDisplayRuntime, validateWorkingDir, } from "@internal/pane-runtime"; +import { HttpError } from "@/api/auth-guard.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { getLocalPreviewRuntime, sweepLocalPreviewDisplays } from "@/preview/local-preview.js"; import { probePaneLogLaunch } from "@/services/mcp-resolve.js"; +import { screenLaunchRemedy } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { readLogTailFrom, TAIL_POLL_MS } from "./log-tail.js"; import type { LaunchPlan, NodeLauncher } from "./node-launcher.js"; @@ -20,8 +26,10 @@ import { subshellLogDir, subshellLogPath, subshellSshConfigPath } from "./subshe /** Today's exact tmux/fs behavior behind the launcher seam (spec §6.3). */ export class LocalLauncher implements NodeLauncher { readonly #tmux: TmuxRunner; + readonly #displayOverride?: VirtualDisplayRuntime; - constructor(deps: { tmux?: TmuxRunner } = {}) { + constructor(deps: { tmux?: TmuxRunner; displays?: VirtualDisplayRuntime } = {}) { + this.#displayOverride = deps.displays; this.#tmux = deps.tmux ?? new TmuxRunner(); // The log dir must exist before ANY pipe-pane: tmux runs `cat >> ` // once, and a missing parent dir makes it die silently — every pane @@ -54,6 +62,38 @@ export class LocalLauncher implements NodeLauncher { * logged (debug) and swallowed, exactly like the pre-seam revive did. */ async launch(plan: LaunchPlan): Promise { + let displayEnv: Record | undefined; + if (plan.screen) { + let allocated = false; + try { + if (!this.#displayOverride) await sweepLocalPreviewDisplays(); + await this.#displays().openFor(plan.id, plan.screen); + allocated = true; + displayEnv = this.#displays().launchEnv(plan.id); + } catch (err) { + if (allocated) await this.#displays().closeFor(plan.id); + const remedy = err instanceof Error ? screenLaunchRemedy(err.message) : null; + if (remedy) throw new HttpError(400, remedy); + throw err; + } + } + try { + await this.#launch(plan, displayEnv); + if (plan.screen) { + const screen = this.#displays().getFor(plan.id); + if (screen) getPreviewBroker().onDisplayOpened(LOCAL_NODE_ID, plan.id, screen); + } + } catch (err) { + if (plan.screen) await this.#displays().closeFor(plan.id); + throw err; + } + } + + #displays(): VirtualDisplayRuntime { + return this.#displayOverride ?? getLocalPreviewRuntime().displays; + } + + async #launch(plan: LaunchPlan, displayEnv?: Record): Promise { // SSH pane config (spec 2026-10-07 decision 4): whoever spawns the pane // writes the file, and for a local row that is THIS process — under the // SERVER's own dataDir, before the pane exists. The path is DERIVED, not @@ -74,6 +114,7 @@ export class LocalLauncher implements NodeLauncher { plan.mcp, plan.harnessSession, plan.reporter, + displayEnv, ); // The pane reports its OWN death through the same reporter prefix the // harness hooks use, so no new credential exists — but it does NOT arrive @@ -90,7 +131,7 @@ export class LocalLauncher implements NodeLauncher { // a preset may deliberately override `SUBSHELL_BASE_URL` (see // `paneEnvFor`), and a death report that went somewhere else than // the pane's own MCP calls would be a silent 401 nobody could explain. - const hookEnv = paneEnvFor(plan.subshellEnv, plan.preset, plan.mcp?.env); + const hookEnv = paneEnvFor(plan.subshellEnv, plan.preset, plan.mcp?.env, displayEnv); this.#tmux.newSubshell(plan.socket, plan.id, plan.cwd, cmd, exitHookFor(plan.reporter, hookEnv)); // Stream all pane output to a per-subshell log file for attach replay. const logFile = subshellLogPath(plan.id); @@ -163,11 +204,13 @@ export class LocalLauncher implements NodeLauncher { */ async terminate(socket: string, id: string): Promise { this.#tmux.run(["-L", socket, "kill-session", "-t", id], {}); + await this.#displays().closeFor(id); } /** Kills the subshell, swallowing "already gone" (TmuxRunner.killSubshell semantics). */ async killSubshell(socket: string, id: string): Promise { this.#tmux.killSubshell(socket, id); + await this.#displays().closeFor(id); } /** Unlinks the dead server's socket file on THIS host. See {@link NodeLauncher.cleanSocket}. */ diff --git a/apps/server/api/src/services/nodes/node-events.ts b/apps/server/api/src/services/nodes/node-events.ts index 45aae4d33..7756ad438 100644 --- a/apps/server/api/src/services/nodes/node-events.ts +++ b/apps/server/api/src/services/nodes/node-events.ts @@ -1,4 +1,9 @@ -import type { NodeEvent, NodeMaintenanceWire, NodeSshEnabledWire } from "@internal/subshell-protocol"; +import type { + NodeEvent, + NodeMaintenanceWire, + NodePreviewEnabledWire, + NodeSshEnabledWire, +} from "@internal/subshell-protocol"; /** * The backend event plane for `/ws/node` (spec 2026-08-31 §3.3, phase 2). @@ -112,6 +117,22 @@ export interface NodeLifecycleHooks { * field the lenient parser dropped — all of which mean REFUSED there) */ onSshEnabled(nodeId: string, reported: NodeSshEnabledWire | undefined): Promise | void; + /** + * The machine's own copy of its preview switch (pane-preview spec Task 3): + * from `ready.previewEnabled` at connect, or from the `preview_enabled` + * event when the mirror moved underneath the connection (in practice: + * corruption). + * + * The same hook-not-repository shape as {@link onSshEnabled}, the same one + * reconciler for both frames, and the same DIRECTION rule: the plane is + * this flag's only writer, so reconciliation never adopts, it pushes. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none (no file, a file that says off, or a malformed `ready` + * field the lenient parser dropped, all of which mean REFUSED there) + */ + onPreviewEnabled(nodeId: string, reported: NodePreviewEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/services/nodes/node-launcher.ts b/apps/server/api/src/services/nodes/node-launcher.ts index d7c534805..ce60303b3 100644 --- a/apps/server/api/src/services/nodes/node-launcher.ts +++ b/apps/server/api/src/services/nodes/node-launcher.ts @@ -1,4 +1,5 @@ import type { HarnessPlugin, McpRegistration, PresetDefinition, ReporterSpec } from "@internal/pane-runtime"; +import type { VirtualDisplayRequest } from "@internal/subshell-protocol"; /** One harness start, structured (spec 2026-08-31 §6.3). */ export interface LaunchPlan { @@ -58,6 +59,13 @@ export interface LaunchPlan { * (createSubshell stays strict). */ bestEffortLog?: boolean; + /** + * The virtual display size to request. The node's selected provider allocates + * it before the pane starts, refusing when its requirements aren't met. + * The local launcher uses the same provider contract on the server. + * Absent on every plain launch. + */ + screen?: VirtualDisplayRequest; } /** diff --git a/apps/server/api/src/services/nodes/node-registry.ts b/apps/server/api/src/services/nodes/node-registry.ts index 00acd2a70..f35cafaa5 100644 --- a/apps/server/api/src/services/nodes/node-registry.ts +++ b/apps/server/api/src/services/nodes/node-registry.ts @@ -49,6 +49,7 @@ export const OWNER_DISABLED_CLOSE_CODE = 4403; * drive fakes and the Bun ws object satisfies it structurally. */ export interface NodeSocket { + getBufferedAmount?(): number; /** * Send one frame; returns like Bun's `ws.send`. Text carries the legacy * JSON envelope; a Buffer carries an encrypted link's binary ciphertext — diff --git a/apps/server/api/src/services/nodes/node-ws-handler.ts b/apps/server/api/src/services/nodes/node-ws-handler.ts index 9dcab8fea..8ef1e2c53 100644 --- a/apps/server/api/src/services/nodes/node-ws-handler.ts +++ b/apps/server/api/src/services/nodes/node-ws-handler.ts @@ -31,6 +31,7 @@ import { import { loadNodeEncryptionKeys, nodeEncryptionPublicKey } from "@/services/nodes/node-encryption-keys.js"; import { announceNodePresence, projectNodeOffline } from "@/services/nodes/node-presence-announce.js"; import { bootstrapSshIdentityOnReady } from "@/services/nodes/ssh-identity.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { refireInputHoldsForNode } from "@/ws/input-hold.js"; import { dispatchOutput, getNodeLifecycleHooks } from "./node-events.js"; @@ -48,7 +49,14 @@ import { releaseHeld, } from "./node-registry.js"; import { binaryPayload, failConnPendings, resolveResult } from "./node-rpc.js"; -import { onNodeSocketClosed, onRelayFrame, onRelayFrameOverCap } from "./relay-frames.js"; +import { + onNodeSocketClosed, + onPreviewClosedReport, + onPreviewFrame, + onPreviewStreamEvent, + onRelayFrame, + onRelayFrameOverCap, +} from "./relay-frames.js"; import { nodeRouteTickets } from "./route-tickets.js"; import { recordNodeDisconnect, recordNodeReady } from "./update-tracker.js"; @@ -1098,6 +1106,12 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // cheap; everything slow lives in the push's own best-effort swallow. if (hooks) await hooks.onSshEnabled(nodeId, event.sshEnabled); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile ssh_enabled`); + // The preview mirror travels the same door (pane-preview spec Task 3) + // with the same decision behind it: the row is the record and never + // adopts, so the hook's whole cost is one read and at most one + // fire-and-forget push of the row's own value. + if (hooks) await hooks.onPreviewEnabled(nodeId, event.previewEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile preview_enabled`); // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity // record has no signing key is ASKED ONCE, and the answer files itself. // Fire-and-forget for the same reason the detect kick above is: the @@ -1180,6 +1194,21 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: ssh_enabled from ${nodeId} with no lifecycle hook`); return; } + case "preview_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's preview mirror moved mid-session. With no node-side + // writer there is no keyboard flip to carry: what arrives here is a + // file that changed by other means (corruption, chiefly: it flips the + // node to REFUSED silently, and this frame is how the plane learns to + // push the repair now rather than at the next reconnect). Same hook as + // `ready`: a mid-session report and a connect-time state are the same + // disagreement, and one reconciler keeps them answering alike. The + // switch has no default arm, so an event type added here without one + // lands dead. This arm is the whole dispatch for this frame. + if (hooks) await hooks.onPreviewEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: preview_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to @@ -1204,6 +1233,31 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: onRelayFrame(nodeId, event); return; } + case "preview_data": { + // The preview lane leg (pane-preview spec §4). The same upstream facts + // the relay arm relies on hold here: the grammar validated shape and + // the PREVIEW_FRAME_MAX_BYTES cap (an over-cap blob answers + // `parsePreviewFrame` with null and never reaches this arm), the link + // is encrypted, the held/supersede guards ran. The blob stays opaque + // through here too: relay-frames' seam hands the frame to the broker's + // pump unopened; this is the routing seam, not the broker. + onPreviewFrame(nodeId, event); + return; + } + case "preview_stream": { + // One screencast frame; the broker fans it out to the pane's attached + // /ws/live sockets (spec §6) and drops slow viewers by byte debt + // (§9). Nothing about this frame reaches a viewer verbatim. + onPreviewStreamEvent(nodeId, event); + return; + } + case "preview_closed": { + // The node volunteering a death it observed (plan decision 3): a lane, + // or a bridge whose plane-side half must go down WITH it so the next + // watch re-opens a live one. The reason set is closed by the grammar. + onPreviewClosedReport(nodeId, event.ref, event.reason); + return; + } } } @@ -1280,6 +1334,19 @@ export async function handleNodeClose(deps: NodeWsDeps, ws: NodeWsSocket): Promi // the node is NOT gone. Fire-and-forget by contract (relay-frames owns // the swallow). onNodeSocketClosed(nodeId); + // The preview broker's twin of that witness (pane-preview Task 8): with + // the link gone, every session, lane, bridge, and display record riding + // this node is plane-side state against refs the node has forgotten, so + // the sweep closes them named `node-dropped` (audited, and wire-only for + // the refs that still exist: an offline node takes its own closes with + // it). Fire-and-forget like the relay's: a link drop must not block the + // close path, and an unhooked broker would leave stale lanes parked + // until the 15-minute idle sweep. The superseded branch below calls + // nothing, for the same reason the relay's witness does not: a + // replacement socket means the node is NOT gone. + void getPreviewBroker() + .closeAllForNode(nodeId, "node-dropped") + .catch((err: unknown) => logger.warn(`preview node-drop cut for ${nodeId} threw: ${String(err)}`)); logger.debug(`node ws: ${nodeId} disconnected → offline`); return; } diff --git a/apps/server/api/src/services/nodes/preview-enabled.ts b/apps/server/api/src/services/nodes/preview-enabled.ts new file mode 100644 index 000000000..f29af347d --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-enabled.ts @@ -0,0 +1,215 @@ +import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + type NodePreviewEnabledWire, + storedDisplayProviderIds, +} from "@internal/subshell-protocol"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { getRequestlessContext } from "@/lib/context.js"; +import { audit } from "@/services/audit.js"; +import { sendCommand } from "@/services/nodes/node-rpc.js"; +import { logger } from "@/utils/logger.js"; + +/** + * The per-node preview switch (pane-preview spec Task 3), written in exactly + * one place: the posture `ssh-enabled.ts` established, cloned because the + * two flags share more than a name: both gate something that exposes content, + * both are opt-in per machine, and both mirror a file the plane alone writes. + * + * **The plane is the SOLE writer.** Same fact, same consequences: there is + * no `subshell preview-enabled` verb, no second stamp to race, and + * reconciliation never compares dates, never adopts, and answers every + * disagreement the same way: push the row. The differences from the SSH twin + * are what does NOT happen here: no relay broker to close (preview's lanes + * land in later tasks) and no wide-forwards cousin. + */ + +/** + * Read the switch the way every gate reads it: the row is the record, read + * fail-CLOSED. A row that does not exist, a column that was never flipped, + * and a stamp-less default all answer OFF. Watching panes is opt-in, so an + * unknown node is a refused node, never an error the caller must handle. + */ +export async function getWebPreviewsEnabled(nodeId: string): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + return row?.webPreviewsEnabled === 1; +} + +export async function getVirtualDisplayEnabled(nodeId: string): Promise { + const { repos } = getRequestlessContext(); + return (await repos.nodes.findById(nodeId))?.virtualDisplayEnabled === 1; +} + +/** The transport is available when either independently authorized feature needs it. */ +export async function getAnyPreviewEnabled(nodeId: string): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + return row?.webPreviewsEnabled === 1 || row?.virtualDisplayEnabled === 1; +} + +function previewWire(row: PreviewRow, changedAt: string): NodePreviewEnabledWire { + const webPreviewsOn = row.webPreviewsEnabled === 1; + const displayOn = row.virtualDisplayEnabled === 1; + return { + on: webPreviewsOn || displayOn, + changedAt, + displayProvider: row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + displayProviders: storedDisplayProviderIds(row.virtualDisplayProviders), + ...(webPreviewsOn === displayOn ? {} : { webPreviewsOn, displayOn }), + }; +} + +/** Flip one node's preview switch: row, audit, and the best-effort push, + * all in this one function, so no caller can perform half the act. The + * stamp is minted HERE (one change-site mints once); the route supplies the + * acting human, never a time. */ +export async function setPreviewEnabled( + nodeId: string, + actorUserId: string, + on: boolean, + feature: "web-previews" | "display" = "web-previews", + provider?: string, + providers?: string[], +): Promise { + const changedAt = new Date().toISOString(); + const { repos } = getRequestlessContext(); + const row = await repos.nodes.setPreviewEnabled(nodeId, { on, changedAt, feature, provider, providers }); + await audit({ + actorUserId, + action: "node.preview_enabled.update", + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify({ + on, + feature, + ...(provider ? { provider } : {}), + ...(providers ? { providers } : {}), + }), + }); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to, + // and a push that fails (the node is offline) costs nothing: the value + // re-lands at the node's next `ready` through {@link reconcilePreviewEnabled}. + if (row) pushSetPreviewEnabledBestEffort(nodeId, previewWire(row, changedAt)); +} + +/** The node-row fields {@link decidePreviewEnabled} reads. Nothing else is relevant. */ +type PreviewRow = Pick & + Partial>; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type PreviewDecision = "push-plane" | "noop"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE: it reads two values and answers; the caller performs. Cloned from + * `decideSshEnabled` verbatim in the one rule that matters: the plane is + * authoritative, so stamps are never read and NOTHING adopts. + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | off / never written | `noop`: silence means refusal HERE, and the row already refuses | + * | nothing | on | `push-plane`: the node has no file (or a lost one); give it ours | + * | the row's value | any value | `noop`: agreement, the steady state; a push here would echo forever | + * | the other value | with a stamp | `push-plane`: the row is the record; the machine is told, never asked | + * | the other value | never written | `noop`: nothing to send (no stamp exists), and the disagreement is INERT: every plane-side gate reads the row, so a stale mirror-on permits nothing the row permits | + * + * @param row - the plane's node row (the two preview fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported nothing (no file, a file that says off, or a malformed + * `ready` field the lenient parser dropped) + */ +export function decidePreviewEnabled(row: PreviewRow, reported: NodePreviewEnabledWire | undefined): PreviewDecision { + const rowOn = row.webPreviewsEnabled === 1; + const displayOn = row.virtualDisplayEnabled === 1; + // Silence reads as OFF, the node's own fail-closed default, which is the + // only reading under which "node lost its file while the row says off" is + // the quiet agreement it is. + if ( + (reported?.on ?? false) === (rowOn || displayOn) && + (reported?.webPreviewsOn ?? reported?.on ?? false) === rowOn && + (reported?.displayOn ?? reported?.on ?? false) === displayOn && + (reported?.displayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER) === + (row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER) && + JSON.stringify([...(reported?.displayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER])].sort()) === + JSON.stringify(storedDisplayProviderIds(row.virtualDisplayProviders).sort()) + ) + return "noop"; + // The one disagreement with nothing to send (row never written, node mirror + // on). The row IS off and the plane gates on the row, so an unstamped push + // would only mint a write time for a write that never happened. + if (!row.previewEnabledAt) return "noop"; + return "push-plane"; +} + +/** + * Send one node the preview state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no + * mirror file: its answer to the gate is the DB row itself. + * + * @param nodeId - the node to tell; `local` is a no-op + * @param state - the STORED value, relayed verbatim; never re-stamped here, + * or the relay would outrank the decision it is carrying + */ +export async function pushSetPreviewEnabled(nodeId: string, state: NodePreviewEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_preview_enabled", ...state }); +} + +/** + * {@link pushSetPreviewEnabled}, fire-and-forget with the failure logged. + * + * An offline node simply misses it: the flag is already on the plane's row, + * which is what refuses every preview act plane-side, and the node re-learns + * the value on its next `ready` through {@link reconcilePreviewEnabled}. + */ +export function pushSetPreviewEnabledBestEffort(nodeId: string, state: NodePreviewEnabledWire): void { + void pushSetPreviewEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`preview-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row. Body of the + * `onPreviewEnabled` lifecycle hook, fed by `ready.previewEnabled` and by the + * `preview_enabled` event a machine sends when its mirror moved underneath it. + * + * The whole hook is one read and at most one fire-and-forget push: nothing to + * stop (a preview flip closes no panes here; the lanes that could arrive in + * later tasks own their own cuts), nothing to write, nothing to await. A row + * that disagrees with the machine pushes; never the other direction. That + * rule is the point. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none + */ +export async function reconcilePreviewEnabled( + nodeId: string, + reported: NodePreviewEnabledWire | undefined, +): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const stamp = row.previewEnabledAt; + if (decidePreviewEnabled(row, reported) !== "push-plane" || stamp === null) return; + // (`decidePreviewEnabled` only answers "push-plane" when a stamp exists; the + // null check is what shows the type what the decide already proved.) + pushSetPreviewEnabledBestEffort(nodeId, previewWire(row, stamp)); +} + +/** Read the selected backend independently of whether new displays are enabled. */ +export async function getEnabledDisplayProviders(nodeId: string): Promise { + return storedDisplayProviderIds( + (await getRequestlessContext().repos.nodes.findById(nodeId))?.virtualDisplayProviders, + ); +} + +export async function getVirtualDisplayProvider(nodeId: string): Promise { + const { repos } = getRequestlessContext(); + return (await repos.nodes.findById(nodeId))?.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; +} diff --git a/apps/server/api/src/services/nodes/preview-frames.ts b/apps/server/api/src/services/nodes/preview-frames.ts new file mode 100644 index 000000000..74c422cd7 --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-frames.ts @@ -0,0 +1,67 @@ +import { + NODE_MAX_FRAME_BYTES, + PREVIEW_LANE_BUFFER_MAX_BYTES, + type PreviewDataFrame, +} from "@internal/subshell-protocol"; +import { getLive } from "@/services/nodes/node-registry.js"; +import { binaryPayload } from "@/services/nodes/node-rpc.js"; + +/** + * The plane-to-node leg of the preview lane (pane-preview spec §4, Task 8). + * A `preview_data` frame is a LINK frame, not a command: lane bytes never + * ride `sendCommand`, exactly as the relay split its own bytes from its + * commands. This file is the sanctioned twin of `sendRelayFrameOverNodeSocket` + * (ssh-relay.service.ts:651), kept separate rather than generalised because + * the relay sender is load-bearing for SSH and one clone is a smaller risk + * than one shared function with a union parameter. + */ + +/** Why one lane frame did not reach the node's socket. */ +export class PreviewSendError extends Error { + constructor( + readonly nodeId: string, + reason: string, + ) { + super(`preview frame to node "${nodeId}" not delivered: ${reason}`); + this.name = "PreviewSendError"; + } +} + +/** + * Write one preview lane frame to `nodeId`'s CURRENT live socket, sealed + * through its established link (any node that carries preview frames speaks + * protocol 23, whose sockets are handshake-classified). Throws + * {@link PreviewSendError} on every non-delivery: no live connection, a + * closing/superseded record, an unestablished link, a frame past the wire + * cap, or a failing write. The broker reads the throw as "this lane's node + * is gone" (the link-drop sweep then reaps the node's sessions named); a + * silent return would strand lane subscribers on a dead pipe. + */ +export function sendPreviewFrameOverNodeSocket(nodeId: string, frame: PreviewDataFrame): void { + const conn = getLive(nodeId); + if (!conn) throw new PreviewSendError(nodeId, "node has no live connection"); + if (conn.closing) throw new PreviewSendError(nodeId, "connection is closing (superseded or revoked)"); + if (!conn.link) throw new PreviewSendError(nodeId, "encrypted link is not established"); + const payload = JSON.stringify(frame); + if (Buffer.byteLength(payload) > NODE_MAX_FRAME_BYTES) { + throw new PreviewSendError(nodeId, `frame exceeds ${NODE_MAX_FRAME_BYTES} bytes`); + } + if ((conn.ws.getBufferedAmount?.() ?? 0) + Buffer.byteLength(payload) + 128 > PREVIEW_LANE_BUFFER_MAX_BYTES) { + throw new PreviewSendError(nodeId, "preview transport exceeded its buffer budget"); + } + try { + const sent = conn.ws.send(binaryPayload(conn.link.sealFrame(payload))); + if (sent === 0) { + // A sealed frame cannot be skipped in the secretstream. Reconnect + // instead of attempting subsequent traffic with divergent key state. + throw new Error("socket dropped the sealed frame"); + } + } catch (err) { + try { + conn.ws.close(1011, "preview transport dropped a frame"); + } catch { + /* already closed */ + } + throw new PreviewSendError(nodeId, `socket send failed: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/apps/server/api/src/services/nodes/preview-refusals.ts b/apps/server/api/src/services/nodes/preview-refusals.ts new file mode 100644 index 000000000..cbb1ab84c --- /dev/null +++ b/apps/server/api/src/services/nodes/preview-refusals.ts @@ -0,0 +1,79 @@ +/** + * The plane's mirror of the node-owned preview refusal words (pane-preview + * spec §6's naming rule: refusals surface NAMED, never reworded into + * silence). The words are authored on the node (`apps/node/agent/src/ + * preview/ports.ts`, `preview/display.ts`, `commands/preview-ports-exec.ts`) + * and arrive here verbatim inside `NodeRpcError.detail`; this module only + * pins the spellings and pairs each with the operator's remedy, the same + * shape `packages/subshell-protocol/src/ssh-errors.ts` is for ssh refusals. + * + * Some words throw bare and some carry a colon suffix naming which resource + * ran out (`preview-display-missing-requirements: x11vnc`), so matching is by + * WORD or WORD-with-suffix, never a bare substring. + */ + +/** The ports scan refused: this platform has no /proc walk (spec §6). */ +export const PREVIEW_PORTS_UNSUPPORTED = "preview-ports-unsupported"; +/** The ports scan refused: the pane is not running on that node anymore. */ +export const PREVIEW_PORTS_PANE_GONE = "preview-ports-pane-gone"; +/** The node's own preview switch is off; the word carries the remedy. */ +export const PREVIEW_NODE_OFF = "preview-node-off: turn on Preview for this node"; +/** A screen launch refused: the selected provider does not support this machine. */ +export const PREVIEW_DISPLAY_UNSUPPORTED = "preview-display-unsupported"; +/** A screen launch refused; the thrown message names an unmet requirement. */ +export const PREVIEW_DISPLAY_MISSING_REQUIREMENTS = "preview-display-missing-requirements"; +/** A screen launch refused; the detail names what failed to come up. */ +export const PREVIEW_DISPLAY_NOT_READY = "preview-display-not-ready"; +/** The open executor refused: the named pane is gone. */ +export const PREVIEW_PANE_GONE = "preview-pane-gone"; +/** The open executor refused: the port is not a CDP endpoint. */ +export const PREVIEW_NOT_CDP = "not-cdp"; + +/** + * Does a node refusal's detail name `word`? True for the bare word and for + * the word with its suffix (`word: something`), so the pair survives the + * agent's richer messages while a substring hit inside another word cannot + * create a false match. + */ +export function detailNamesWord(detail: string, word: string): boolean { + return detail === word || detail.startsWith(`${word}: `); +} + +/** A node switch-off refusal's detail: the bare prefix or the full remedy string. */ +export function detailIsNodeOff(detail: string): boolean { + return detailNamesWord(detail, "preview-node-off"); +} + +/** + * The screen-launch remedies, keyed by word. `launch` (Task 9's create path) + * renders these as 400s that name both the refusal and the next step; the + * node's own words ride along so the operator can grep them. + */ +export const SCREEN_LAUNCH_REMEDIES: Readonly> = { + "preview-display-provider-disabled": + "This display provider is disabled on the node. Enable it in node settings, or choose another provider for a new subshell.", + "preview-display-provider-conflict": + "This subshell already has a display from a different provider. Start a new subshell to use another provider.", + "preview-node-off": + "Virtual displays are disabled on this node. Turn on Allow virtual displays in node settings, then launch again.", + [PREVIEW_DISPLAY_UNSUPPORTED]: + "The selected virtual display provider is not supported on this node. Choose a supported provider in node settings, or launch without a virtual display.", + [PREVIEW_DISPLAY_MISSING_REQUIREMENTS]: + "The selected virtual display provider has unmet requirements. Check the requirements in node settings, or launch without a virtual display.", + [PREVIEW_DISPLAY_NOT_READY]: + "That node's virtual display did not come up inside the launch budget, so the screen pane was refused. Try again, or launch without a screen.", +}; + +/** + * The remedy for a screen launch the NODE refused: the paired sentence when + * the word is known (plus the node's own detail, verbatim), null for detail + * this mirror does not speak. + */ +export function screenLaunchRemedy(detail: string): string | null { + for (const [word, remedy] of Object.entries(SCREEN_LAUNCH_REMEDIES)) { + if (detail === word || detail.startsWith(`${word}: `)) { + return detail === word ? remedy : `${remedy} (${detail})`; + } + } + return null; +} diff --git a/apps/server/api/src/services/nodes/relay-frames.ts b/apps/server/api/src/services/nodes/relay-frames.ts index 776d3c8ec..928151da7 100644 --- a/apps/server/api/src/services/nodes/relay-frames.ts +++ b/apps/server/api/src/services/nodes/relay-frames.ts @@ -1,4 +1,5 @@ -import type { RelayFrame } from "@internal/subshell-protocol"; +import type { NodeEvent, PreviewCloseReason, PreviewDataFrame, RelayFrame } from "@internal/subshell-protocol"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { getRelayBroker } from "@/services/ssh-relay.service.js"; import { logger } from "@/utils/logger.js"; @@ -152,4 +153,132 @@ export function relayOverCapsSeenFor(nodeId: string): RelayOverCapRecord | undef export function resetRelayFramesForTests(): void { routedByNode.clear(); overCapByNode.clear(); + resetPreviewFramesForTests(); +} + +/* ------------------------------------------------------------------ */ +/* the preview intake (pane-preview spec §4, Task 8) */ +/* ------------------------------------------------------------------ */ + +/** + * The same door for the preview surface (protocol 23): `preview_data`, + * `preview_stream`, and `preview_closed` arrive on the identical + * authenticated, grammar-validated path the relay frames ride, and the + * preview broker owns everything past here (lane pumping, screencast + * fan-out, the reported-close teardown). Fire-and-forget for the same reason + * the relay legs are: a broker throw must not cost the socket the frames + * behind it. These records are also what the handler's dispatch rows assert, + * because the event switch has no default branch and only these rows can see + * a missing `case`. + */ + +/** What the seam records for one node's lane frames: counters and the lane ref, never a blob. */ +export interface PreviewLaneRouteRecord { + /** How many `preview_data` frames have been routed from this node. */ + frames: number; + /** The lane ref of the most recent frame (an opaque id). */ + lastRef: string; + /** The direction of the most recent frame (only `out` legs pump, but the record keeps either). */ + lastDir: PreviewDataFrame["dir"]; +} + +/** What the seam records for one node's screencast events. */ +export interface PreviewStreamRouteRecord { + /** How many `preview_stream` events have been routed from this node. */ + events: number; + /** The bridge ref of the most recent event. */ + lastRef: string; + /** The bridge counter of the most recent event (gap reporting is the broker's problem, not this seam's). */ + lastSeq: number; +} + +/** What the seam records for one node's reported closes. */ +export interface PreviewCloseRouteRecord { + /** How many `preview_closed` reports have been routed from this node. */ + reports: number; + /** The ref of the most recent report. */ + lastRef: string; + /** The named reason of the most recent report. */ + lastReason: string; +} + +const laneFramesByNode = new Map(); +const streamEventsByNode = new Map(); +const closeReportsByNode = new Map(); + +/** Route one lane frame to the broker's pump (the `out` leg to its subscribers). */ +export function onPreviewFrame(nodeId: string, frame: PreviewDataFrame): void { + const record = laneFramesByNode.get(nodeId); + if (record) { + record.frames += 1; + record.lastRef = frame.ref; + record.lastDir = frame.dir; + } else { + laneFramesByNode.set(nodeId, { frames: 1, lastRef: frame.ref, lastDir: frame.dir }); + } + try { + getPreviewBroker().onNodeLaneFrame(nodeId, frame); + } catch (err: unknown) { + logger.withError(err).warn(`preview lane routing for node ${nodeId} threw (contained): ${String(err)}`); + } +} + +/** Route one screencast frame to the broker's fan-out. */ +export function onPreviewStreamEvent(nodeId: string, ev: Extract): void { + const record = streamEventsByNode.get(nodeId); + if (record) { + record.events += 1; + record.lastRef = ev.ref; + record.lastSeq = ev.seq; + } else { + streamEventsByNode.set(nodeId, { events: 1, lastRef: ev.ref, lastSeq: ev.seq }); + } + try { + getPreviewBroker().onStreamEvent(nodeId, ev); + } catch (err: unknown) { + logger.withError(err).warn(`preview stream fan-out for node ${nodeId} threw (contained): ${String(err)}`); + } +} + +/** Route one node-reported close (a lane, or the plane's half of a bridge with it). */ +export function onPreviewClosedReport(nodeId: string, ref: string, reason: PreviewCloseReason): void { + const record = closeReportsByNode.get(nodeId); + if (record) { + record.reports += 1; + record.lastRef = ref; + record.lastReason = reason; + } else { + closeReportsByNode.set(nodeId, { reports: 1, lastRef: ref, lastReason: reason }); + } + void getPreviewBroker() + .onNodeReportedClose(nodeId, ref, reason) + .catch((err: unknown) => + logger.withError(err).warn(`preview reported-close cut for ${nodeId} threw: ${String(err)}`), + ); +} + +/** What the seam recorded for one node's lane frames (no blob slot; mirrors {@link relayFramesSeenFor}). + * @internal test-only */ +export function previewFramesSeenFor(nodeId: string): PreviewLaneRouteRecord | undefined { + return laneFramesByNode.get(nodeId); +} + +/** What the seam recorded for one node's screencast events. + * @internal test-only */ +export function previewStreamEventsSeenFor(nodeId: string): PreviewStreamRouteRecord | undefined { + return streamEventsByNode.get(nodeId); +} + +/** What the seam recorded for one node's reported closes. + * @internal test-only */ +export function previewClosesSeenFor(nodeId: string): PreviewCloseRouteRecord | undefined { + return closeReportsByNode.get(nodeId); +} + +/** Drop every preview seam record. + * @internal test-only */ +export function resetPreviewFramesForTests(): void { + laneFramesByNode.clear(); + streamEventsByNode.clear(); + closeReportsByNode.clear(); } diff --git a/apps/server/api/src/services/nodes/remote-launcher.ts b/apps/server/api/src/services/nodes/remote-launcher.ts index 407c632e7..24058a9d2 100644 --- a/apps/server/api/src/services/nodes/remote-launcher.ts +++ b/apps/server/api/src/services/nodes/remote-launcher.ts @@ -2,8 +2,10 @@ import { buildSshConfigPath, type HarnessPlugin } from "@internal/pane-runtime"; import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, + type NodePreviewScreenWire, type NodeProbeEntry, parseNodeCaptureResult, + parseNodeLaunchResult, parseNodeLogReadResult, parseNodePaneCursorResult, parseNodePaneSizeResult, @@ -12,8 +14,11 @@ import { parseNodePromptDeliver, parseNodeStatDirResult, } from "@internal/subshell-protocol"; +import { HttpError } from "@/api/auth-guard.js"; import type { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { getRequestlessContext } from "@/lib/context.js"; +import { screenLaunchRemedy } from "@/services/nodes/preview-refusals.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { logger } from "@/utils/logger.js"; import { detectOnNodeBestEffort, readAgentInventory } from "./inventory.js"; import { LOG_TAIL_BYTES, tailLinesFromWindowText } from "./log-tail.js"; @@ -92,6 +97,13 @@ export interface RemoteLauncherDeps { * caught by the caller — the kick is total. */ detect?: (nodeId: string) => void; + /** + * Ack-echo consumer for a screen launch (default + * `getPreviewBroker().onDisplayOpened`): when the node's `launch` ack names + * the display it allocated, that pairing is the preview broker's audit + * fact. Injected so a launcher test never opens the broker. + */ + onDisplayOpened?: (nodeId: string, paneId: string, screen: NodePreviewScreenWire) => void; } /** @@ -309,15 +321,44 @@ export class RemoteLauncher implements NodeLauncher { // `ssh: undefined` on a plain plan (the frame's parser refuses an // explicit-undefined optional; the mcp comment above is the precedent). ...(plan.ssh ? { ssh: { configPath: plan.ssh.configPath, fileContent: plan.ssh.fileContent } } : {}), + // The screen request (pane-preview §6). Conditional spread like ssh's: + // a plain launch's frame carries no member, byte-identical to the + // pre-preview wire the node's parser still requires. + ...(plan.screen ? { screen: plan.screen } : {}), }; + let data: unknown; try { - await this.#send(cmd, LAUNCH_TIMEOUT_MS); + data = await this.#send(cmd, LAUNCH_TIMEOUT_MS); } catch (err) { if (err instanceof NodeRpcError && err.code === "failed" && BINARY_MISSING_RE.test(err.message)) { this.#kickDetect(); } + // A screen the NODE refused: the node owns the refusal word, this + // mirror pairs it with the operator's remedy and renders a 400 that + // names both (the create path's preflight covers only the switch; + // platform, binaries, and a display that never came up are the node's + // to see). Unknown detail rethrows untouched, the established posture. + if (err instanceof NodeRpcError && err.code === "failed" && err.detail !== undefined) { + const remedy = screenLaunchRemedy(err.detail); + if (remedy !== null) throw new HttpError(400, remedy); + } throw err; } + const ack = parseNodeLaunchResult(data); + if (ack === null) throw this.#malformed("launch"); + if (ack.screen) { + // The display exists the moment the ack says so; the audit is a + // courtesy record and must not un-launch a live pane by throwing. + try { + (this.#deps.onDisplayOpened ?? ((n, p, s) => getPreviewBroker().onDisplayOpened(n, p, s)))( + this.#nodeId, + plan.id, + ack.screen, + ); + } catch (err) { + logger.warn(`preview display audit failed for pane ${plan.id}: ${String(err)}`); + } + } } /** diff --git a/apps/server/api/src/services/preview-broker.ts b/apps/server/api/src/services/preview-broker.ts new file mode 100644 index 000000000..d747fbf62 --- /dev/null +++ b/apps/server/api/src/services/preview-broker.ts @@ -0,0 +1,1395 @@ +import type { ServiceTarget } from "@internal/subshell-protocol"; +import { + type NodeCommandBody, + type NodeEvent, + type NodePreviewPortsResult, + type NodePreviewScreenWire, + PREVIEW_FORWARD_SESSIONS_PER_PANE, + PREVIEW_FRAME_MAX_BYTES, + PREVIEW_IDLE_MS, + PREVIEW_LANE_BUFFER_MAX_BYTES, + PREVIEW_LANE_QUEUE_MAX, + PREVIEW_LIFETIME_MAX_MS, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewCloseReason, + type PreviewDataFrame, + type PreviewStreamFrame, + parseNodePreviewCloseResult, + parseNodePreviewOpenResult, + parseNodePreviewPortsResult, + previewImageSizeAllowed, +} from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; +import { getLocalPreviewRuntime, sweepLocalPreviewDisplays } from "@/preview/local-preview.js"; +import { type AuditEventInput, audit } from "@/services/audit.js"; +import { NodeRpcError, sendCommand } from "@/services/nodes/node-rpc.js"; +import { sendPreviewFrameOverNodeSocket } from "@/services/nodes/preview-frames.js"; +import { getLogger } from "@/utils/logger.js"; + +/** + * The plane-side preview broker (pane-preview spec 2026-10-21 §5-§7). + * + * It owns everything the link carries but the link itself: forward sessions + * and their byte lanes (§4), the one screencast bridge per pane with a + * refcount of REST watchers (§6), the pane-keyed set of /ws/live sockets + * entitled to frames, the launch-time display memory (§9), and the 5-second + * ports cache. The shape is ssh-relay.service.ts's: a module singleton built + * lazily over injectable seams, closes that recompute authorization rather + * than diffing events, and an audit pair per SESSION-level close. + * + * What it is NOT: a gate. Routes and the live feed authorize (Task 9's REST + * surface, Task 11's ws attach); the broker trusts the caller for `who` and + * enforces only caps, refcounts, and revocation sweeps. And it imports + * nothing from the `ws/` layer: the fan-out sender is INJECTED + * (`configurePreviewStreamSender`, Task 11's wiring), because the broker must + * stay testable without a socket and the /ws/live crossing belongs in + * live-ws.ts. + * + * Refs are the one secret here: a session or lane ref IS the `/pv` path + * credential (spec §6), minted from the CSPRNG and appearing nowhere else. + * Unlike the relay's routing refs, it never rides an audit row, and every + * `node.preview_*` metadata block carries pane/node/port/kind/reason instead. + */ + +/* ------------------------------------------------------------------ */ +/* refusals */ +/* ------------------------------------------------------------------ */ + +/** Viewer-level refusals the broker itself raises (node refusals are NodeRpcError). */ +export type PreviewRefusalCode = + | "preview-lane-over-cap" + | "preview-watch-over-cap" + | "watch-port-conflict" + | "preview-open-cancelled" + | "preview-session-over-cap"; + +/** + * A refusal raised by the broker's own caps, carrying the wire-stable code + * Task 9 maps to HTTP (409 for `watch-port-conflict`, cap refusals to their + * spec-named strings). Distinct from the bare word `over-cap`, which names + * CLOSES (the sender's death), never refusals (spec §6's naming rule). + */ +export class PreviewRefusal extends Error { + constructor( + readonly code: PreviewRefusalCode, + message: string, + ) { + super(message); + this.name = "PreviewRefusal"; + } +} + +/* ------------------------------------------------------------------ */ +/* the socket seam (exported: Task 11 builds the real one) */ +/* ------------------------------------------------------------------ */ + +/** + * What a stream recipient must offer. NOT the `ElysiaWS` wrapper: measured on + * this repo's Elysia 1.4.29/Bun 1.4.2, the per-callback wrapper carries + * neither a `bufferedAmount` property nor a `getBufferedAmount` method, while + * the raw Bun socket carries the METHOD. Task 11 stores a two-member adapter + * over `ws` + `ws.raw` (the `?? 0` read at remote-subshell-ws.ts:79 is that + * precedent). An absent method reads as zero debt, which keeps frames + * flowing for any other sendable object. + */ +export interface PreviewStreamSocket { + send(data: string): unknown; + getBufferedAmount?: () => number; +} + +/** + * The fan-out sender: it OWNS serialization (JSON.stringify is live-ws's + * house, the broker stays frame-shaped), and it receives the frame built for + * exactly one socket. Installed once at boot by Task 11 + * (`configurePreviewStreamSender`); an unconfigured broker never fans out. + */ +export type PreviewStreamSender = (socket: PreviewStreamSocket, frame: PreviewStreamFrame) => void; + +/* ------------------------------------------------------------------ */ +/* state shapes */ +/* ------------------------------------------------------------------ */ + +/** One lane event: decoded bytes out, or the named death of the lane. */ +export type PreviewLaneEvent = { bytes: Uint8Array } | { closed: PreviewCloseReason }; + +/** The per-subscriber callback shape (decision 2: keyed by LANE, not session). */ +export type PreviewLaneCallback = (event: PreviewLaneEvent) => void; + +/** + * One duplex lane under a forward session. An iframe, an HMR socket, and + * asset requests all hold lanes under ONE session, so subscriber sets live + * here, not on the session: a session-keyed callback would pump one + * connection's bytes into every duplex. + */ +export interface PreviewLane { + /** The lane's 32-hex ref (the node-side lane id and the wire's lane key). */ + ref: string; + /** The session this lane rides under. */ + sessionRef: string; + /** Live subscribers; each connection adds and removes its own. */ + subscribers: Set; + /** A target may send its greeting before the browser upgrade finishes. */ + buffered: Uint8Array[]; + bufferedBytes: number; +} + +/** + * A live preview session: a forward lane-pool owner (`kind: "forward"`) or + * the pane's one screencast bridge (`kind: "screencast"`). + */ +export interface PreviewSession { + service?: ServiceTarget; + serviceId?: string; + /** The 32-hex session ref (for forward, the PROBE ref: minted from the CSPRNG, this is the credential). */ + ref: string; + /** Which preview surface this session fronts. */ + kind: "forward" | "screencast"; + /** + * Discriminator for the proxy (decision 6): `raw` when the port is the + * pane's registered screen `viewer.port` (bytes are RFB, no HTTP parsing), + * `http` otherwise. Screencast bridges set `http` (they have no leg). + */ + mode: "http" | "raw"; + /** The subshell whose pane this previews. */ + paneId: string; + /** The node the lane/bridge was opened against. */ + nodeId: string; + /** The target port (RFB port for raw, service port otherwise, CDP port for a bridge). */ + port: number; + /** Who opened it (the forward tier holder that revocation re-asks). */ + createdBy: string; + /** Epoch ms of the open (audit-time context; the sweeps use lastActivity). */ + createdAt: number; + /** Lane ref -> lane. Forward sessions grow and shrink these. */ + lanes: Map; + /** + * REST watcher -> SUBSCRIPTION count, not a set: two tabs of one account + * are two watchers, and a Set would let one tab's DELETE close the bridge + * under its sibling's canvas. + */ + viewers: Map; + /** Epoch ms of the last lane traffic (the idle sweep reads this; bridges ride refcount/lifetime, never traffic silence). */ + lastActivity: number; + /** + * Anchor for spec §6's per-session cookie jar. It lives HERE so it dies on + * every session teardown path (this broker has all of them); its CONTENTS + * are read and written only by Task 10's pv-http/pv-ws, which the broker + * never calls into, and no wire type carries them. + */ + cookieJar: Map; +} + +/** The verbs the routes, the ws feed, and the sweeps call (Tasks 9-11). */ +interface PreviewOpenInput { + service?: ServiceTarget; + serviceId?: string; + viewerUserId: string; + paneId: string; + nodeId: string; + port: number; + /** The route rechecks current account/access after asynchronous node work. */ + authorize?: () => Promise; +} + +interface PendingPreviewOpen extends PreviewOpenInput { + kind: "forward" | "screencast"; + cancelled?: PreviewCloseReason; + registered?: boolean; +} + +export interface PreviewBroker { + removeService(paneId: string, serviceId: string): Promise; + /** Changes at every revocation, invalidating access reads already in flight. */ + streamAccessRevision(): number; + openForward(input: PreviewOpenInput): Promise<{ ref: string }>; + openLaneForConnection(sessionRef: string): Promise<{ laneRef: string }>; + subscribeLane(laneRef: string, cb: PreviewLaneCallback): () => void; + sendLaneBytes(laneRef: string, bytes: Uint8Array): void; + closeLaneForConnection(laneRef: string, reason: PreviewCloseReason): Promise; + startWatch(input: PreviewOpenInput): Promise<{ ok: true }>; + stopWatch(viewerUserId: string, paneId: string): Promise<{ ok: true }>; + attachStream( + paneId: string, + socketKey: string, + holderUserId: string, + socket: PreviewStreamSocket, + ): { ok: true } | { ok: false; code: "preview-stream-over-cap" }; + detachStream(paneId: string, socketKey: string): void; + detachSocketsFor(socketKey: string): void; + sessionFor(ref: string): PreviewSession | null; + /** The pane's live screencast bridge, if any (tests and the feed read it by pane). */ + sessionForPane(paneId: string): PreviewSession | null; + getPorts(nodeId: string, paneId: string): Promise; + onDisplayOpened(nodeId: string, paneId: string, screen: NodePreviewScreenWire): void; + /** + * The screen the plane remembers from this pane's launch ack, or null. + * The ack names the display before the node's own ports scan reports it, + * and Task 9's info route fills that window from here (a reported + * `screenLost` still outranks the memory). + */ + displayForPane(paneId: string): { nodeId: string; screen: NodePreviewScreenWire } | null; + observeDisplay(nodeId: string, paneId: string, screen: NodePreviewScreenWire | null): void; + onDisplayClosed(paneId: string, reason: PreviewCloseReason): Promise; + closeForPane(paneId: string, reason: PreviewCloseReason): Promise; + closeAllForNode(nodeId: string, reason: PreviewCloseReason): Promise; + /** Sweeps the user's forwards, watch counts, and held streams; answers how many SESSIONS it closed. */ + closeAllForUser(userId: string, reason: PreviewCloseReason): Promise; + closeUnauthorizedForPane(paneId: string): Promise; + onStreamEvent(nodeId: string, ev: Extract): void; + onNodeLaneFrame(nodeId: string, frame: PreviewDataFrame): void; + onNodeReportedClose(nodeId: string, ref: string, reason: PreviewCloseReason): Promise; + /** One idle pass over FORWARD sessions (the periodic timer runs it; tests drive it with a fake clock). */ + sweepIdle(): Promise; + /** Stops the sweep timer and clears all state (test isolation). */ + shutdown(): void; +} + +/* ------------------------------------------------------------------ */ +/* seams and the singleton */ +/* ------------------------------------------------------------------ */ + +/** The `getPorts` cache window (spec §6: 5 s, keyed by pane). */ +export const PREVIEW_PORTS_CACHE_MS = 5_000; +export const PREVIEW_CAPABILITIES_PER_NODE = 64; +export const PREVIEW_PENDING_OPENS_PER_NODE = 32; +const PREVIEW_PORTS_CACHE_ENTRIES = 512; + +/** How often the periodic idle sweep runs; tests call `sweepIdle()` directly. */ +const PREVIEW_SWEEP_INTERVAL_MS = 60_000; + +interface PreviewBrokerDeps { + nowMs: () => number; + /** The signed COMMAND path (open/close/ports), exactly sendCommand's contract. */ + send: (nodeId: string, cmd: NodeCommandBody) => Promise; + /** The byte-frame path (lane `in` legs), NOT the command path (preview-frames.ts). */ + linkSend: (nodeId: string, frame: PreviewDataFrame) => void; + /** The shares resolver the revocation recompute asks per holder (production: the real one). */ + accessFor: (userId: string, paneId: string) => Promise; + audit: (event: AuditEventInput) => Promise; + /** The fan-out sender; undefined means "not wired" and frames are never sent. */ + fanOut: PreviewStreamSender | undefined; + log: (line: string) => void; +} + +/** The access resolver's repos, built on first ask (never at import) and reused. */ +let accessRepos: { subshells: SubshellsRepository; shares: SubshellSharesRepository } | undefined; + +function productionDeps(): PreviewBrokerDeps { + return { + nowMs: () => Date.now(), + send: async (nodeId, cmd) => { + if (nodeId !== LOCAL_NODE_ID) return sendCommand(nodeId, cmd); + await sweepLocalPreviewDisplays(); + return getLocalPreviewRuntime().execute(cmd, (event) => { + const broker = getPreviewBroker(); + if (event.type === "preview_data") broker.onNodeLaneFrame(LOCAL_NODE_ID, event); + else if (event.type === "preview_stream") broker.onStreamEvent(LOCAL_NODE_ID, event); + else + void broker + .onNodeReportedClose(LOCAL_NODE_ID, event.ref, event.reason) + .catch((err: unknown) => getLogger().warn(`Local preview teardown failed: ${String(err)}`)); + }); + }, + linkSend: (nodeId, frame) => { + if (nodeId === LOCAL_NODE_ID) getLocalPreviewRuntime().forward.ingestInboundFrame(frame); + else sendPreviewFrameOverNodeSocket(nodeId, frame); + }, + // The repos are built directly (not through getRequestlessContext) so + // this module never sits in the context <-> services import circle, and + // once — they are stateless over `db`, and building them per ask was + // pure allocation. + accessFor: async (userId, paneId) => { + accessRepos ??= { subshells: new SubshellsRepository(db), shares: new SubshellSharesRepository(db) }; + const { access } = await loadSubshellAccess(accessRepos, userId, paneId); + return access; + }, + audit, + fanOut: productionStreamSender, + log: (line) => getLogger().debug(line), + }; +} + +/** + * The production fan-out slot. One-shot by contract: Task 11's live-ws.ts + * calls `configurePreviewStreamSender` once at boot (the singleton builds + * lazily, so there is no construction site to inject at; a setter is the + * seam that makes). `resetForTests` overrides it for the fake only. + */ +let productionStreamSender: PreviewStreamSender | undefined; + +/** Installs the production fan-out sender (first call wins; tests use resetForTests). */ +export function configurePreviewStreamSender(send: PreviewStreamSender): void { + productionStreamSender ??= send; +} + +let instance: PreviewBroker | undefined; + +/** Returns the preview broker, building it lazily over the production seams. */ +export function getPreviewBroker(): PreviewBroker { + instance ??= buildPreviewBroker(productionDeps()); + return instance; +} + +/** + * Test seam: rebuilds the singleton over named seams (fake clock for the + * idle sweep, fake command RPC, fake byte-frame path, fake access answers, + * injected fan-out). With no argument it rebuilds over production seams. The + * AUDIT seam stays real on purpose: the audit rows are half the contract, so + * the fake writes them to the suite's migrated DB (Task 8's tests read them + * back). + */ +export function resetForTests(seams?: { + now?: () => number; + send?: (nodeId: string, cmd: NodeCommandBody) => Promise; + linkSend?: (nodeId: string, frame: PreviewDataFrame) => void; + accessFor?: (userId: string, paneId: string) => Promise; + sendToSockets?: PreviewStreamSender; +}): void { + instance?.shutdown(); + instance = undefined; + const prod = productionDeps(); + const deps: PreviewBrokerDeps = { + ...prod, + ...(seams?.now ? { nowMs: seams.now } : {}), + ...(seams?.send ? { send: seams.send } : {}), + ...(seams?.linkSend ? { linkSend: seams.linkSend } : {}), + ...(seams?.accessFor ? { accessFor: seams.accessFor } : {}), + // An absent `sendToSockets` inherits the production slot (unset in tests + // until something configures it): that is what "an unconfigured broker + // never fans out" means here. + fanOut: seams && "sendToSockets" in seams ? seams.sendToSockets : prod.fanOut, + }; + instance = buildPreviewBroker(deps); +} + +/** Drops the memoized instance entirely (builds nothing; pairs with resetForTests in suites that need a true blank). */ +export function shutdownPreviewBrokerForTests(): void { + instance?.shutdown(); + instance = undefined; +} + +/** + * The crash-case belt: close one pane's preview state from a death transition + * that has no room to wait or to throw. Same shape as `closeRelayForPaneExit` + * in ssh-relay.service.ts — fire-and-forget, every failure logged and none + * re-raised — because a node whose pane just crashed may never answer the + * lane closes, and a reconcile pass must keep walking rows whatever the + * answer. The exit route's own awaited `closeForPane` is the fast path; this + * is what stops forward lanes and browser stream sockets from outliving the + * pane to the idle and lifetime caps when the hook report is lost. `closeForPane` + * is idempotent, so route-and-belt together still audit exactly one close. + */ +export function closePreviewForPaneExit(paneId: string): void { + try { + void getPreviewBroker() + .closeForPane(paneId, "pane-gone") + .catch((err: unknown) => getLogger().warn(`preview teardown for pane ${paneId} failed: ${String(err)}`)); + } catch (err) { + getLogger().warn(`preview teardown for pane ${paneId} threw before the close: ${String(err)}`); + } +} + +/* ------------------------------------------------------------------ */ +/* the mint */ +/* ------------------------------------------------------------------ */ + +/** + * The session/lane ref: 128 bits from the CSPRNG, hex. This mint is the + * credential (spec §10: "secret unguessability" is a property of HERE, not + * of the format regex; a `Math.random()` hex still matches PREVIEW_REF_RE). + */ +function mintRef(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("hex"); +} + +/* ------------------------------------------------------------------ */ +/* the broker */ +/* ------------------------------------------------------------------ */ + +interface LaneRecord { + lane: PreviewLane; + session: PreviewSession; + /** + * True from the synchronous reservation until the node's ack: the cap and + * the queue see the lane from before its `preview_open` is even sent + * (review 2026-10-23). Nothing can address a pending lane externally — + * its ref reaches callers only after the ack, and the node cannot name a + * ref it has not acked — so `pending` guards no read path; it exists to + * make the registration honest. + */ + pending: boolean; +} + +interface StreamEntry { + socket: PreviewStreamSocket; + holderUserId: string; +} + +interface LaneWaiter { + sessionRef: string; + resolve: (answer: { laneRef: string }) => void; + reject: (err: Error) => void; +} + +function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { + const sessionsByRef = new Map(); + /** paneId -> the one screencast bridge's ref (spec §6: the watch is pane-keyed). */ + const bridgeRefByPane = new Map(); + /** laneRef -> {lane, session}; the wire's lane key for both directions. */ + const lanesByRef = new Map(); + /** paneId -> socketKey -> entry. Keyed by the PER-CONNECTION id, NEVER socket identity (live-ws.ts's rule). */ + const streamsByPane = new Map>(); + /** paneId -> the display the node reported at launch (the mac-leg fallback for `mode`). */ + const displaysByPane = new Map(); + /** paneId -> cached `getPorts` answer (5 s window, spec §6). */ + const portsCache = new Map(); + /** paneId -> queued lane requests beyond the per-pane cap (decision 2). */ + const laneWaitersByPane = new Map(); + /** + * paneId -> the flying zero->one bridge open (review 2026-10-23). One cold + * open per pane may ever be in flight: concurrent startWatch callers await + * the winner instead of each minting a bridge the last-set-wins map would + * strand (an orphan no verb can reach and sweepIdle never walks). Cleared + * when the chain settles either way, so a failed open is not sticky. + */ + const watchOpeningByPane = new Map>(); + // Revocation must see opens before their node acknowledgement registers a session. + const opening = new Set(); + let streamAccessRevision = 0; + function cancelOpening(matches: (op: PendingPreviewOpen) => boolean, reason: PreviewCloseReason): void { + for (const op of opening) if (matches(op)) op.cancelled = reason; + } + function checkOpening(op: PendingPreviewOpen): void { + if (op.cancelled) + throw new PreviewRefusal( + "preview-open-cancelled", + "Preview access changed while opening. Reopen it if you still have access.", + ); + } + async function authorizeOpening(op: PendingPreviewOpen): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + checkOpening(op); + const revision = streamAccessRevision; + await op.authorize?.(); + checkOpening(op); + if (revision === streamAccessRevision) return; + } + throw new PreviewRefusal( + "preview-open-cancelled", + "Preview access changed while opening. Reopen it if you still have access.", + ); + } + async function withOpening( + input: PreviewOpenInput, + kind: PendingPreviewOpen["kind"], + run: (op: PendingPreviewOpen) => Promise, + ): Promise { + const pending = [...opening].filter((op) => op.nodeId === input.nodeId); + if (pending.length >= PREVIEW_PENDING_OPENS_PER_NODE) { + throw new PreviewRefusal( + "preview-session-over-cap", + "Too many previews are opening on this node. Wait for them to finish, then try again.", + ); + } + if (kind === "forward") { + const retained = [...sessionsByRef.values()].filter( + (session) => session.nodeId === input.nodeId && session.kind === "forward", + ).length; + const reserved = pending.filter((op) => op.kind === "forward" && !op.registered).length; + if (retained + reserved >= PREVIEW_CAPABILITIES_PER_NODE) { + throw new PreviewRefusal( + "preview-session-over-cap", + "Preview capacity on this node is full. Reuse an existing preview, or try again after inactive preview links expire.", + ); + } + } + const op: PendingPreviewOpen = { ...input, kind }; + opening.add(op); + try { + return await run(op); + } finally { + opening.delete(op); + } + } + + const sweepTimer = setInterval(() => { + void broker.sweepIdle().catch((err: unknown) => deps.log(`preview idle sweep failed: ${String(err)}`)); + }, PREVIEW_SWEEP_INTERVAL_MS); + sweepTimer.unref?.(); + + /** + * Writes one `node.preview_*` row. + * + * The metadata deliberately OMITS the ref, diverging from the relay's + * audit block (ssh-relay.service.ts:583 includes it because "a routing ref + * is not a credential"): a preview session/lane ref IS the credential, + * the `/pv` path secret, so it belongs in no row an operator can read + * (spec §6). Byte content stays out the same way; only ids, port, kind, + * reason, and counts ride. + */ + async function auditPreview(action: string, nodeId: string | null, meta: Record): Promise { + await deps.audit({ + actorUserId: null, + action, + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify(meta), + }); + } + + function laneCountForPane(paneId: string): number { + let n = 0; + for (const rec of lanesByRef.values()) if (rec.session.paneId === paneId) n += 1; + return n; + } + + async function sendPreviewClose(nodeId: string, ref: string, reason: PreviewCloseReason): Promise { + try { + const data = await deps.send(nodeId, { type: "preview_close", ref, reason }); + parseNodePreviewCloseResult(data); // shape-checked; the ack's `closed` is advisory (the node forgot the ref either way) + } catch (err) { + // The close is a courtesy against a node that may already be gone (the + // link-drop sweep reaps the rest). Loud in the debug log, never thrown + // back into a sweep. + deps.log(`preview_close ${reason} for ref on node ${nodeId} failed: ${String(err)}`); + } + } + + async function requestPreviewOpen( + nodeId: string, + command: Extract, + ): Promise { + try { + const result = parseNodePreviewOpenResult(await deps.send(nodeId, command)); + if (!result || result.ref !== command.ref) { + throw new Error("preview_open answered a shape or reference the grammar refuses"); + } + } catch (err) { + // Only an explicit refusal proves the node didn't open anything. A + // lost or malformed reply still needs teardown for the allocated ref. + if (!(err instanceof NodeRpcError && err.code === "failed")) { + await sendPreviewClose(nodeId, command.ref, "viewer-left"); + } + throw err; + } + } + + /** Removes the lane entry, notifies its subscribers, optionally tells the node, then frees a queue slot. */ + async function removeLane(rec: LaneRecord, reason: PreviewCloseReason, sendWire: boolean): Promise { + lanesByRef.delete(rec.lane.ref); + rec.session.lanes.delete(rec.lane.ref); + for (const cb of [...rec.lane.subscribers]) { + rec.lane.subscribers.delete(cb); + try { + cb({ closed: reason }); + } catch (err) { + deps.log(`preview lane subscriber threw on close: ${String(err)}`); + } + } + if (sendWire) await sendPreviewClose(rec.session.nodeId, rec.lane.ref, reason); + await dequeueWaiter(rec.session.paneId); + } + + /** A freed lane slot goes to the pane's first still-living waiter. */ + async function dequeueWaiter(paneId: string): Promise { + const queue = laneWaitersByPane.get(paneId); + if (!queue || queue.length === 0) return; + if (laneCountForPane(paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) return; + const waiter = queue.shift(); + if (!waiter) return; + const session = sessionsByRef.get(waiter.sessionRef); + if (!session) { + // The session died while queued (the common case: its last connection + // was the one that just closed). Reject rather than mint against a ref + // nothing owns; later waiters get their turn on the next release. + waiter.reject(new Error(`preview session ${waiter.sessionRef.slice(0, 8)}… closed while its lane was queued`)); + await dequeueWaiter(paneId); + return; + } + try { + waiter.resolve(await mintLane(session)); + } catch (err) { + waiter.reject(err instanceof Error ? err : new Error(String(err))); + } + } + + /** + * Mints a fresh lane ref, OPENS it on the node, and flips the registration + * live. The reservation is the load-bearing part (review 2026-10-23): the + * placeholder lands in the very structures `laneCountForPane` counts BEFORE + * the first await, so two concurrent callers can never both pass the cap + * check and both spend a node slot; the second one queues instead. A + * refusal releases through the same close path a real lane uses, so the + * freed slot still promotes the pane's first waiter. The close reason on + * the release is invisible (a placeholder has no subscribers yet); it says + * `viewer-left` because the caller's promise rejecting IS the leaving. + */ + async function mintLane(session: PreviewSession): Promise<{ laneRef: string }> { + const laneRef = mintRef(); + const lane: PreviewLane = { + ref: laneRef, + sessionRef: session.ref, + subscribers: new Set(), + buffered: [], + bufferedBytes: 0, + }; + session.lanes.set(laneRef, lane); + lanesByRef.set(laneRef, { lane, session, pending: true }); + try { + await requestPreviewOpen(session.nodeId, { + type: "preview_open", + ref: laneRef, + kind: "forward", + paneId: session.paneId, + port: session.port, + service: session.service, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + } catch (err) { + // requestPreviewOpen already closes uncertain node state. Releasing + // this reservation only frees the server-side slot. + const rec = lanesByRef.get(laneRef); + if (rec) await removeLane(rec, "viewer-left", false); + throw err; + } + const rec = lanesByRef.get(laneRef); + if (!rec) { + // The session died under the in-flight open (closeForwardSession walked + // the placeholder away). A late node registration also needs an explicit + // close: the earlier close could have arrived before it existed. + await sendPreviewClose(session.nodeId, laneRef, "revoked"); + throw new Error(`preview session ${session.ref.slice(0, 8)}… closed while its lane was opening`); + } + rec.pending = false; + session.lastActivity = deps.nowMs(); + return { laneRef }; + } + + function detachStreamEntries(paneId: string): void { + // Drops the pane's stream set WITHOUT touching the sockets: the /ws/live + // socket carries the whole feed and outlives a lost grant or a gone pane + // (Task 11's matrix: "stops frames to that socket while the socket lives"). + streamsByPane.delete(paneId); + } + + /** Tears down a forward session whole: lanes off the wire, waiters rejected, the pair audited. */ + async function closeForwardSession(session: PreviewSession, reason: PreviewCloseReason): Promise { + if (!sessionsByRef.has(session.ref)) return; + sessionsByRef.delete(session.ref); + // The session ref was the probe, closed on the node at open: the only + // live node-side state under a forward session is its lanes, so each + // lane's close is the wire work (and lane closes audit nothing; the + // SESSION pair is the one row). + const laneCloses = [...session.lanes.values()].map((lane) => + removeLane({ lane, session, pending: false }, reason, true).catch((err: unknown) => + deps.log(`preview lane close during sweep threw: ${String(err)}`), + ), + ); + await Promise.all(laneCloses); + rejectWaitersFor(session.paneId, (w) => w.sessionRef === session.ref, new Error("preview session closed")); + await auditPreview("node.preview_forward.close", session.nodeId, { + paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "forward", + reason, + }); + } + + /** + * Tears down the pane's bridge: counts gone, sockets detached (never + * closed), optionally told the node, the pair audited once per bridge + * (not per watcher - that is the row's whole design). + */ + async function closeWatchBridge( + session: PreviewSession, + reason: PreviewCloseReason, + sendWire: boolean, + ): Promise { + if (bridgeRefByPane.get(session.paneId) !== session.ref) return; + bridgeRefByPane.delete(session.paneId); + sessionsByRef.delete(session.ref); + session.viewers.clear(); + detachStreamEntries(session.paneId); + if (sendWire) await sendPreviewClose(session.nodeId, session.ref, reason); + await auditPreview("node.preview_watch.close", session.nodeId, { + paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "screencast", + reason, + }); + } + + /** The live bridge's join path: true when the caller joined, refusal thrown by name. */ + function joinBridge({ viewerUserId, paneId, port }: { viewerUserId: string; paneId: string; port: number }): boolean { + const bridgeRef = bridgeRefByPane.get(paneId); + const bridge = bridgeRef ? sessionsByRef.get(bridgeRef) : undefined; + if (!bridge) return false; + if (bridge.port !== port) { + // Two bridges on one pane would interleave on a channel with no + // discriminator, and the pane-keyed stopWatch could not name the + // right one. A matching port just joins. + throw new PreviewRefusal( + "watch-port-conflict", + `this pane's screencast bridge is already watching port ${bridge.port}`, + ); + } + const sum = [...bridge.viewers.values()].reduce((a, b) => a + b, 0); + if (sum + 1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + // PER BRIDGE (decision 7): a per-viewer cap would let one + // account's 40 tabs sail past the limit the spec named. + throw new PreviewRefusal( + "preview-watch-over-cap", + `this pane's screencast bridge already has ${PREVIEW_WATCH_SUBSCRIBERS_MAX} subscribers`, + ); + } + bridge.viewers.set(viewerUserId, (bridge.viewers.get(viewerUserId) ?? 0) + 1); + bridge.lastActivity = deps.nowMs(); + return true; + } + + /** The serialized zero->one edge: ask the node, register the bridge, audit the pair. */ + async function openBridge( + { + viewerUserId, + paneId, + nodeId, + port, + }: { + viewerUserId: string; + paneId: string; + nodeId: string; + port: number; + }, + op: PendingPreviewOpen, + ): Promise { + if (1 > PREVIEW_WATCH_SUBSCRIBERS_MAX) { + throw new PreviewRefusal("preview-watch-over-cap", "screencast subscribers cap below one"); + } + const ref = mintRef(); + await requestPreviewOpen(nodeId, { + type: "preview_open", + ref, + kind: "screencast", + paneId, + port, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + try { + await authorizeOpening(op); + } catch (err) { + await sendPreviewClose(nodeId, ref, op.cancelled ?? "revoked"); + throw err; + } + // The ack's `subscribers` count is IGNORED: the bridge counts itself + // (this broker owns the refcount; the node's number is advisory at best). + const session: PreviewSession = { + ref, + kind: "screencast", + mode: "http", // bridges have no leg; the member exists for the proxy's discriminator + paneId, + nodeId, + port, + createdBy: viewerUserId, + createdAt: deps.nowMs(), + lanes: new Map(), + viewers: new Map([[viewerUserId, 1]]), + lastActivity: deps.nowMs(), + cookieJar: new Map(), + }; + sessionsByRef.set(ref, session); + bridgeRefByPane.set(paneId, ref); + await auditPreview("node.preview_watch.open", nodeId, { paneId, nodeId, port, kind: "screencast" }); + return session; + } + + function rejectWaitersFor(paneId: string, which: (w: LaneWaiter) => boolean, err: Error): void { + const queue = laneWaitersByPane.get(paneId); + if (!queue || queue.length === 0) return; + const kept: LaneWaiter[] = []; + for (const w of queue) { + if (which(w)) w.reject(err); + else kept.push(w); + } + if (kept.length === 0) laneWaitersByPane.delete(paneId); + else laneWaitersByPane.set(paneId, kept); + } + + /** The live screen record for `mode`: getPorts answer FIRST, launch memory second (decision 6). */ + async function resolveMode(nodeId: string, paneId: string, port: number): Promise<"http" | "raw"> { + try { + const answer = await broker.getPorts(nodeId, paneId); + if (answer.screen && answer.screen.viewer.port === port) return "raw"; + // The screen exists but is not this port: `http`, decided (the memory + // cannot disagree upward, and on the mac leg there is no memory). + if (answer.screen) return "http"; + } catch (err) { + deps.log( + `preview getPorts refused during open (${paneId.slice(0, 8)}…): ${String(err)}; falling back to display memory`, + ); + } + const memory = displaysByPane.get(paneId); + if (memory && memory.screen.viewer.port === port) return "raw"; + return "http"; + } + + function dropPortsCache(paneIds: Iterable): void { + for (const p of paneIds) portsCache.delete(p); + } + + const broker: PreviewBroker = { + async removeService(paneId, serviceId) { + streamAccessRevision++; + for (const op of opening) if (op.paneId === paneId && op.serviceId === serviceId) op.cancelled = "revoked"; + for (const session of [...sessionsByRef.values()]) { + if (session.paneId === paneId && session.serviceId === serviceId) await closeForwardSession(session, "revoked"); + } + }, + streamAccessRevision: () => streamAccessRevision, + async openForward(input) { + return withOpening(input, "forward", async (op) => { + const { viewerUserId, paneId, nodeId, port } = input; + await authorizeOpening(op); + const ref = mintRef(); + const mode = input.service ? "http" : await resolveMode(nodeId, paneId, port); + checkOpening(op); + // The PROBE open (spec §5): validates the node will serve this pane at + // all, and the refusal path records nothing and opens nothing. Every + // preview_open rides the lifetime cap: the absolute ceiling is + // belt-and-braces while refcount/idle/revoke are the real lifecycle, + // and the value is named here so two executors cannot pick two. + await requestPreviewOpen(nodeId, { + type: "preview_open", + ref, + kind: "forward", + paneId, + port, + service: input.service, + lifetimeMs: PREVIEW_LIFETIME_MAX_MS, + }); + await sendPreviewClose(nodeId, ref, op.cancelled ?? "viewer-left"); + await authorizeOpening(op); + const session: PreviewSession = { + ref, + kind: "forward", + mode, + paneId, + nodeId, + port, + service: input.service, + serviceId: input.serviceId, + createdBy: viewerUserId, + createdAt: deps.nowMs(), + lanes: new Map(), + viewers: new Map(), + lastActivity: deps.nowMs(), + cookieJar: new Map(), + }; + sessionsByRef.set(ref, session); + op.registered = true; + await auditPreview("node.preview_forward.open", nodeId, { paneId, nodeId, port, kind: "forward" }); + checkOpening(op); + return { ref }; + }); + }, + + async openLaneForConnection(sessionRef) { + const session = sessionsByRef.get(sessionRef); + if (!session) throw new Error(`no live preview session ${sessionRef.slice(0, 8)}… for a new lane`); + if (session.kind !== "forward") throw new Error("preview lanes belong to forward sessions only"); + if (laneCountForPane(session.paneId) >= PREVIEW_FORWARD_SESSIONS_PER_PANE) { + const queue = laneWaitersByPane.get(session.paneId) ?? []; + if (queue.length >= PREVIEW_LANE_QUEUE_MAX) { + throw new PreviewRefusal( + "preview-lane-over-cap", + `this pane already carries ${PREVIEW_FORWARD_SESSIONS_PER_PANE} preview lanes with ${PREVIEW_LANE_QUEUE_MAX} waiting; try again when one frees`, + ); + } + return new Promise<{ laneRef: string }>((resolve, reject) => { + queue.push({ sessionRef, resolve, reject }); + laneWaitersByPane.set(session.paneId, queue); + }); + } + return mintLane(session); + }, + + subscribeLane(laneRef, cb) { + const rec = lanesByRef.get(laneRef); + if (!rec) { + cb({ closed: "target-gone" }); + return () => {}; + } + rec.lane.subscribers.add(cb); + const buffered = rec.lane.buffered.splice(0); + rec.lane.bufferedBytes = 0; + for (const bytes of buffered) { + if (!rec.lane.subscribers.has(cb)) break; + cb({ bytes }); + } + return () => { + rec.lane.subscribers.delete(cb); + }; + }, + + sendLaneBytes(laneRef, bytes) { + const rec = lanesByRef.get(laneRef); + if (!rec) return; // the lane already died (the common race at teardown) + if (bytes.byteLength > PREVIEW_FRAME_MAX_BYTES) { + // The plane-side twin of the node's pump cap (spec §6): BOTH proxy + // consumers funnel through this choke point, so an oversized HTTP + // body chunk and an oversized browser WS message get the same named + // death Task 1 pins from the receiver side. Nothing rides the link; + // the LANE dies (the session survives it), and this is the ONE + // lane-level audit row. + void removeLane(rec, "over-cap", true) + .catch((err: unknown) => deps.log(`preview over-cap lane close threw: ${String(err)}`)) + .then(() => + auditPreview("node.preview_lane.over_cap", rec.session.nodeId, { + paneId: rec.session.paneId, + nodeId: rec.session.nodeId, + port: rec.session.port, + kind: "forward", + reason: "over-cap", + }), + ) + .catch((err: unknown) => deps.log(`preview over-cap audit threw: ${String(err)}`)); + return; + } + try { + deps.linkSend(rec.session.nodeId, { + type: "preview_data", + ref: laneRef, + dir: "in", + blob: Buffer.from(bytes).toString("base64"), + }); + rec.session.lastActivity = deps.nowMs(); + } catch (err) { + // A dead link is not the sender's problem to resolve: the link-drop + // sweep reaps this node's sessions named `node-dropped` (or the close + // hook already did). Log, drop, and let the sweep own it. + deps.log(`preview lane write dropped (link send threw): ${String(err)}`); + } + }, + + async closeLaneForConnection(laneRef, reason) { + const rec = lanesByRef.get(laneRef); + if (!rec) return; // already gone (the one-shot response won the race to the idle sweep) + await removeLane(rec, reason, true); + }, + + async startWatch(input) { + return withOpening(input, "screencast", async (op) => { + const { viewerUserId, paneId, nodeId, port } = input; + await authorizeOpening(op); + if (joinBridge({ viewerUserId, paneId, port })) return { ok: true }; + const flying = watchOpeningByPane.get(paneId); + if (flying) { + // The pane's one cold open is already flying (two tabs POSTed at + // once): await the winner's ack, then take the join path against the + // now-live bridge — port-conflict and the per-bridge cap apply to + // the joiner exactly as to a late caller. If the open FAILED, the + // rejection is this caller's honest answer too; the map entry is + // already cleared, so the NEXT startWatch flies fresh. + await flying; + checkOpening(op); + return broker.startWatch(input); + } + // Cold: this caller owns the open, and registering the bridge happens + // inside the serialized chain — the race is gone because only one open + // per pane ever flies (review 2026-10-23). + const opening = openBridge({ viewerUserId, paneId, nodeId, port }, op); + watchOpeningByPane.set(paneId, opening); + try { + await opening; + checkOpening(op); + } finally { + watchOpeningByPane.delete(paneId); + } + return { ok: true } as const; + }); + }, + + async stopWatch(viewerUserId, paneId) { + const bridgeRef = bridgeRefByPane.get(paneId); + const bridge = bridgeRef ? sessionsByRef.get(bridgeRef) : undefined; + if (!bridge) return { ok: true }; // REST DELETE is idempotent, and stays so after a node-reported teardown + const count = bridge.viewers.get(viewerUserId) ?? 0; + if (count <= 1) bridge.viewers.delete(viewerUserId); + else bridge.viewers.set(viewerUserId, count - 1); + // DECREMENT, never delete-key: one DELETE with two tabs open leaves + // count 1 and the bridge alive. + if (bridge.viewers.size === 0) await closeWatchBridge(bridge, "viewer-left", true); + return { ok: true }; + }, + + attachStream(paneId, socketKey, holderUserId, socket) { + // The key is a PER-CONNECTION id the caller resolves from ws.data; + // Elysia builds a fresh wrapper per callback, so socket identity is not + // a style preference but dead code (live-ws.ts:36-42). The socket stays + // the VALUE (its `send` reaches the shared raw). + const map = streamsByPane.get(paneId) ?? new Map(); + if (!map.has(socketKey) && map.size >= PREVIEW_WATCH_SUBSCRIBERS_MAX) { + // Refused AT ATTACH, answering a value, never throwing: the /ws/live + // switch swallows it, the socket stays open, the ninth tab's + // experience is the dark canvas, and this row is the operator's. + void auditPreview("node.preview_watch.viewer_drop", null, { paneId, reason: "over-cap" }).catch( + (err: unknown) => deps.log(`preview attach-cap audit threw: ${String(err)}`), + ); + return { ok: false, code: "preview-stream-over-cap" }; + } + map.set(socketKey, { socket, holderUserId }); + streamsByPane.set(paneId, map); + return { ok: true }; + }, + + detachStream(paneId, socketKey) { + const map = streamsByPane.get(paneId); + if (!map) return; + map.delete(socketKey); + if (map.size === 0) streamsByPane.delete(paneId); + }, + + detachSocketsFor(socketKey) { + // The socket's `close` handler knows only the id (the wrapper it hands + // Task 11 is not the wrapper it came in as), so the walk is by key. + for (const [paneId, map] of [...streamsByPane]) { + map.delete(socketKey); + if (map.size === 0) streamsByPane.delete(paneId); + } + }, + + sessionFor(ref) { + return sessionsByRef.get(ref) ?? null; + }, + + sessionForPane(paneId) { + const ref = bridgeRefByPane.get(paneId); + return ref ? (sessionsByRef.get(ref) ?? null) : null; + }, + + async getPorts(nodeId, paneId) { + const hit = portsCache.get(paneId); + if (hit && hit.nodeId === nodeId && deps.nowMs() - hit.at < PREVIEW_PORTS_CACHE_MS) return hit.answer; + // A node refusal (preview-ports-unsupported, offline, ...) REJECTS with + // the named error rather than fabricating an empty answer; Task 9's + // info route is what turns the rejection into `{ nodeEnabled, reason }`. + const raw = await deps.send(nodeId, { type: "preview_ports_get", paneId }); + const answer = parseNodePreviewPortsResult(raw); + if (answer === null) throw new Error("preview_ports_get answered a shape the grammar refuses"); + if (!portsCache.has(paneId) && portsCache.size >= PREVIEW_PORTS_CACHE_ENTRIES) { + const oldest = portsCache.keys().next().value; + if (oldest !== undefined) portsCache.delete(oldest); + } + portsCache.set(paneId, { nodeId, at: deps.nowMs(), answer }); + return answer; + }, + + onDisplayOpened(nodeId, paneId, screen) { + displaysByPane.set(paneId, { nodeId, screen }); + void auditPreview("node.preview_display.open", nodeId, { paneId, nodeId, ...screen }).catch((err: unknown) => + deps.log(`preview display-open audit threw: ${String(err)}`), + ); + }, + + observeDisplay(nodeId, paneId, screen) { + if (screen) displaysByPane.set(paneId, { nodeId, screen }); + else displaysByPane.delete(paneId); + }, + + displayForPane(paneId) { + return displaysByPane.get(paneId) ?? null; + }, + + async onDisplayClosed(paneId, reason) { + const entry = displaysByPane.get(paneId); + if (!entry) return; // double-close is idempotent; a pane that never displayed audits nothing + displaysByPane.delete(paneId); + await auditPreview("node.preview_display.close", entry.nodeId, { + paneId, + nodeId: entry.nodeId, + ...entry.screen, + reason, + }); + }, + + async closeForPane(paneId, reason) { + streamAccessRevision++; + cancelOpening((op) => op.paneId === paneId, reason); + for (const session of [...sessionsByRef.values()]) { + if (session.paneId !== paneId) continue; + if (session.kind === "forward") await closeForwardSession(session, reason); + else await closeWatchBridge(session, reason, true); + } + detachStreamEntries(paneId); + await broker.onDisplayClosed(paneId, reason); + dropPortsCache([paneId]); + rejectWaitersFor(paneId, () => true, new Error(`preview pane ${paneId.slice(0, 8)}… closed`)); + }, + + async closeAllForNode(nodeId, reason) { + streamAccessRevision++; + cancelOpening((op) => op.nodeId === nodeId, reason); + const panesTouched = new Set(); + for (const session of [...sessionsByRef.values()]) { + if (session.nodeId !== nodeId) continue; + panesTouched.add(session.paneId); + if (session.kind === "forward") await closeForwardSession(session, reason); + else await closeWatchBridge(session, reason, true); + } + for (const [paneId, display] of [...displaysByPane]) { + if (display.nodeId !== nodeId) continue; + await broker.onDisplayClosed(paneId, reason); + } + // Forward panes with attached sockets but no bridge still lose their + // sockets: nothing on this node can ever feed them again. + for (const paneId of panesTouched) detachStreamEntries(paneId); + dropPortsCache(panesTouched); + }, + + async closeAllForUser(userId, reason) { + streamAccessRevision++; + cancelOpening((op) => op.viewerUserId === userId, reason); + let closed = 0; + for (const session of [...sessionsByRef.values()]) { + if (session.kind === "forward") { + if (session.createdBy === userId) { + await closeForwardSession(session, reason); + closed += 1; + } + continue; + } + if (!session.viewers.has(userId)) continue; + session.viewers.delete(userId); + if (session.viewers.size === 0) { + await closeWatchBridge(session, reason, true); + closed += 1; + } + } + for (const [paneId, map] of [...streamsByPane]) { + for (const [socketKey, entry] of [...map]) { + if (entry.holderUserId === userId) map.delete(socketKey); + } + if (map.size === 0) streamsByPane.delete(paneId); + } + return closed; + }, + + async closeUnauthorizedForPane(paneId) { + streamAccessRevision++; + // THE shares cut. Riding the relay's recompute posture (spec §6 names + // it): re-ask authorization per holder from THIS broker's own state, + // never diff the event that triggered the recompute. One verb covers + // removal, the edit->view downgrade, and the Everyone row's level + // change, because `loadSubshellAccess` already folds the NULL-grantee + // row into what each holder resolves to - a per-grantee diff keyed on + // named users would miss the row that reaches everybody. + const asked = new Map(); + const access = async (userId: string): Promise => { + const cached = asked.get(userId); + if (cached) return cached; + const a = await deps.accessFor(userId, paneId); + asked.set(userId, a); + return a; + }; + for (const op of [...opening]) { + if ( + op.paneId === paneId && + !accessAtLeast(await access(op.viewerUserId), op.kind === "forward" ? "edit" : "view") + ) + op.cancelled = "revoked"; + } + for (const session of [...sessionsByRef.values()]) { + if (session.paneId !== paneId) continue; + if (session.kind === "forward") { + // Below EDIT closes the whole session (lanes included) named + // `revoked`: an edit->view grantee loses ONLY the edit tier. + if (!accessAtLeast(await access(session.createdBy), "edit")) { + await closeForwardSession(session, "revoked"); + } + continue; + } + // Watchers and stream holders below VIEW drop; a bridge emptied by + // revocation closes `revoked`, not `viewer-left` (the audit names + // what actually ended it). + for (const viewer of [...session.viewers.keys()]) { + const permission = await access(viewer); + if (!accessAtLeast(permission, "view")) session.viewers.delete(viewer); + else if (permission === "view") { + // An editor could have selected a different browser on this node. + // A downgrade must enforce the same port restriction as a new watch. + const discovered = await broker.getPorts(session.nodeId, paneId).catch(() => null); + if (discovered?.cdpPort !== session.port) session.viewers.delete(viewer); + } + } + if (session.viewers.size === 0) await closeWatchBridge(session, "revoked", true); + } + // Stream sockets are PANE-keyed, not session-keyed: a pane can hold + // attached sockets with no bridge alive (the node reported the bridge + // closed; the sockets ride until something feeds them again). So this + // walk runs for EVERY shares write on the pane, forward-only included — + // pane-complete by construction, harmless when empty. + const map = streamsByPane.get(paneId); + if (map) { + for (const [socketKey, entry] of [...map]) { + if (!accessAtLeast(await access(entry.holderUserId), "view")) map.delete(socketKey); + } + if (map.size === 0) streamsByPane.delete(paneId); + } + }, + + onStreamEvent(nodeId, ev) { + if (!previewImageSizeAllowed(ev.w, ev.h, ev.blob)) return; + const session = sessionsByRef.get(ev.ref); + if (session?.kind !== "screencast" || session.nodeId !== nodeId) return; // foreign node's bridge, or a lane ref, or gone + const map = streamsByPane.get(session.paneId); + if (!map || map.size === 0) return; + const send = deps.fanOut; + if (!send) return; // never wired: an unconfigured broker never fans out + // The frame is pane-keyed and carries NO ref: the ref never crosses to + // the browser (spec §6; §8's frame type from the protocol package). + const frame: PreviewStreamFrame = { + type: "preview-stream", + id: session.paneId, + seq: ev.seq, + w: ev.w, + h: ev.h, + blob: ev.blob, + }; + for (const [socketKey, entry] of [...map]) { + // Socket subscription alone never authorizes a browser port. The REST + // watch registration checks node settings and view-only discovery. + if (!session.viewers.has(entry.holderUserId)) continue; + // The METHOD (never a property) over the raw socket, read through the + // adapter's `?? 0`: spec §9 drops slow VIEWERS, never the bridge. + const debt = entry.socket.getBufferedAmount?.() ?? 0; + if (debt > PREVIEW_STREAM_DEBT_MAX) { + map.delete(socketKey); + // The row explains a dark canvas: which node, which port, which + // kind, why. (The attach-cap row above keeps what it has — at that + // point no session has even been consulted.) + void auditPreview("node.preview_watch.viewer_drop", session.nodeId, { + paneId: session.paneId, + nodeId: session.nodeId, + port: session.port, + kind: "screencast", + reason: "slow-link", + }).catch((err: unknown) => deps.log(`preview slow-link drop audit threw: ${String(err)}`)); + continue; + } + try { + send(entry.socket, frame); + } catch (err) { + deps.log(`preview fan-out threw for one socket: ${String(err)}`); + } + } + if (map.size === 0) streamsByPane.delete(session.paneId); + }, + + onNodeLaneFrame(nodeId, frame) { + const rec = lanesByRef.get(frame.ref); + if (!rec || rec.session.nodeId !== nodeId) return; // the socket's node is the fence + if (frame.dir !== "out") return; // the plane owns the `in` leg; a node pushing one is nonsense + rec.session.lastActivity = deps.nowMs(); + const bytes = new Uint8Array(Buffer.from(frame.blob, "base64")); + if (bytes.byteLength === 0) return; + if (rec.lane.subscribers.size === 0) { + // Also bound chunk overhead when a target sends many tiny frames. + if ( + rec.lane.buffered.length >= 256 || + rec.lane.bufferedBytes + bytes.byteLength > PREVIEW_LANE_BUFFER_MAX_BYTES + ) { + void removeLane(rec, "slow-link", true).catch((err: unknown) => + deps.log(`preview buffered lane close failed: ${String(err)}`), + ); + return; + } + rec.lane.buffered.push(bytes); + rec.lane.bufferedBytes += bytes.byteLength; + return; + } + for (const cb of rec.lane.subscribers) { + try { + cb({ bytes }); + } catch (err) { + deps.log(`preview lane subscriber threw on bytes: ${String(err)}`); + } + } + }, + + async onNodeReportedClose(nodeId, ref, reason) { + // The node volunteering a death it observed (plan decision 3): a lane, + // a bridge, or a stray. The plane's own closes need no report, so + // whatever arrives here names state the node tore down machine-side. + const rec = lanesByRef.get(ref); + if (rec) { + if (rec.session.nodeId !== nodeId) return; + // A NODE-reported lane close stays WIRE-ONLY: spec §9's actions are + // the plane's own acts, and the reason word already reaches the + // viewer on the wire. No audit row, no second preview_close. + await removeLane(rec, reason, false); + return; + } + const session = sessionsByRef.get(ref); + if (!session) { + deps.log(`preview_closed for unknown ref ${ref.slice(0, 8)}… on node ${nodeId} (${reason})`); + return; + } + if (session.nodeId !== nodeId) return; + if (session.kind !== "screencast") { + // A forward SESSION ref was closed by us at probe time; a report + // naming one is stale. Its lanes, if any are still registered, were + // not named by the node (lane refs are distinct). + deps.log(`preview_closed naming a forward session ref on node ${nodeId} (${reason}); ignoring`); + return; + } + // The bridge's twin: tear the plane's half down WITH it, so the next + // startWatch hits the zero->one edge and re-sends preview_open ("a + // matching port just joins" may only ever join a LIVE bridge). + await closeWatchBridge(session, reason, false); + }, + + async sweepIdle() { + let closed = 0; + const now = deps.nowMs(); + for (const session of [...sessionsByRef.values()]) { + // FORWARD sessions only. The constant's own JSDoc names a forward + // lane; a screencast bridge's life is refcount, revocation, the + // node-reported close, and the node's lifetime event - never + // plane-side traffic silence (a watched static page produces zero + // frames by definition). + if (session.kind !== "forward") continue; + if (now - session.lastActivity < PREVIEW_IDLE_MS) continue; + await closeForwardSession(session, "idle"); + closed += 1; + } + return closed; + }, + + shutdown() { + streamAccessRevision++; + cancelOpening(() => true, "node-dropped"); + clearInterval(sweepTimer); + sessionsByRef.clear(); + bridgeRefByPane.clear(); + watchOpeningByPane.clear(); // the chains die with the instance they were flying on + lanesByRef.clear(); + streamsByPane.clear(); + displaysByPane.clear(); + portsCache.clear(); + for (const queue of laneWaitersByPane.values()) { + for (const w of queue) w.reject(new Error("preview broker reset")); + } + laneWaitersByPane.clear(); + }, + }; + + return broker; +} diff --git a/apps/server/api/src/services/subshell-manager.service.ts b/apps/server/api/src/services/subshell-manager.service.ts index e5c10c9cd..3aa105a76 100644 --- a/apps/server/api/src/services/subshell-manager.service.ts +++ b/apps/server/api/src/services/subshell-manager.service.ts @@ -11,7 +11,7 @@ import { TmuxTimeoutError, tmuxSocketFor, } from "@internal/pane-runtime"; -import type { ApiKeyPermission } from "@internal/subshell-protocol"; +import type { ApiKeyPermission, NodePreviewScreenWire } from "@internal/subshell-protocol"; import { dirAllowed, type NodeEvent, @@ -19,9 +19,13 @@ import { normalizeLabel, parseNodeProbeEntries, parseSshForwardRows, + parseStoredVirtualDisplayRequest, + parseVirtualDisplayRequest, type SshConnectionSnapshotWire, type SshForwardWire, + type VirtualDisplayRequest, } from "@internal/subshell-protocol"; +import { HttpError } from "@/api/auth-guard.js"; import { harnessUsable } from "@/api/harness-utils.js"; import { intersectSystemPermissions } from "@/auth/system-key-permissions.js"; import type { PresetsRepository } from "@/db/repositories/presets.repository.js"; @@ -33,6 +37,7 @@ import type { SubshellTable, SubshellUpdate } from "@/db/types/subshells.db-type import { getRequestlessContext } from "@/lib/context.js"; import { presetAccess } from "@/lib/preset-access.js"; import type { Access } from "@/lib/subshell-access.js"; +import { getLocalPreviewRuntime } from "@/preview/local-preview.js"; import { type AuditEventInput, audit } from "@/services/audit.js"; import { publishLive } from "@/services/live-bus.js"; // Value cycle with `lockdown.js` (it imports THIS module for the kill loop): @@ -49,6 +54,7 @@ import { subshellMcpEnv, } from "@/services/mcp-launch.js"; import { probeReporterLaunch } from "@/services/mcp-resolve.js"; +import { resolveNodeDisplayRequest } from "@/services/nodes/display-selection.js"; import { launcherFor } from "@/services/nodes/launcher-registry.js"; import { LocalLauncher } from "@/services/nodes/local-launcher.js"; import { type LogCursorRequest, readLogCursor } from "@/services/nodes/log-tail.js"; @@ -65,6 +71,7 @@ import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { subshellLogPath } from "@/services/nodes/subshell-paths.js"; import { getNotifyService, type NotifyKind } from "@/services/notify.service.js"; import { EMPTY_PRESET, parsePreset } from "@/services/preset-definition.js"; +import { closePreviewForPaneExit, getPreviewBroker } from "@/services/preview-broker.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sshCanonicalDestination } from "@/services/ssh-destination.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; @@ -383,6 +390,7 @@ export class SubshellManagerService { extraPaneEnv, subshellId, workspaceId, + screen, }: { /** Explicit permission ceiling of the creating credential. */ systemKeyPermissions?: ApiKeyPermission[] | null; @@ -467,6 +475,14 @@ export class SubshellManagerService { * the truth rather than a fiction. */ workspaceId?: string; + /** + * The virtual display size to request with the launch (pane-preview spec + * §6): forwarded verbatim to the launcher, which ships it on the frame; + * the AGENT node allocates (or refuses by name), `LocalLauncher` ignores + * it. The service preflighted the node's Preview switch before arriving + * here, so no row exists unless the plane already said yes. + */ + screen?: VirtualDisplayRequest; }): Promise<{ id: string; tmuxSocket: string; apiKey: string; promptDelivered: boolean }> { if (subshellId !== undefined && !/^[a-zA-Z0-9_-]{1,64}$/.test(subshellId)) { throw new Error("createSubshell: subshellId is outside the id grammar"); @@ -487,6 +503,7 @@ export class SubshellManagerService { } const targetNode = nodeId ?? LOCAL_NODE_ID; + if (screen) screen = await resolveNodeDisplayRequest(targetNode, screen); const launcher = this.#launcherFor(targetNode); const realPath = await launcher.validateWorkingDir(workingDir); await assertDirAllowed(targetNode, realPath); @@ -568,6 +585,7 @@ export class SubshellManagerService { // The relay pane's key home, recorded with the row (migration 0052): // immutable thereafter, and no direct pane carries it. keyHomeNodeId: ssh?.keyHomeNodeId ?? null, + ...(screen ? { screenConfig: JSON.stringify(screen) } : {}), }); if (workspaceId !== undefined) { @@ -641,6 +659,9 @@ export class SubshellManagerService { // The composed ssh member (spec 2026-10-07 decision 4). Conditional // spread: absent on every non-ssh launch, byte-identical plan there. ...(ssh ? { ssh: { configPath: ssh.configPath, fileContent: ssh.fileContent } } : {}), + // Conditional too (pane-preview §6): a plain launch's frame carries + // no screen member at all, byte-identical to the pre-preview plan. + ...(screen ? { screen } : {}), }); if (prompt?.trim()) { promptDelivered = await this.#deliverPrompt( @@ -841,7 +862,7 @@ export class SubshellManagerService { const views: ReturnType[] = []; for (const row of rows) { const preview = withPreviews ? await this.#preview(row) : []; - views.push(toSubshellView(row, row.status, preview, "owner", isNodeOffline(row.nodeId))); + views.push(toSubshellView(row, row.status, preview, "owner", isNodeOffline(row.nodeId), displayNameFor(row))); } return views; } @@ -873,7 +894,14 @@ export class SubshellManagerService { async getSubshell(userId: string, id: string): Promise | undefined> { const row = await this.#subshells.findById(id); if (!row || row.userId !== userId) return undefined; - return toSubshellView(row, row.status, await this.#preview(row), "owner", isNodeOffline(row.nodeId)); + return toSubshellView( + row, + row.status, + await this.#preview(row), + "owner", + isNodeOffline(row.nodeId), + displayNameFor(row), + ); } /** @@ -970,6 +998,7 @@ export class SubshellManagerService { return existing; // same owner, already restarting — join it } const run = (async (): Promise<{ id: string; tmuxSocket: string } | null> => { + await this.#screenForRestart(source); // Recheck here as well as in the service so a direct manager call or a // revoked grant cannot stop a live pane before discovering the refusal. const nextPresetId = swapPresetTo === undefined ? source.presetId : swapPresetTo; @@ -979,6 +1008,9 @@ export class SubshellManagerService { throw new Error("Preset access was removed. Choose another preset before restarting this subshell."); } } + // Revoke capabilities before stopping the old process: the exit hook may + // arrive after its token is revoked, and a restart keeps the same row id. + await getPreviewBroker().closeForPane(source.id, "pane-gone"); if (source.alive === 1 && source.tmuxSocket) { // killSubshell swallows "already gone"; the tree dies with its baked // key, which #reviveRow then rotates off the same row anyway. @@ -1025,6 +1057,9 @@ export class SubshellManagerService { logger.info(`subshell restart abandoned (terminate/delete raced): ${source.id}`); return null; } + // Catch opens that arrived while the old pane was stopping. The parked + // row now rejects opens until revival completes. + await getPreviewBroker().closeForPane(source.id, "pane-gone"); const parked = await this.#subshells.findById(source.id); if (!parked) return null; // deleted between park and re-read let revived: boolean; @@ -1090,6 +1125,7 @@ export class SubshellManagerService { async terminateSubshell(userId: string, id: string): Promise { const row = await this.#subshells.findById(id); if (!row || row.userId !== userId) return false; + await getPreviewBroker().closeForPane(id, "pane-gone"); let killUnverified = false; if (row.tmuxSocket) { // Kill on the node the row lives on (row-based launcher, spec §6.3). @@ -1114,6 +1150,7 @@ export class SubshellManagerService { // `terminated` there is no transition left to claim. const stopped = (await this.#subshells.updateIfAlive(id, { alive: 0 })) > 0; await this.#subshells.markTerminated(id, new Date().toISOString()); + await getPreviewBroker().closeForPane(id, "pane-gone"); publishLive({ kind: "subshell.changed", id }); // Teardown must finish even when the token store is down: a failed revoke // unlinks apiKeyId (guard 401s the key) so the audit event below still lands. @@ -1180,6 +1217,7 @@ export class SubshellManagerService { async deleteSubshell(userId: string, id: string): Promise { const row = await this.#subshells.findById(id); if (!row || row.userId !== userId) return false; + await getPreviewBroker().closeForPane(id, "pane-gone"); const launcher = this.#launcherFor(row.nodeId); if (row.tmuxSocket) { // Kill the pane even if it is already dead; try/catch so a missing @@ -1209,6 +1247,7 @@ export class SubshellManagerService { return [] as string[]; }); await this.#subshells.delete(id); + await getPreviewBroker().closeForPane(id, "pane-gone"); // The pane's row is gone, so its tab rows cascaded with it. The OWNER's // workspaces THIS emptied retire too (spec 2026-10-17: a workspace exists // exactly as long as it holds a tab), and only the ones it emptied — an @@ -1415,6 +1454,7 @@ export class SubshellManagerService { systemKeyPermissions = null, }: { backoffCount: number; systemKeyPermissions?: ApiKeyPermission[] | null }, ): Promise { + const screen = await this.#screenForRestart(row); // A NULL `presetId` is not a missing preset — it is a presetless launch // (spec 2026-09-13 §4), and it revives as exactly what it was created // as: EMPTY_PRESET. Only a NON-NULL reference whose row vanished throws. @@ -1494,6 +1534,7 @@ export class SubshellManagerService { harnessSession, reporter, bestEffortLog: true, + ...(screen ? { screen } : {}), }); // Conditional revival: a terminate that landed after the pre-spawn // check (between it and this write) must not resurrect the row — the @@ -1527,6 +1568,22 @@ export class SubshellManagerService { return true; } + async #screenForRestart(row: SubshellTable): Promise { + if (!row.screenConfig) return undefined; + let screen: VirtualDisplayRequest | null; + try { + screen = parseVirtualDisplayRequest(JSON.parse(row.screenConfig)); + } catch { + screen = null; + } + if (!screen) + throw new HttpError( + 409, + "This subshell's saved display settings are invalid. Start a new subshell with a virtual display.", + ); + return resolveNodeDisplayRequest(row.nodeId, screen, 409); + } + /** * Fires the death push on the alive→dead reconcile transition — the ONLY * place death notifies (the manual `terminateSubshell` path deliberately @@ -1576,6 +1633,7 @@ export class SubshellManagerService { /** The classic local sweep loop (verbatim pre-§6.3 body, crash branch folded into {@link #applyDeath}). */ async #reconcileLocalRows(rows: SubshellTable[], now: string): Promise { for (const row of rows) { + observeDisplayStatus(LOCAL_NODE_ID, row.id, getLocalPreviewRuntime().displays.getFor(row.id)); // A manual restart owns this row right now: it is parked (alive:0, no // pane) mid-kill→respawn and is about to come back on its own. Sweeping // it here would revoke the token #reviveRow just issued, stamp a false @@ -1724,6 +1782,7 @@ export class SubshellManagerService { // The agent answers ONLY for the ids it supervises; no entry is // the same no-evidence class as an offline node — skip, don't kill. if (!entry) continue; + if (entry.previewScreen !== undefined) observeDisplayStatus(nodeId, row.id, entry.previewScreen); if (!entry.alive) { await this.#applyDeath(row, { exitCode: entry.exitCode, endedAt: now }); continue; @@ -1741,10 +1800,11 @@ export class SubshellManagerService { /** * The shared death transition — where a probe- or report-driven death is - * stamped alive→dead AND the pane's artifacts (preview cache, ssh config dir, - * idle tmux server) are cleared. Every entry point that learns a LIVE pane is - * gone calls this: the sweep (local tmux probe or the agent `probe`/`exit` - * census), an agent `exit` event, and the `subshells_report` reconnect census. + * stamped alive→dead AND the pane's artifacts (preview cache, preview broker + * state, ssh config dir, idle tmux server) are cleared. Every entry point + * that learns a LIVE pane is gone calls this: the sweep (local tmux probe + * or the agent `probe`/`exit` census), an agent `exit` event, and the + * `subshells_report` reconnect census. * (The reconcile's no-socket fast path — a row that cannot be alive — stamps * directly rather than routing here, and repeats the ssh sweep so no * alive→dead path misses it.) Idempotent by construction: the stamp + death @@ -1791,6 +1851,7 @@ export class SubshellManagerService { // a subshell that will never come back has its MCP token revoked here. // "Never" means opted out OR the backoff limit was exhausted (the // sweep gives up at that count, so the bearer would linger otherwise). + if (fresh.nodeId === LOCAL_NODE_ID) await getLocalPreviewRuntime().displays.closeFor(fresh.id); const restarted = await this.maybeAutoRestart(fresh); // The ssh config dir is a pane artifact like the preview cache and the idle // tmux server below: it dies with the pane, HERE, in the ONE shared @@ -1803,7 +1864,14 @@ export class SubshellManagerService { // The relay cut runs whatever the restart ladder decided: an agent-capable // ssh pane never auto-restarts (M1 decision 7), and a restart of anything // else must not strand the relay session of the process that just died. + // Same posture, same belt, for the preview state: the exit route's own + // `closeForPane` is the fast path, but when the hook report is lost it is + // THIS line that keeps a forward session (and the browser stream sockets + // attached to the pane) from outliving it to the idle and lifetime caps. + // Fire-and-forget like the relay's: a crashed pane's node may not answer + // the lane closes, and the sweep must keep walking rows. closeRelayForPaneExit(fresh.id); + closePreviewForPaneExit(fresh.id); if (!restarted) sweepLocalSshDir(fresh); if (!restarted && (fresh.restartOnExit !== 1 || fresh.backoffCount >= 5)) { // Terminal: this subshell will never come back, so its bearer must @@ -2346,6 +2414,7 @@ function normalizePaneTitle(raw: string): string { export function toSubshellView( row: { + screenConfig?: string | null; id: string; userId: string; presetId: string | null; @@ -2389,10 +2458,13 @@ export function toSubshellView( * and read false. */ nodeOffline = false, + virtualDisplay: string | null = null, ) { const sshSummary = row.ssh !== null ? sshPaneSummary(row.ssh) : null; return { id: row.id, + screen: parseStoredVirtualDisplayRequest(row.screenConfig), + virtualDisplay: status === "running" && row.alive === 1 ? virtualDisplay : null, presetId: row.presetId, harnessId: row.harnessId, nodeId: row.nodeId, @@ -2473,3 +2545,16 @@ export function sshPaneSummary(raw: string | null): { destination: string; forwa const forwards = column.declaredForwards === undefined ? [] : parseSshForwardRows(column.declaredForwards); return { destination, forwards }; } + +/** Cheap metadata read: remote displays arrive on the existing batched probe. */ +function displayNameFor(row: { id: string; nodeId: string }): string | null { + if (row.nodeId === LOCAL_NODE_ID) return getLocalPreviewRuntime().displays.getFor(row.id)?.display ?? null; + return getPreviewBroker().displayForPane(row.id)?.screen.display ?? null; +} + +function observeDisplayStatus(nodeId: string, id: string, screen: NodePreviewScreenWire | null): void { + const broker = getPreviewBroker(); + const before = broker.displayForPane(id); + broker.observeDisplay(nodeId, id, screen); + if ((before?.screen.display ?? null) !== (screen?.display ?? null)) publishLive({ kind: "subshell.changed", id }); +} diff --git a/apps/server/api/src/services/subshell-services.ts b/apps/server/api/src/services/subshell-services.ts new file mode 100644 index 000000000..ba568edc3 --- /dev/null +++ b/apps/server/api/src/services/subshell-services.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "node:crypto"; +import { normalizeLabel, normalizeServiceHost } from "@internal/subshell-protocol"; +import { HttpError } from "@/api/auth-guard.js"; +import { db } from "@/db/index.js"; +import { audit } from "@/services/audit.js"; +import { publishLive } from "@/services/live-bus.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; + +export const MAX_SAVED_SERVICES = 100; +export function listServices(subshellId: string) { + return db + .selectFrom("subshellServices") + .selectAll() + .where("subshellId", "=", subshellId) + .orderBy("createdAt") + .orderBy("id") + .execute(); +} +export async function getService(subshellId: string, id: string) { + const row = await db + .selectFrom("subshellServices") + .selectAll() + .where("subshellId", "=", subshellId) + .where("id", "=", id) + .executeTakeFirst(); + if (!row) throw new HttpError(404, "This saved service was removed. Add it again from Web previews & screens."); + return row; +} +export async function saveService( + subshellId: string, + input: { name?: string; host?: string; port: number; protocol: "http" | "https" }, + actorUserId: string | null = null, +) { + const host = normalizeServiceHost(input.host ?? "localhost"); + if (!host) throw new HttpError(400, "Enter a hostname or IP address without a protocol, path, or credentials."); + // The insert and limit check share one transaction: concurrent tabs cannot exceed the bound. + const row = await db.transaction().execute(async (tx) => { + const existing = await tx + .selectFrom("subshellServices") + .selectAll() + .where("subshellId", "=", subshellId) + .where("host", "=", host) + .where("port", "=", input.port) + .where("protocol", "=", input.protocol) + .executeTakeFirst(); + if (existing) return existing; + const count = await tx + .selectFrom("subshellServices") + .select(tx.fn.countAll().as("n")) + .where("subshellId", "=", subshellId) + .executeTakeFirstOrThrow(); + if (count.n >= MAX_SAVED_SERVICES) + throw new HttpError(409, "This subshell has 100 saved services. Remove one before adding another."); + return tx + .insertInto("subshellServices") + .values({ + id: randomUUID(), + subshellId, + host, + port: input.port, + protocol: input.protocol, + name: normalizeLabel(input.name ?? "", 80), + createdAt: new Date().toISOString(), + }) + .returningAll() + .executeTakeFirstOrThrow(); + }); + await audit({ + actorUserId, + action: "subshell.service.save", + targetType: "subshell", + targetId: subshellId, + metadataJson: JSON.stringify({ serviceId: row.id, host: row.host, port: row.port, protocol: row.protocol }), + }); + publishLive({ kind: "subshell.changed", id: subshellId }); + return row; +} +export async function removeService(subshellId: string, id: string, actorUserId: string | null = null) { + await db.deleteFrom("subshellServices").where("subshellId", "=", subshellId).where("id", "=", id).execute(); + await getPreviewBroker().removeService(subshellId, id); + await audit({ + actorUserId, + action: "subshell.service.remove", + targetType: "subshell", + targetId: subshellId, + metadataJson: JSON.stringify({ serviceId: id }), + }); + publishLive({ kind: "subshell.changed", id: subshellId }); + return { ok: true }; +} diff --git a/apps/server/api/src/services/subshells.service.ts b/apps/server/api/src/services/subshells.service.ts index edef12df1..823bc7518 100644 --- a/apps/server/api/src/services/subshells.service.ts +++ b/apps/server/api/src/services/subshells.service.ts @@ -9,7 +9,13 @@ import { BackendErrorCodes, throwApiError } from "@internal/backend-errors"; import type { AttentionKind } from "@internal/mcp-core"; import { allHarnesses, getHarness, tmuxSocketFor } from "@internal/pane-runtime"; import type { ApiKeyPermission } from "@internal/subshell-protocol"; -import { joinPresetPrompt, NODE_RESULT_MAINTENANCE, parsePresetPromptBlocks } from "@internal/subshell-protocol"; +import { + joinPresetPrompt, + NODE_RESULT_MAINTENANCE, + parsePresetPromptBlocks, + parseVirtualDisplayRequest, + type VirtualDisplayRequest, +} from "@internal/subshell-protocol"; import type { GuardActor } from "@/api/auth-guard.js"; import { HttpError } from "@/api/auth-guard.js"; import { harnessUsable } from "@/api/harness-utils.js"; @@ -38,8 +44,10 @@ import { probeQuiet, waitSentinel, } from "@/services/nodes/pane-exec.js"; +import { getVirtualDisplayEnabled } from "@/services/nodes/preview-enabled.js"; import { isNodeOfflineError } from "@/services/nodes/remote-launcher.js"; import { getNotifyService } from "@/services/notify.service.js"; +import { getPreviewBroker } from "@/services/preview-broker.js"; import { serverSubshellsEnabled } from "@/services/server-as-node.js"; import { sweepLocalSshDir } from "@/services/ssh-launch.service.js"; import { closeRelayForPaneExit, sweepRelayForPane } from "@/services/ssh-relay.service.js"; @@ -449,6 +457,7 @@ export class SubshellsService extends BaseService { extraPaneEnv, subshellId, workspaceId, + screen, }: { /** Permission ceiling passed to the new pane credential. */ systemKeyPermissions?: ApiKeyPermission[] | null; @@ -510,6 +519,12 @@ export class SubshellsService extends BaseService { * row before anything spawns. */ workspaceId?: string; + /** + * A virtual display at this size, allocated by the node's selected provider. + * The plane checks the node's permission before any row, token, or RPC; + * the node checks the provider's requirements before allocating resources. + */ + screen?: VirtualDisplayRequest | null; }): Promise<{ id: string; tmuxSocket: string; promptDelivered: boolean }> { // Lockdown is the FIRST question the instance asks of every create // (operator ask 2026-09-24): it answers valid bodies and invalid ones @@ -634,6 +649,32 @@ export class SubshellsService extends BaseService { 409, ); } + // The gated screen preflight (spec §6): a `screen` launch against a node + // whose Preview switch is off is refused HERE, before the row, the + // token, and the wire, with the same remedy the node itself names. The + // node's Task 7 gate stays the authority for every fact the plane's row + // cannot hold (platform, binaries). Placed in the service rather than + // the launcher so a refused create never creates a pane at all. + if (screen === undefined && presetRow?.screenConfig) { + try { + screen = parseVirtualDisplayRequest(JSON.parse(presetRow.screenConfig)); + } catch { + screen = null; + } + if (!screen) + throw new SubshellCreateError( + "bad_request", + "This preset’s display settings are invalid. Edit the preset before launching.", + 400, + ); + } + if (screen && !(await getVirtualDisplayEnabled(resolvedNodeId))) { + throw new SubshellCreateError( + "bad_request", + "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings, or launch without a virtual display.", + 400, + ); + } // The deferred default (spec 2026-10-01 §2): the launch node's home, read // only from sources this request already has — the local host's homedir, // or the agent's reported `ready` facts. A node that has never reported a @@ -683,6 +724,7 @@ export class SubshellsService extends BaseService { ...(extraPaneEnv !== undefined ? { extraPaneEnv } : {}), ...(subshellId !== undefined ? { subshellId } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(screen ? { screen } : {}), }) .catch(rethrowLaunchRefusal); // Feed the picker's Recents (and the new-subshell form's pre-fill) from @@ -1243,6 +1285,14 @@ export class SubshellsService extends BaseService { // thing that can tell a revoked grantee (spec §4.2). Deriving which topics // that means stays in the publisher — the service carries domain facts. publishLive({ kind: "subshell.shares-changed", id, before: { ownerUserId: row.userId, shares: before } }); + // A preview session is broker state, not a socket: the shares-changed + // publish tells /ws/live viewers the row moved, but it cannot touch a + // forward lane's node connection or a watch count. Recomputed against + // the POST-write grant set, every lane below `edit` and every viewer + // below `view` on THIS pane dies now (pane-preview spec §7; the relay + // twin is `closeUnauthorizedForNode` in the set-node-shares route). + // Awaited so the answer does not race the cut it describes. + await getPreviewBroker().closeUnauthorizedForPane(id); return { shares: await this.#shareViews(id) }; } diff --git a/apps/server/api/src/ws/__tests__/live-consistency.test.ts b/apps/server/api/src/ws/__tests__/live-consistency.test.ts index bce9c8681..5173d1b4d 100644 --- a/apps/server/api/src/ws/__tests__/live-consistency.test.ts +++ b/apps/server/api/src/ws/__tests__/live-consistency.test.ts @@ -1,12 +1,16 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; import { hashPassword } from "better-auth/crypto"; import { Elysia } from "elysia"; import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "@/api/__tests__/helpers/auth-tables.js"; import { listSubshellsRoute } from "@/api/subshells/list-subshells.route.js"; import { db } from "@/db/index.js"; +import { SubshellSharesRepository } from "@/db/repositories/subshell-shares.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; -import { handleLiveClose, handleLiveOpen, type LiveWsSocket, liveWsDeps } from "@/ws/live-ws.js"; +import { getPreviewBroker, resetForTests, shutdownPreviewBrokerForTests } from "@/services/preview-broker.js"; +import { sendPreviewStreamSocket } from "@/ws/live-registry.js"; +import { handleLiveClose, handleLiveMessage, handleLiveOpen, type LiveWsSocket, liveWsDeps } from "@/ws/live-ws.js"; import { issueWsToken } from "@/ws/ws-token.js"; /** @@ -130,3 +134,248 @@ describe("the live feed's snapshot matches the REST list (one cache, two produce expect(snapshot.map((s) => s.id).sort()).toEqual(rest.map((s) => s.id).sort()); }); }); + +/** + * The preview half of THE control (§11.14): for `watch` subscriptions, the + * sockets that receive `preview-stream` for a pane must be EXACTLY the view-+ + * holders that sent `watch`. Same doctrine as the snapshot rows above, with + * the sharpest edge yet: an admin WITHOUT a grant gets nothing (the 2026-10-09 + * ruling, asserted here at the stream fan-out, not just at the list), and the + * owner's own second socket that never watched gets nothing. + * + * Nothing here stands on a stub: the sockets open through the real + * `handleLiveOpen` with the real `liveWsDeps()`, the access answers come from + * real share rows, the revocation rows call the very + * `closeUnauthorizedForPane` verb the shares route calls, and the frames ride + * the real broker mechanics with the shipped serializer installed. Only the + * node link is faked (there is no node), and even that records its commands. + * + * Before the revocation rows, note this file carried NO revocation case + * (its two snapshot rows are pure feed-vs-REST equality). The socket-alive + * precedent is `live-ws.ts`'s resync path: a client that received a row it + * cannot render ASKS for the list again and its socket keeps running. These + * rows match THAT behavior for the stream half: frames stop, the socket + * lives, the canvas goes dark; nothing closes. + */ + +const pvPassword = "live-preview-pass-1"; +const pvOwnerEmail = `pv-owner-${crypto.randomUUID()}@subshell.local`; +const pvEditEmail = `pv-edit-${crypto.randomUUID()}@subshell.local`; +const pvViewEmail = `pv-view-${crypto.randomUUID()}@subshell.local`; +const pvAdminEmail = `pv-admin-${crypto.randomUUID()}@subshell.local`; + +const PV_NODE = "n-pv-fake"; +let pvPaneId = ""; +let pvOwnerId = ""; +let pvEditId = ""; +let pvViewId = ""; +let pvAdminId = ""; + +/** The pane's bridge ref after the per-test open; every row streams from it. */ +let pvBridgeRef = ""; +const pvFeeds: { ws: LiveWsSocket; closed: { code?: number; reason?: string }[]; sent: string[] }[] = []; + +/** Opens the feed as `userId` through the REAL deps and awaits the snapshot. */ +async function openFeed(userId: string) { + const token = issueWsToken(userId); + const sent: string[] = []; + const closed: { code?: number; reason?: string }[] = []; + const ws: LiveWsSocket = { + data: { query: { token } }, + send(data: string) { + sent.push(data); + return 1; + }, + close(code?: number, reason?: string) { + closed.push({ code, reason }); + }, + }; + pvFeeds.push({ ws, closed, sent }); + await handleLiveOpen(ws, liveWsDeps()); + return { ws, closed, sent }; +} + +function streamCount(feed: { sent: string[] }): number { + return feed.sent.filter((s) => (JSON.parse(s) as { type: string }).type === "preview-stream").length; +} + +function watchPane(ws: LiveWsSocket, paneId: string): Promise { + return handleLiveMessage(ws, JSON.stringify({ type: "watch", id: paneId }), liveWsDeps()); +} + +function pvStreamEvent(seq: number): Extract { + return { + type: "preview_stream", + ref: pvBridgeRef, + seq, + w: 80, + h: 25, + ts: 1_780_000_000_000 + seq, + blob: `pv-${seq}`, + }; +} + +/** Shares writes go through the very repository the shares route uses. */ +const pvShares = new SubshellSharesRepository(db); + +describe("preview-stream reaches EXACTLY the view-+ holders that sent watch (the §11.14 stream half)", () => { + beforeAll(async () => { + await setupAuthTables(); + const users = new UsersRepository(db); + pvOwnerId = await users.createUser({ + email: pvOwnerEmail, + name: pvOwnerEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvEditId = await users.createUser({ + email: pvEditEmail, + name: pvEditEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvViewId = await users.createUser({ + email: pvViewEmail, + name: pvViewEmail, + passwordHash: await hashPassword(pvPassword), + role: "user", + }); + pvAdminId = await users.createUser({ + email: pvAdminEmail, + name: pvAdminEmail, + passwordHash: await hashPassword(pvPassword), + role: "admin", + }); + pvPaneId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: pvPaneId, + userId: pvOwnerId, + presetId: "p", + harnessId: "claude-code", + name: "pv-pane", + workingDir: "/tmp", + tmuxSocket: null, + }); + await pvShares.replaceForSubshell( + pvPaneId, + [ + { granteeUserId: pvEditId, permission: "edit" }, + { granteeUserId: pvViewId, permission: "view" }, + ], + pvOwnerId, + ); + }); + + beforeEach(async () => { + // Fake the link, keep the fan-out real: the installed sender is the + // shipped serializer, so the broker fans out exactly as production + // would, JSON and all. (Passing it explicitly also makes the rows + // order-independent against the process-global boot slot in a serial + // run.) The broker's access seam stays PRODUCTION here: the revocation + // rows must recompute against the same share rows the routes rewrote. + resetForTests({ + send: async (_nodeId: string, cmd: NodeCommandBody): Promise => { + if (cmd.type === "preview_open") return { ref: cmd.ref, subscribers: 1 }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + if (cmd.type === "preview_ports_get") return { ports: [], cdpPort: 9222, screen: null }; + return {}; + }, + sendToSockets: sendPreviewStreamSocket, + }); + await getPreviewBroker().startWatch({ viewerUserId: pvOwnerId, paneId: pvPaneId, nodeId: PV_NODE, port: 9222 }); + const bridge = getPreviewBroker().sessionForPane(pvPaneId); + if (!bridge) throw new Error("preview matrix: the bridge did not stand"); + pvBridgeRef = bridge.ref; + pvFeeds.length = 0; + }); + + afterAll(async () => { + for (const feed of pvFeeds) handleLiveClose(feed.ws); + shutdownPreviewBrokerForTests(); + await db.deleteFrom("subshellShares").where("subshellId", "=", pvPaneId).execute(); + await db.deleteFrom("subshells").where("id", "=", pvPaneId).execute(); + await deleteUserByEmailOrId(pvOwnerEmail); + await deleteUserByEmailOrId(pvEditEmail); + await deleteUserByEmailOrId(pvViewEmail); + await deleteUserByEmailOrId(pvAdminEmail); + }); + + it("after watch, the sockets that receive frames are EXACTLY the view-+ holders that watched", async () => { + for (const viewerUserId of [pvEditId, pvViewId]) { + await getPreviewBroker().startWatch({ viewerUserId, paneId: pvPaneId, nodeId: PV_NODE, port: 9222 }); + } + const owner = await openFeed(pvOwnerId); + const ownerSibling = await openFeed(pvOwnerId); // second tab of the same holder, never watches + const editor = await openFeed(pvEditId); + const viewer = await openFeed(pvViewId); + const admin = await openFeed(pvAdminId); // role grants nothing on a foreign pane + + await watchPane(owner.ws, pvPaneId); + await watchPane(editor.ws, pvPaneId); + await watchPane(viewer.ws, pvPaneId); + // ownerSibling and admin send nothing. + + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + + const recipients = [owner, ownerSibling, editor, viewer, admin].filter((f) => streamCount(f) === 1); + expect(recipients.length).toBe(3); + expect(streamCount(owner)).toBe(1); // owner counts as view-+ + expect(streamCount(editor)).toBe(1); + expect(streamCount(viewer)).toBe(1); + expect(streamCount(ownerSibling)).toBe(0); // the owner's socket that never watched: nothing + expect(streamCount(admin)).toBe(0); // an admin without a grant: nothing + // And what arrived is the frame, blob untouched, id addressed by pane + // (the bridge ref never crosses to the browser). + expect( + owner.sent.map((s) => JSON.parse(s) as Record).find((f) => f.type === "preview-stream"), + ).toEqual({ type: "preview-stream", id: pvPaneId, seq: 1, w: 80, h: 25, blob: "pv-1" }); + }); + + it("a watch from an admin with no grant attaches nothing and closes nothing", async () => { + const admin = await openFeed(pvAdminId); + // The admin's ROLE must not open the stream: watch resolves through the + // grants axis like every other pane door, and there is no grant. + await watchPane(admin.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect(streamCount(admin)).toBe(0); + expect(admin.closed).toEqual([]); // invisible reads as silence, never as a close + }); + + it("stops frames to a socket whose grant is removed mid-stream while the socket itself lives", async () => { + await getPreviewBroker().startWatch({ viewerUserId: pvViewId, paneId: pvPaneId, nodeId: PV_NODE, port: 9222 }); + const owner = await openFeed(pvOwnerId); + const viewer = await openFeed(pvViewId); + await watchPane(owner.ws, pvPaneId); + await watchPane(viewer.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect([streamCount(owner), streamCount(viewer)]).toEqual([1, 1]); + + // The shares cut, exactly as the route performs it: rewrite the rows, + // then re-ask the broker's recompute. + await pvShares.replaceForSubshell(pvPaneId, [{ granteeUserId: pvEditId, permission: "edit" }], pvOwnerId); + await getPreviewBroker().closeUnauthorizedForPane(pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(2)); + + expect(streamCount(viewer)).toBe(1); // seq 2 never arrives + expect(viewer.closed).toEqual([]); // the socket stays live (the resync precedent: the client re-asks, nothing closes it) + expect(streamCount(owner)).toBe(2); // untouched holders keep streaming + }); + + it("keeps stream frames flowing through an edit-to-view downgrade (watch is what view buys)", async () => { + await getPreviewBroker().startWatch({ viewerUserId: pvEditId, paneId: pvPaneId, nodeId: PV_NODE, port: 9222 }); + const editor = await openFeed(pvEditId); + await watchPane(editor.ws, pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(1)); + expect(streamCount(editor)).toBe(1); + + await pvShares.replaceForSubshell(pvPaneId, [{ granteeUserId: pvEditId, permission: "view" }], pvOwnerId); + await getPreviewBroker().closeUnauthorizedForPane(pvPaneId); + getPreviewBroker().onStreamEvent(PV_NODE, pvStreamEvent(2)); + + // The stream half: `view` still buys the screencast, so the SAME socket + // keeps receiving. What the downgrade does close is the EDIT tier + // (forward lanes) - the split Task 8's broker test owns. + expect(streamCount(editor)).toBe(2); + expect(editor.closed).toEqual([]); + }); +}); diff --git a/apps/server/api/src/ws/__tests__/live-ws.test.ts b/apps/server/api/src/ws/__tests__/live-ws.test.ts index 581f5a85a..86be59e36 100644 --- a/apps/server/api/src/ws/__tests__/live-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/live-ws.test.ts @@ -1,4 +1,13 @@ import { describe, expect, it } from "bun:test"; +import { + type NodeCommandBody, + type NodeEvent, + PREVIEW_STREAM_DEBT_MAX, + PREVIEW_WATCH_SUBSCRIBERS_MAX, + type PreviewStreamFrame, +} from "@internal/subshell-protocol"; +import { getPreviewBroker, resetForTests } from "@/services/preview-broker.js"; +import { sendPreviewStreamSocket, sendToSockets } from "@/ws/live-registry.js"; import { EVERYONE_TOPIC, userTopic } from "@/ws/live-topics.js"; import { handleLiveClose, @@ -20,14 +29,19 @@ import { * the socket, which is how the first version of this endpoint leaked an * interval per closed socket while its test stayed green. */ -function fakeSocket(query: Record = {}) { +function fakeSocket(query: Record = {}, raw: unknown = { getBufferedAmount: () => 0 }) { const sent: string[] = []; const closed: { code?: number; reason?: string }[] = []; const subscribed: string[] = []; const data: LiveWsSocket["data"] = { query }; let readyState = 1; // OPEN + // `raw` is the SAME instance in both wrappers, as in production: the socket + // the watch adapter hands the broker reads its byte debt through it, so a + // double that fakes the raw socket per-wrapper would miss exactly the + // property the adapter exists to carry. const wrapper = (): LiveWsSocket => ({ data, + raw, get readyState() { return readyState; }, @@ -68,6 +82,10 @@ function deps(over: Partial = {}): LiveWsDeps { // factory hands out. Individual cases flip it. accountDisabled: async () => false, passwordChangeRequired: async () => false, + // The `watch` gate asks one question; the healthy answer by default is + // this socket's owner watching their own pane. Rows that probe the gate + // flip it (a `view` grant, a foreign `none`). + accessFor: async () => "owner", ...over, }; } @@ -371,3 +389,343 @@ describe("/ws/live bounded work and resync", () => { expect(frames().filter((f) => f.type === "preview")).toEqual([]); }); }); + +/* ------------------------------------------------------------------ */ +/* watch/unwatch: the screencast fan-out wired through the broker */ +/* ------------------------------------------------------------------ */ + +/** + * Rebuilds the broker over fake node seams with the REAL fan-out sender + * installed (`sendPreviewStreamSocket`, the very function `live-ws.ts` wires + * at module init). Passing the seam explicitly keeps every row below + * deterministic no matter which sender won the process-global boot slot in + * a serial run: what these rows exercise is the real broker mechanics and + * the real serialization, driven through the real handler. + */ +function fakePreviewNode(accessFor?: LiveWsDeps["accessFor"]): { commands: NodeCommandBody[] } { + const commands: NodeCommandBody[] = []; + resetForTests({ + send: async (_nodeId: string, cmd: NodeCommandBody): Promise => { + commands.push(cmd); + if (cmd.type === "preview_open") return { ref: cmd.ref, subscribers: 1 }; + if (cmd.type === "preview_close") return { ref: cmd.ref, closed: true }; + return {}; + }, + sendToSockets: sendPreviewStreamSocket, + accessFor, + }); + return { commands }; +} + +/** The pane's live screencast bridge, built the way the REST watch path builds it. */ +async function openBridge(paneId: string, nodeId = "n-fake"): Promise { + const broker = getPreviewBroker(); + await broker.startWatch({ viewerUserId: "u1", paneId, nodeId, port: 9222 }); + const ref = broker.sessionForPane(paneId)?.ref; + if (!ref) throw new Error(`bridge for ${paneId} did not stand`); + return ref; +} + +function streamEvent(ref: string, seq: number): Extract { + return { type: "preview_stream", ref, seq, w: 64, h: 32, ts: 1_780_000_000_000 + seq, blob: `blob-${seq}` }; +} + +function streamFrames(sent: string[]): Record[] { + return sent.map((s) => JSON.parse(s) as Record).filter((f) => f.type === "preview-stream"); +} + +describe("/ws/live watch subscriptions", () => { + it("refuses a disabled account before its live socket has been closed", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + let disabled = false; + const d = deps({ accountDisabled: async () => disabled }); + const { ws, closeWs, sent } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + disabled = true; + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + try { + expect(streamFrames(sent)).toEqual([]); + } finally { + handleLiveClose(closeWs); + } + }); + + it("stops retrying when revocation repeatedly overtakes the access check", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + let asked = 0; + const d = deps({ + accessFor: async () => { + asked++; + await getPreviewBroker().closeUnauthorizedForPane("another-pane"); + return "view"; + }, + }); + const { ws, closeWs, sent } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + try { + expect(streamFrames(sent)).toEqual([]); + expect(asked).toBe(3); + } finally { + handleLiveClose(closeWs); + } + }); + + for (const cut of ["share", "user"] as const) { + it(`rechecks a pending stream subscription after ${cut} revocation`, async () => { + let allowed = true; + fakePreviewNode(async (userId) => (userId === "u1" && !allowed ? "none" : "owner")); + const ref = await openBridge("p-1"); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered = () => {}; + const started = new Promise((resolve) => { + entered = resolve; + }); + let asked = 0; + const d = deps({ + accessFor: async () => { + asked++; + const answer = allowed ? "view" : "none"; + if (asked === 1) { + entered(); + await gate; + } + return answer; + }, + }); + const { ws, closeWs, sent } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + const pending = handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + await started; + allowed = false; + if (cut === "share") await getPreviewBroker().closeUnauthorizedForPane("p-1"); + else await getPreviewBroker().closeAllForUser("u1", "revoked"); + release(); + await pending; + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + try { + expect(streamFrames(sent)).toEqual([]); + expect(asked).toBe(2); + } finally { + handleLiveClose(closeWs); + } + }); + } + + it("a watch from a view grant attaches the socket; frames arrive with the blob untouched, and only there", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + // Two sockets of the SAME holder (both redeem to u1): only the first + // sends `watch`, so only the first may receive. + const watcher = fakeSocket({ token: "good" }); + const sibling = fakeSocket({ token: "good" }); + const d = deps({ accessFor: async () => "view" }); + await handleLiveOpen(watcher.ws, d); + await handleLiveOpen(sibling.ws, d); + await handleLiveMessage(watcher.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(streamFrames(watcher.sent)).toEqual([ + { type: "preview-stream", id: "p-1", seq: 1, w: 64, h: 32, blob: "blob-1" }, + ]); + expect(streamFrames(sibling.sent)).toEqual([]); + handleLiveClose(watcher.closeWs); + handleLiveClose(sibling.closeWs); + }); + + it("a watch the access check fails attaches nothing and never closes the socket", async () => { + fakePreviewNode(); + const ref = await openBridge("p-foreign"); + const asked: string[] = []; + const d = deps({ + accessFor: async (_u, paneId) => { + asked.push(paneId); + return "none"; + }, + }); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + // Spy straight at the broker: the check must be the last thing standing + // between a stranger and the stream set. + const broker = getPreviewBroker(); + const realAttach = broker.attachStream; + const attached: string[] = []; + broker.attachStream = (paneId, socketKey, holderUserId, socket) => { + attached.push(paneId); + return realAttach(paneId, socketKey, holderUserId, socket); + }; + + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-foreign" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(asked).toEqual(["p-foreign"]); // the gate was asked + expect(attached).toEqual([]); // and refused: nothing reached the broker + expect(streamFrames(sent)).toEqual([]); + expect(closed).toEqual([]); // invisible reads as silence, never as a refusal + handleLiveClose(closeWs); + }); + + it("unwatch stops delivery and leaves the socket open", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + const d = deps(); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + await handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(sent)).toHaveLength(1); + + await handleLiveMessage(ws, JSON.stringify({ type: "unwatch", id: "p-1" }), d); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 2)); + + expect(streamFrames(sent)).toHaveLength(1); // seq 2 never arrived + expect(closed).toEqual([]); + handleLiveClose(closeWs); + }); + + /** + * THE swapped-wrapper pin. Elysia hands `close` a fresh `ElysiaWS` that + * shares `data` with the one `open` got (the file-header measurement), so + * a stream registry keyed by the SOCKET OBJECT would find nothing here and + * leak the entry for the pane's life. This double builds a fresh wrapper + * per call exactly like the adapter does: closing through `closeWs` (never + * the `ws` that attached) must still detach. + */ + it("closing through the swapped wrapper detaches every pane; the holder's other socket is unaffected", async () => { + fakePreviewNode(); + const refA = await openBridge("p-a"); + const refB = await openBridge("p-b"); + const d = deps(); + const a = fakeSocket({ token: "good" }); + const b = fakeSocket({ token: "good" }); + await handleLiveOpen(a.ws, d); + await handleLiveOpen(b.ws, d); + for (const pane of ["p-a", "p-b"]) { + await handleLiveMessage(a.ws, JSON.stringify({ type: "watch", id: pane }), d); + await handleLiveMessage(b.ws, JSON.stringify({ type: "watch", id: pane }), d); + } + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refA, 1)); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refB, 1)); + expect(streamFrames(a.sent)).toHaveLength(2); + expect(streamFrames(b.sent)).toHaveLength(2); + + // Close socket a through the wrapper it never attached with. + handleLiveClose(a.closeWs); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refA, 2)); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(refB, 2)); + expect(streamFrames(a.sent)).toHaveLength(2); // both panes detached by one close + expect(streamFrames(b.sent)).toHaveLength(4); // sibling untouched, both panes still flowing + handleLiveClose(b.closeWs); + }); + + it("byte debt on one socket detaches THAT socket through the raw-socket adapter; bridge and lane stay untouched", async () => { + const node = fakePreviewNode(); + const ref = await openBridge("p-1"); + const d = deps(); + // The lagging socket's raw reports debt above the cap; the handler's + // adapter must read the METHOD off `raw` (the wrapper carries neither + // property nor method - Task 8's measurement is why this exists at all). + const slow = fakeSocket({ token: "good" }, { getBufferedAmount: () => PREVIEW_STREAM_DEBT_MAX + 1 }); + const fast = fakeSocket({ token: "good" }); + await handleLiveOpen(slow.ws, d); + await handleLiveOpen(fast.ws, d); + await handleLiveMessage(slow.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + await handleLiveMessage(fast.ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(slow.sent)).toEqual([]); // dropped before its frame was sent + expect(streamFrames(fast.sent)).toHaveLength(1); + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 2)); + // Still silent. This row cannot tell "detached" from "skipped again" + // while the debt stays over cap; the deletion itself is pinned by the + // broker's viewer_drop row in preview-broker.test.ts. + expect(streamFrames(slow.sent)).toEqual([]); + expect(streamFrames(fast.sent)).toHaveLength(2); + + // The drop is viewer-side only: the bridge still stands and the wire saw + // no close of anything (spec §9 drops slow viewers, never the bridge). + expect(getPreviewBroker().sessionForPane("p-1")).not.toBeNull(); + expect(node.commands.filter((c) => c.type === "preview_close")).toEqual([]); + handleLiveClose(slow.closeWs); + handleLiveClose(fast.closeWs); + }); + + /** + * The ninth socket's experience is the dark canvas: the attach cap refuses + * with a VALUE plus an audit row (both Task 8's), and this row pins the + * handler's half: the refusal is swallowed. Not thrown, not a close, not a + * registration. + */ + it("a watch the attach cap refuses leaves the socket open and unregistered", async () => { + fakePreviewNode(); + const ref = await openBridge("p-1"); + const broker = getPreviewBroker(); + const quiet: string[] = []; + for (let i = 0; i < PREVIEW_WATCH_SUBSCRIBERS_MAX; i += 1) { + expect(broker.attachStream("p-1", `fill-${i}`, "u1", { send: (d) => quiet.push(d) })).toEqual({ ok: true }); + } + const d = deps(); + const { ws, closeWs, sent, closed } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + + await expect(handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d)).resolves.toBeUndefined(); + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + + expect(streamFrames(sent)).toEqual([]); // unregistered: the pane's frames skip it + expect(closed).toEqual([]); // and it stays open on the feed + expect(quiet).toHaveLength(PREVIEW_WATCH_SUBSCRIBERS_MAX); // the eight it displaced keep streaming + handleLiveClose(closeWs); + }); + + it("a watch that lands after the socket closed during the access check attaches nothing", async () => { + // `liveStop` is the close marker: a close that lands while the gate's + // await is in flight must not leave a dead socket in the stream set. + fakePreviewNode(); + const ref = await openBridge("p-1"); + let release: () => void = () => {}; + const gate = new Promise((r) => { + release = r; + }); + const d = deps({ + accessFor: async () => { + await gate; + return "view"; + }, + }); + const { ws, closeWs, sent } = fakeSocket({ token: "good" }); + await handleLiveOpen(ws, d); + const pending = handleLiveMessage(ws, JSON.stringify({ type: "watch", id: "p-1" }), d); + handleLiveClose(closeWs); + release(); + await pending; + + getPreviewBroker().onStreamEvent("n-fake", streamEvent(ref, 1)); + expect(streamFrames(sent)).toEqual([]); + }); +}); + +describe("live-registry preview fan-out primitives", () => { + it("one send per socket, JSON-serialized in both shapes", () => { + const frame: PreviewStreamFrame = { type: "preview-stream", id: "p", seq: 7, w: 4, h: 2, blob: "zz" }; + const a: string[] = []; + const b: string[] = []; + const sa = { send: (d: string) => a.push(d) }; + const sb = { send: (d: string) => b.push(d) }; + + sendPreviewStreamSocket(sa, frame); + sendToSockets([sa, sb], frame); + + expect(a).toEqual([JSON.stringify(frame), JSON.stringify(frame)]); + expect(b).toEqual([JSON.stringify(frame)]); + }); +}); diff --git a/apps/server/api/src/ws/live-registry.ts b/apps/server/api/src/ws/live-registry.ts index b146d15c4..813f89d17 100644 --- a/apps/server/api/src/ws/live-registry.ts +++ b/apps/server/api/src/ws/live-registry.ts @@ -1,3 +1,5 @@ +import type { PreviewStreamFrame } from "@internal/subshell-protocol"; +import type { PreviewStreamSocket } from "@/services/preview-broker.js"; import type { LiveWsSocket } from "@/ws/live-ws.js"; /** @@ -72,3 +74,30 @@ export function dropLiveSocketsFor(userId: string, reason = "role changed"): num export function resetLiveRegistryForTests(): void { byUser.clear(); } + +/** + * Serialize one preview frame and hand it to one socket. + * + * This is the delivery primitive the broker's fan-out calls per recipient, and + * the one place a `preview-stream` frame becomes bytes. It lives beside the + * registry that already owns "who is connected" so the serializer never needs + * to know about the broker and the broker never needs to know about sockets: + * `live-ws.ts` installs it at module init, and tests pass it to the broker's + * seam explicitly. The frame is stringified as-is; the broker already built it + * from a pane's screen, so the blob crosses untouched. + */ +export function sendPreviewStreamSocket(socket: PreviewStreamSocket, frame: PreviewStreamFrame): void { + socket.send(JSON.stringify(frame)); +} + +/** + * Send one frame to every socket in `sockets`. + * + * Production wires the per-socket function above (the broker checks each + * recipient's byte debt between sends and drops slow ones mid-fan-out), so + * this loop is for callers that already hold a settled audience rather than a + * reason to re-check it. + */ +export function sendToSockets(sockets: Iterable, frame: PreviewStreamFrame): void { + for (const socket of sockets) sendPreviewStreamSocket(socket, frame); +} diff --git a/apps/server/api/src/ws/live-ws.ts b/apps/server/api/src/ws/live-ws.ts index 946b74204..0296c184d 100644 --- a/apps/server/api/src/ws/live-ws.ts +++ b/apps/server/api/src/ws/live-ws.ts @@ -1,13 +1,26 @@ import { type LiveServerFrame, parseLiveClientFrame } from "@internal/subshell-protocol"; import { getRequestlessContext } from "@/lib/context.js"; +import { type Access, accessAtLeast, loadSubshellAccess } from "@/lib/subshell-access.js"; import { accountDisabled } from "@/services/account-status.js"; import { backupPasswordChangeRequired } from "@/services/backup-admin-recovery.js"; +import { configurePreviewStreamSender, getPreviewBroker } from "@/services/preview-broker.js"; import type { SubshellsService } from "@/services/subshells.service.js"; import { logger } from "@/utils/logger.js"; -import { registerLiveSocket, unregisterLiveSocket } from "@/ws/live-registry.js"; +import { registerLiveSocket, sendPreviewStreamSocket, unregisterLiveSocket } from "@/ws/live-registry.js"; import { topicsForViewer } from "@/ws/live-topics.js"; import { consumeWsToken, type WsTokenIdentity } from "@/ws/ws-token.js"; +/** + * The broker's ONE crossing into the socket layer, installed at module load + * so no `watch` can attach a stream before its serializer exists. + * + * The assignment is pure: it constructs nothing, so import stays cheap and + * the compiled binary's non-boot subcommands are unaffected; the broker + * itself remains lazy behind `getPreviewBroker()`, called only from the + * handlers below. + */ +configurePreviewStreamSender(sendPreviewStreamSocket); + /** * Exactly what the sharing-aware list answers with, derived from the method * itself rather than restated. @@ -49,7 +62,25 @@ export interface LiveWsSocket { liveViewerId?: string; /** True while a previews request is being served — see {@link handleLiveMessage}. */ liveCapturing?: boolean; + /** + * The socket-lifetime key the preview stream set holds this socket + * under. `liveViewerId` answers WHO (a user, shared by all their tabs); + * this answers WHICH SOCKET, because a `watch` subscription belongs to + * one connection: a reconnect is legitimately a new viewer (the same + * reasoning `subshell-ws.ts` carries for its viewer id). It rides + * `data` rather than being the socket object because the object is + * swapped per callback, see this interface's header. + */ + streamViewerId?: string; }; + /** + * The underlying socket. Elysia keeps backpressure (`getBufferedAmount`) + * on the RAW side only, measured in `remote-subshell-ws.ts`; the preview + * adapter reads the method off this exactly as that file's + * `RawWithBackpressure` does. Optional so hand-rolled doubles may omit + * it, which the adapter reads as zero debt. + */ + raw?: unknown; send(data: string): unknown; /** * Joins a pub/sub topic (Bun's own, forwarded by Elysia). Optional so a @@ -89,6 +120,15 @@ export interface LiveWsDeps { accountDisabled(userId: string): Promise; /** Human viewers must finish temporary-password recovery before receiving the feed. */ passwordChangeRequired(userId: string): Promise; + /** + * The viewer's access to ONE pane, the whole question a `watch` or + * `unwatch` asks before touching the preview stream set. A seam because + * the answer needs the database, and it is the SAME question the REST + * preview gate asks (`preview-gate` → `loadSubshellAccess`), not a second + * reading of it: one resolver keeps the two doors of one pane answering + * identically, which is the whole point of the §11.14 control. + */ + accessFor(userId: string, paneId: string): Promise; } /** @@ -145,6 +185,11 @@ export async function handleLiveOpen(ws: LiveWsSocket, deps: LiveWsDeps): Promis // Who this socket belongs to, for the messages it may send later. Stashed // rather than re-derived: the token is single-use and already spent. ws.data.liveViewerId = userId; + // And WHICH socket this is, for the preview stream set a later `watch` + // joins. Minted here, before any await: the message handler can arrive on + // a fresh socket immediately, and a key that appears only after some check + // would silently make the first watch unrevocable by close. + ws.data.streamViewerId = crypto.randomUUID(); // Findable so a revocation can reach it: the disable drop is the // load-bearing one (a disabled account must not keep streaming, and cannot // re-mint), and a role change still closes the socket so the reconnect @@ -225,6 +270,15 @@ export async function handleLiveOpen(ws: LiveWsSocket, deps: LiveWsDeps): Promis */ export function handleLiveClose(ws: LiveWsSocket): void { ws.data?.liveStop?.(); + // Stream subscriptions die with the connection, keyed by the + // socket-lifetime id that `open` minted. The key is read from `data` for + // the same reason the stopper is: the wrapper `close` receives is not the + // one that attached. Detaching through the broker rather than a local set + // is the point: the broker owns the pane-side bookkeeping (per-pane + // counts, idle-bridge closing) and a close that only edited a local list + // would strand those. Safe when this socket watched nothing. + const streamKey = ws.data?.streamViewerId; + if (streamKey) getPreviewBroker().detachSocketsFor(streamKey); } /** @@ -246,6 +300,19 @@ export function liveWsDeps(): LiveWsDeps { previewsFor: (userId, ids) => getRequestlessContext().services.subshells.previewsFor(userId, ids), accountDisabled: (userId) => accountDisabled(getRequestlessContext().db, userId), passwordChangeRequired: (userId) => backupPasswordChangeRequired(getRequestlessContext().db, userId), + // The ordinary gate, exactly as `attach-resolve` and the REST preview + // gate call it: shares count (a pane's own key never reaches this + // socket anyway - a scoped token is refused at redemption), roles carry + // no arm here since 2026-10-09. + accessFor: async (userId, paneId) => { + const { repos } = getRequestlessContext(); + const { access } = await loadSubshellAccess( + { subshells: repos.subshells, shares: repos.subshellShares }, + userId, + paneId, + ); + return access; + }, }; } @@ -261,13 +328,19 @@ export function liveWsDeps(): LiveWsDeps { export const MAX_PREVIEW_REQUEST = 60; /** - * Handles a client frame. The only thing a client may ask for is screens. + * Handles a client frame: a screens ask is answered here (pulled), and a + * `watch`/`unwatch` joins or leaves the broker's pushed screencast fan-out + * (see {@link handlePreviewSubscription}). Both paths resolve the pane + * through the ordinary access gate, so the socket can never carry a row or a + * stream its owner could not fetch over REST. * * Previews are PULLED rather than pushed (spec 2026-09-19 §4.4): the cards are * the one surface that renders them, so a page showing none costs nothing, and - * a card that wants a fresher screen after a change asks again. That keeps the - * fan-out free of per-socket state — the server holds no watch list, it just - * answers. + * a card that wants a fresher screen after a change asks again. That answer + * path keeps no per-socket state: the server holds no screens list, it just + * answers. The STREAM path is where subscription state lives, and it lives + * in the preview broker rather than here: this handler is the broker's one + * door. * * The ids are filtered to what this viewer may see by the ordinary visible-set * read; an id they cannot see is simply absent from the answer, never refused, @@ -297,6 +370,15 @@ export async function handleLiveMessage(ws: LiveWsSocket, raw: unknown, deps: Li return; } + // The screencast subscription verbs. Both gate on the same access question + // and both answer a refusal with silence: no frame back, no close, no + // signal a stranger can read as "this pane exists". + if (frame.type === "watch" || frame.type === "unwatch") { + await handlePreviewSubscription(ws, userId, frame.type, frame.id, deps); + return; + } + if (frame.type !== "previews") return; + const wanted = frame.ids.slice(0, MAX_PREVIEW_REQUEST); if (wanted.length === 0) return; // ONE capture run per socket at a time. Each id is a `capture-pane` spawn, @@ -326,6 +408,81 @@ export async function handleLiveMessage(ws: LiveWsSocket, raw: unknown, deps: Li } } +/** + * The raw-socket backpressure shape, restated narrow exactly as + * `remote-subshell-ws.ts` does: the wrapper carries neither the property nor + * a method, so the byte-debt the broker checks before every send has to come + * off `raw`. + */ +type RawWithBackpressure = { getBufferedAmount?: () => number }; + +/** + * Joins or leaves one pane's screencast fan-out on behalf of this socket. + * + * The gate is the ordinary one (`view` or better through the shared + * resolver, shares on, roles carrying no arm since 2026-10-09), and every + * refusal path is silence: the caller's socket keeps streaming its feed, and + * nothing written here ever closes it. `unwatch` runs the same gate for the + * same grammar, though its refusal is meaningless to enforce (detaching what + * is not attached is a no-op); the check just keeps the pair uniform. + * + * After the gate, the broker owns everything: the per-pane subscriber cap + * (its refusal is a RETURNED VALUE plus its own audit row; the socket's + * experience is a dark canvas, so the value is swallowed here), the + * slow-viewer byte-debt drop, and the revocation sweep. What crosses to the + * broker is the narrowest socket it accepts (a send-through plus the raw + * debt read), keyed by the socket-lifetime id so a close can always find + * this entry even after its wrapper was swapped. + */ +async function handlePreviewSubscription( + ws: LiveWsSocket, + userId: string, + verb: "watch" | "unwatch", + paneId: string, + deps: LiveWsDeps, +): Promise { + const broker = getPreviewBroker(); + let access: Access = "none"; + try { + // A sweep cannot detach a subscription that hasn't registered yet. If + // revocation overtook this read, ask again before attaching. Bound the + // retries so continuous permission changes cannot hold this task forever. + for (let attempt = 0; attempt < 3; attempt++) { + const revision = broker.streamAccessRevision(); + if ((await deps.accountDisabled(userId)) || (await deps.passwordChangeRequired(userId))) return; + const answer = await deps.accessFor(userId, paneId); + if (revision === broker.streamAccessRevision()) { + access = answer; + break; + } + } + } catch (err) { + // Fail CLOSED: an unanswered access question is no access. The socket + // stays open on its feed; the next frame will ask again on its own. + logger.withError(err).warn("live ws: could not resolve pane access for a watch; treating as none"); + return; + } + if (!accessAtLeast(access, "view")) return; + // The gate suspends, and a close may land in the window: `liveStop` is + // cleared by `handleLiveClose`, so its absence means this socket is dead + // and must not join (or leave) a set it can no longer be detached from. + // Same marker the previews loop below leans on. + if (!ws.data.liveStop) return; + const streamKey = ws.data.streamViewerId; + if (!streamKey) return; + if (verb === "unwatch") { + broker.detachStream(paneId, streamKey); + return; + } + broker.attachStream(paneId, streamKey, userId, { + send: (data) => ws.send(data), + getBufferedAmount: () => (ws.raw as RawWithBackpressure | undefined)?.getBufferedAmount?.() ?? 0, + }); + // The return value is the cap's refusal or acceptance, and both end this + // handler's work identically; the audit trail a refusal writes belongs to + // the broker, which owns the cap. +} + /** One send, typed against the shared envelope so a frame the browser cannot parse is a type error. */ function sendFrame(ws: LiveWsSocket, frame: LiveFrame): void { ws.send(JSON.stringify(frame)); diff --git a/apps/server/api/src/ws/ws.plugin.ts b/apps/server/api/src/ws/ws.plugin.ts index 2e21e6111..4531cd1bf 100644 --- a/apps/server/api/src/ws/ws.plugin.ts +++ b/apps/server/api/src/ws/ws.plugin.ts @@ -127,8 +127,10 @@ wsPlugin.ws("/ws/node", { * upgrade-time identity lookup. Its upgrade hook enforces the browser origin * boundary before the token is redeemed in `open`. * - * The client sends nothing on this socket yet, so there is no `message` - * handler to write; `watch-previews` arrives with the preview work. + * The client sends `previews` (pulled screens), `watch`/`unwatch` (the pushed + * screencast fan-out, wired through the preview broker), and `resync` when a + * broadcast carries a row it has never seen; `handleLiveMessage` owns all + * three, and every pane it touches passes the ordinary access gate first. */ wsPlugin.ws("/ws/live", { upgrade({ request }) { @@ -143,7 +145,8 @@ wsPlugin.ws("/ws/live", { }); }, message(ws, message) { - // The one thing a client may ask for is screens (spec 2026-09-19 §4.4). + // Screens, stream subscriptions, and resync (spec 2026-09-19 §4.4 for the + // first and last; the watch pair is the §11.14 fan-out). // Elysia JSON-parses frames beginning with `{`, so this arrives as either // a string or an object; the handler accepts both and ignores the rest. void handleLiveMessage(ws as unknown as LiveWsSocket, message, liveWsDeps()).catch((err: unknown) => { diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index 6e7608696..651be872d 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -530,3 +530,26 @@ remain authoritative. SSH imports carry the exported destination as the expected destination while resolving their saved alias; never silently follow an alias to another host. The file limit is 1 MiB. Keep validation, exports, and the public guide aligned when adding a field or version. + +## Preview in subshells + +Workspace panes offer Web previews & screens through an Open web preview or screen dialog. REST resources use views and MCP exposes list_subshell_views, open_subshell_view, and watch_subshell_browser. Persisted panel IDs and node-wire identifiers keep their preview names. Each app port, browser watch, and virtual display opens in its own workspace tab, split to the right of its terminal. Adding a preview restores a maximized workspace group and activates the terminal before splitting. Desktop previews use the same dockview layout as terminals, so tabs can be moved and split without rebuilding the terminal. Each preview has one parent pane and appears as a child entry under its subshell in the sidebar. Closing the preview leaves that pane and subshell running. Stored preview panels use `preview::screen`, `preview::browser`, or `preview::app:`; the server keeps them only while the parent pane is visible to the workspace owner. Hidden preview tabs release viewer subscriptions and reopen their selected view when shown again. Phone previews use their own entries in the flat tab strip and never overwrite the desktop layout. + +The standalone subshell route still uses PreviewSection. In a workspace tab the section fills the panel and selects one app, browser watch, or screen at a time. App and screen viewers fill their preview area and support full screen. Phone web previews prepare a real new-tab link after the request, avoiding popup blocking. The launch form checks the selected machine’s Linux support, Web previews & screens setting, and live Xvfb/x11vnc/xauth prerequisites before enabling Virtual display. Missing programs show a gold link to the virtual-display setup guide instead of inline installation instructions. Failed checks show their reason, and both states offer Recheck. A successful check hides Recheck. + +Subshell list views carry `virtualDisplay` metadata for the sidebar and headers. The window indicator uses that shared row rather than starting per-row preview scans. Its icon-only header control opens the preview picker dialog; subshells without a display use a globe icon for the same picker; its tooltip says “Virtual display enabled - click to activate”. Remote display updates ride the existing batched liveness probe. + +Workspace previews consolidate refresh, full screen, and secondary actions in one toolbar. Selected viewers omit their own headers and padding; the terminal’s Web previews & screens bar is hidden while its preview tab is open. Connection indicators reflect iframe loading, RFB connection events, or browser frame freshness. Preview groups put Maximize pane in their viewer action menu and show Restore pane directly in the toolbar while maximized. Full screen follows the same pattern: enter through the menu, exit from the toolbar. They omit the duplicate group-header button. The dock adapter subscribes to maximize and group-change events and reads the current group when toggling, so moving a preview keeps its actions bound to the right group. + +Browser-watch requests carry a lifecycle generation: late success must release its old pane's interest without reactivating the view, and late errors must not follow navigation. Stop and unmount invalidate pending starts. Display prerequisite rechecks on the same machine preserve the user's selection; changing machines or receiving a completed refusal clears it. + +The preview picker always uses compact rows, adding search and a bounded app scroller when it has more than six choices. Browser and virtual display choices stay outside the app scroller. Ports are sorted and deduplicated before rendering. + + +Preview images are untrusted decoder inputs. `lib/preview-jpeg.ts` checks +JPEG dimensions before `Image`; `lib/preview-rfb-bounds.ts` installs the +framebuffer, rectangle, render-queue, and embedded-image guards immediately +after constructing noVNC. It consumes unused clipboard messages without +extended-clipboard decompression. The adapter uses the pinned noVNC 1.7 +private surface, covered against the real class; preserve these guards when +upgrading noVNC. Never rely only on a frame's claimed width and height. diff --git a/apps/server/web/docs/home-launch-guidance.md b/apps/server/web/docs/home-launch-guidance.md index 3545cce9f..c22852951 100644 --- a/apps/server/web/docs/home-launch-guidance.md +++ b/apps/server/web/docs/home-launch-guidance.md @@ -81,3 +81,15 @@ integration arguments are added only when the session launches. The expanded preview’s inline “edit the preset” action opens the shared preset fields in a dialog. Save and apply updates the stored preset and reapplies the returned launch fields; cancelling or a failed save leaves the launch untouched. + +The launch form reconciles node changes in `use-launch-form-defaults.ts`, +including readiness state, so separate effects cannot restore stale selections. +The node picker permits a node without the current harness: switching clears +an unavailable harness and its preset, with a visible explanation. It does not +silently substitute another agent. Compatible choices and prompts stay. The +working directory resets to the new node’s own recents or home. Virtual display +is unchecked only after a node change confirms that its selected provider is +disabled, unsupported, or missing requirements. Pending and failed probes block +submission without clearing it; results from a prior node never change the +current choice. Selecting a preset deliberately copies its display intent and +shows unmet requirements until the user changes the display or node. diff --git a/apps/server/web/docs/launch-form-picker.md b/apps/server/web/docs/launch-form-picker.md index 95dbed620..c08dfbda7 100644 --- a/apps/server/web/docs/launch-form-picker.md +++ b/apps/server/web/docs/launch-form-picker.md @@ -123,13 +123,12 @@ ready). the form's node and directory pre-fill from the newest prior subshell. `launchTemplateFromList` (`lib/launch-defaults.ts`) reads the SAME `sortByCreation` head as `defaultAgentId`'s recent tier, so the agent default -and the full-settings default can never disagree about which row is "recent"; -the agent and its preset ride the existing blank-only tier, which consumes the -armed template and applies the preset only when ITS agent survived the -usability check. The auto arm fires once, only while the form still holds +and the full-settings default can never disagree about which row is "recent". +The agent uses the existing blank-only tier. A preset is never inherited from +the previous launch: it applies only when explicitly selected, so cancelling +and reopening does not restore discarded preset settings. The auto arm fires once, only while the form still holds `emptyNewSubshellForm()`. A Split `initialForm`, a caller seed, pre-settle -typing, or `firstRun` (whose Preset row is hidden, so a preset landed there -would be invisible) all disqualify it. Degradation is never a second rule: a +typing, or `firstRun` all disqualify it. Degradation is never a second rule: a newest row on an offline node re-homes the pick and the existing machine-switch arm clears the copied directory and re-arms the per-node seed; the preset-membership guard drops a preset that does not belong to the landed diff --git a/apps/server/web/docs/node-page.md b/apps/server/web/docs/node-page.md index a1baca0e1..0810e21bb 100644 --- a/apps/server/web/docs/node-page.md +++ b/apps/server/web/docs/node-page.md @@ -1,5 +1,12 @@ # A node's page: structure, gates, maintenance +The Web previews & screens card keeps the node's combined opt-in switch visible to all +viewers, with changes restricted to the owner (an admin for the built-in node). +When enabled, it uses `useNodeDisplayReadiness`, shared with the new-subshell +dialog, to show missing prerequisites, setup guidance, and Recheck. Platform +limitations remain visible even when the switch is on. Rechecking is read-only +and doesn't require permission to change the switch. + Deep dive for `apps/server/web`, moved verbatim out of `AGENTS.md`. This file carries the full history behind the summary; the routing line in `AGENTS.md` names when to read it. diff --git a/apps/server/web/docs/workspace-first.md b/apps/server/web/docs/workspace-first.md index 0b1c89771..74fcd2c33 100644 --- a/apps/server/web/docs/workspace-first.md +++ b/apps/server/web/docs/workspace-first.md @@ -143,3 +143,7 @@ The `/workspaces` overview is gone. Both `/` and `/workspaces` use `WorkspaceLanding` to resume the last available workspace. The latter keeps the workspace sidebar available when none exist, so its selector can create one. The workspace header has no back link to an overview. + +## Preview tabs + +A subshell’s Preview control opens or focuses one preview tab beside its terminal. The tab uses dockview’s ordinary drag, split, and close controls. Its parent pane remains a terminal membership row; closing a preview never removes that row. Preview IDs are derived as `preview:` and are stored with the desktop layout. Server layout pruning removes them with deleted or inaccessible parents and binds renderer params back to the authorized parent. The sidebar reads the currently open preview entries and focuses the matching tab from a child row under the subshell. Phone previews are presentation state in the flat strip, preserving the rule that phones never write the desktop’s split layout. diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 1ec2cb7ae..28053cd05 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -25,6 +25,7 @@ "@internal/server": "workspace:*", "@internal/subshell-protocol": "workspace:*", "@loglayer/transport-simple-pretty-terminal": "3.3.0", + "@novnc/novnc": "1.7.0", "@subshell-ai/plugin-api": "workspace:*", "@tanstack/react-form": "1.33.5", "@tanstack/react-query": "5.101.4", @@ -51,7 +52,7 @@ "sonner": "2.0.8", "tailwind-merge": "3.6.0", "tw-animate-css": "1.4.0", - "zod": "4.6.6" + "zod": "4.6.7" }, "devDependencies": { "@happy-dom/global-registrator": "21.0.4", diff --git a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx index 198af03b6..a5b0ddad0 100644 --- a/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/clone-subshell-dialog.test.tsx @@ -43,6 +43,16 @@ function makeSource(overrides: Partial = {}): SubshellView { } describe("cloneInputFromSource", () => { + it("preserves the source display rather than adopting a changed preset or node default", () => { + expect( + cloneInputFromSource(makeSource({ screen: { provider: "native-example", width: 1440, height: 900 } }), "copy"), + ).toMatchObject({ + virtualDisplay: true, + displayProvider: "native-example", + displaySize: { width: 1440, height: 900 }, + }); + }); + it("copies agent/preset/dir/node and trims the name; absent node means local", () => { expect(cloneInputFromSource(makeSource(), " Copy ")).toEqual({ harnessId: "claude", @@ -207,6 +217,7 @@ describe("CloneSubshellDialog", () => { // a prompt of its own - only an explicit "" keeps the copy from // resurrecting it server-side. prompt: "", + screen: null, }, }), ); diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index ab3811456..6841a07cb 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -603,6 +603,13 @@ describe("NewSubshellForm preset row", () => { } }); + it("blocks a requested display until its selected provider is ready", () => { + const form = { harnessId: "terminal", nodeId: "local", workingDir: "/tmp", presetId: null, promptBlocks: [] }; + expect(canSubmit({ ...form, virtualDisplay: true, displayUnavailable: "This provider is disabled." })).toBe(false); + expect(canSubmit({ ...form, virtualDisplay: false, displayUnavailable: "This provider is disabled." })).toBe(true); + expect(canSubmit({ ...form, virtualDisplay: true })).toBe(true); + }); + it("canSubmit: a checked Save-as-preset without a name never submits", () => { const base = { harnessId: "claude-code", presetId: null, workingDir: "/x", nodeId: "local", promptBlocks: [] }; expect(canSubmit({ ...base })).toBe(true); @@ -688,7 +695,7 @@ describe("NewSubshellForm honest hints", () => { } }); - it("chosen agent runnable on no visible node → the mirror hint", async () => { + it("clears an agent that cannot run on the chosen node and explains the correction", async () => { const restore = mockFetch([ENROLLED_INCOMPAT], [CLAUDE]); try { await renderForm({ @@ -698,7 +705,7 @@ describe("NewSubshellForm honest hints", () => { nodeId: "a3", promptBlocks: [], }); - expect(await screen.findByText(/No available node can run Claude Code/)).toBeDefined(); + expect(await screen.findByText(/Claude Code can.t run on studio|Claude Code can.t run on node/)).toBeDefined(); } finally { restore(); } @@ -838,7 +845,7 @@ describe("NewSubshellForm prior-launch defaults", () => { }; } - it("opens on the newest row's full launch: node, dir, agent AND preset", async () => { + it("uses the newest node, directory and agent without restoring its preset", async () => { const PI_NODE = node({ id: "a1", name: "mac mini", harnesses: [PI_ON] }); const restore = mockFetch( [LOCAL, PI_NODE], @@ -850,7 +857,8 @@ describe("NewSubshellForm prior-launch defaults", () => { try { const { latest } = await renderForm(); await waitFor(() => expect(latest().harnessId).toBe("pi")); - expect(latest().presetId).toBe("p-pi"); + expect(latest().presetId).toBeNull(); + expect(screen.queryByText(/Settings copied from/)).toBeNull(); expect(latest().nodeId).toBe("a1"); // The copied directory outranks the recents seed — the row's own dir is // the answer, not the node's generic most-recent path. @@ -1299,7 +1307,7 @@ describe("preset prefill of the launch fields (spec 2026-09-29)", () => { it("a preset fills the agent it belongs to (the row leads the form)", async () => { const pi = plugin({ id: "pi", name: "Pi", type: "agent-harness" }); const restore = mockFetch( - [LOCAL, ENROLLED_ONLINE], + [{ ...LOCAL, harnesses: [CLAUDE_ON, { harnessId: "pi", name: "Pi", installed: true }] }, ENROLLED_ONLINE], [CLAUDE, pi], [preset({ id: "p-pi", harnessId: "pi", name: "Pi one" })], ); diff --git a/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx b/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx new file mode 100644 index 000000000..ff6fb634b --- /dev/null +++ b/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx @@ -0,0 +1,39 @@ +import { afterEach, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { VirtualDisplayIndicator } from "../virtual-display-indicator"; + +afterEach(cleanup); +it("marks a subshell with a display and removes the indicator when it loses it", () => { + const view = render(); + expect(screen.getByRole("img", { name: "Virtual display :91" })).toBeTruthy(); + view.rerender(); + expect(screen.queryByRole("img") === null).toBe(true); +}); +it("opens the display from an icon-only header shortcut", () => { + let opened = 0; + render( + { + opened += 1; + }} + />, + ); + expect(screen.queryByText("Virtual display") === null).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Open web preview or screen" })); + expect(opened).toBe(1); +}); + +it("keeps the header picker available for subshells without a virtual display", () => { + let opened = 0; + render( + { + opened += 1; + }} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Open web preview or screen" })); + expect(opened).toBe(1); + expect(screen.queryByRole("img")).toBeNull(); +}); diff --git a/apps/server/web/src/components/__tests__/workspace-tabs.test.tsx b/apps/server/web/src/components/__tests__/workspace-tabs.test.tsx index 145ff5fc7..d2b12ed19 100644 --- a/apps/server/web/src/components/__tests__/workspace-tabs.test.tsx +++ b/apps/server/web/src/components/__tests__/workspace-tabs.test.tsx @@ -12,6 +12,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra import { WorkspaceTab, WorkspaceTabs } from "@/components/workspace-tabs"; import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys"; import { requestWorkspacePaneFocus, resetWorkspaceFocusForTests } from "@/lib/workspace-focus"; +import { requestWorkspacePreview } from "@/lib/workspace-preview"; import { setFetchRouter } from "@/test-setup"; import type { WorkspaceDetail, WorkspacePaneRow } from "@/types/workspace"; @@ -214,3 +215,44 @@ describe("WorkspaceTabs: the active tab scrolls into view", () => { } }); }); + +it("opens one phone preview tab and closes it without removing the subshell", async () => { + const deletes: string[] = []; + setFetchRouter((input, init) => { + const url = new URL(String(input), "http://localhost"); + if (init?.method === "DELETE") deletes.push(url.pathname); + if (url.pathname === "/api/subshells") return Promise.resolve(new Response("[]")); + if (url.pathname === "/api/auth/ws-token") return Promise.resolve(new Response("{}", { status: 503 })); + return Promise.resolve(new Response("{}")); + }); + try { + renderStrip(); + await waitFor(() => expect(Boolean(document.querySelector("[data-terminal-controls-scroller]"))).toBe(true)); + act(() => { + expect(requestWorkspacePreview("s-1", { kind: "screen" })).toBe(true); + }); + await screen.findByRole("button", { name: "Virtual display · s-1" }); + act(() => { + requestWorkspacePreview("s-1", { kind: "screen" }); + }); + expect(screen.getAllByRole("button", { name: "Virtual display · s-1" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Close Virtual display for s-1" })); + expect(screen.queryByRole("button", { name: "Virtual display · s-1" }) === null).toBe(true); + expect(document.querySelector('[data-pane-id="p0"]')).toBeTruthy(); + act(() => { + requestWorkspacePreview("s-1", { kind: "app", port: 41430 }); + }); + await screen.findByRole("button", { name: "App · 41430 · s-1" }); + act(() => { + requestWorkspacePreview("s-1", { kind: "screen" }); + }); + await screen.findByRole("button", { name: "Virtual display · s-1" }); + expect(screen.getByRole("button", { name: "App · 41430 · s-1" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Close Virtual display for s-1" })); + expect(screen.getByRole("button", { name: "App · 41430 · s-1" })).toBeTruthy(); + expect(deletes).toEqual([]); + } finally { + cleanup(); + setFetchRouter(null); + } +}); diff --git a/apps/server/web/src/components/clone-subshell-dialog.tsx b/apps/server/web/src/components/clone-subshell-dialog.tsx index 31e4f4300..f7d3c4dbc 100644 --- a/apps/server/web/src/components/clone-subshell-dialog.tsx +++ b/apps/server/web/src/components/clone-subshell-dialog.tsx @@ -34,6 +34,13 @@ function cloneNodeId(source: SubshellView): string { */ export function cloneInputFromSource(source: SubshellView, name: string): CreateSubshellInput { return { + ...(source.screen + ? { + virtualDisplay: true, + displayProvider: source.screen.provider, + displaySize: { width: source.screen.width, height: source.screen.height }, + } + : {}), harnessId: source.harnessId, presetId: source.presetId, workingDir: source.workingDir, diff --git a/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx new file mode 100644 index 000000000..60747eec9 --- /dev/null +++ b/apps/server/web/src/components/nodes/__tests__/node-preview-card.test.tsx @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NodePreviewCard } from "@/components/nodes/node-preview-card"; + +/** + * The Preview card on the node page (task-12 Step 4, pinned by Step 1): the + * same gate and tooltip posture as the SSH card beside it - owner-only flip, + * honest remedy line for a non-owner, and a single PUT of `{on}` at the Task + * 3 route. The fetch mock is the route, and the node arrives as a prop. + */ + +type Call = { method: string; url: string; body?: Record }; + +function node(over: Partial = {}): NodeDetail { + return { + id: "node1", + name: "box", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "box", + status: "online", + lastSeenAt: null, + agentVersion: "1.0.0", + protocolVersion: 12, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + sshWideForwards: false, + webPreviewsEnabled: false, + held: null, + ...over, + } as NodeDetail; +} + +function mount( + n: NodeDetail, + readiness: { body: unknown; status?: number } = { body: { supported: true, missing: [] } }, +) { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ + method, + url: url.pathname, + body: init?.body ? (JSON.parse(String(init.body)) as Call["body"]) : undefined, + }); + if (url.pathname.endsWith("/display-readiness")) { + return Promise.resolve(new Response(JSON.stringify(readiness.body), { status: readiness.status ?? 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ ...n, webPreviewsEnabled: true }))); + }) as typeof fetch; + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const view = render( + + + , + ); + return { + view, + calls, + restore: () => (globalThis.fetch = original), + }; +} + +afterEach(() => cleanup()); + +describe("the Preview switch", () => { + it("reflects the stored flag and asks the route when flipped", async () => { + const h = mount(node({ webPreviewsEnabled: false })); + try { + const toggle = screen.getByRole("switch", { name: /allow web previews/i }); + // Cast-and-read, this repo's convention (Base UI wears aria-checked and + // aria-disabled; there is no jest-dom dependency here). + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.matches(":disabled, [aria-disabled=true]")).toBe(false); + fireEvent.click(toggle); + await waitFor(() => + expect(h.calls.find((c) => c.method === "PUT" && c.url === "/api/nodes/node1/views-enabled")).toBeTruthy(), + ); + expect(h.calls.find((c) => c.method === "PUT" && c.url === "/api/nodes/node1/views-enabled")?.body).toEqual({ + on: true, + feature: "web-previews", + }); + } finally { + h.restore(); + } + }); + + it("a checked switch mirrors the stored on-state", () => { + const h = mount(node({ webPreviewsEnabled: true })); + try { + expect(screen.getByRole("switch", { name: /allow web previews/i }).getAttribute("aria-checked")).toBe("true"); + } finally { + h.restore(); + } + }); + + it("a non-owner sees it disabled with the owner remedy", () => { + const h = mount(node({ canManage: false, access: "edit" })); + try { + expect( + screen.getByRole("switch", { name: /allow web previews/i }).matches(":disabled, [aria-disabled=true]"), + ).toBe(true); + expect(screen.getAllByText(/only this node/i)[0]).toBeTruthy(); + } finally { + h.restore(); + } + }); + + it("the local host answers with the admin remedy line", () => { + const h = mount(node({ kind: "local", canManage: false })); + try { + expect(screen.getAllByText(/an admin can change this setting/i)[0]).toBeTruthy(); + } finally { + h.restore(); + } + }); +}); + +describe("node view prerequisites", () => { + it("explains disabled features without probing the node", () => { + const h = mount(node()); + try { + expect(screen.getByText("Web previews are disabled on this node.")).toBeTruthy(); + expect(h.calls).toHaveLength(0); + } finally { + h.restore(); + } + }); + + it("shows missing requirements and clears the warning after a successful recheck", async () => { + const readiness = { body: { supported: true, missing: ["xauth"] } }; + const h = mount(node({ webPreviewsEnabled: true, virtualDisplayEnabled: true }), readiness); + try { + const guide = await screen.findByRole("link", { name: "Set up virtual display" }); + expect(guide.getAttribute("href")).toBe("https://docs.subshell.sh/guides/preview#use-a-virtual-display-on-linux"); + expect(screen.getByText("Virtual display requirements are missing on the selected node.")).toBeTruthy(); + expect(screen.getByRole("switch", { name: /allow virtual displays/i }).getAttribute("aria-checked")).toBe("true"); + readiness.body = { supported: true, missing: [] }; + fireEvent.click(screen.getByRole("button", { name: "Recheck" })); + await screen.findByText("Virtual display requirements are met."); + expect(screen.queryByRole("link", { name: "Set up virtual display" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Recheck" })).toBeNull(); + } finally { + h.restore(); + } + }); + + it("explains unsupported features while keeping saved services available", async () => { + const h = mount(node({ webPreviewsEnabled: true, virtualDisplayEnabled: true, os: "darwin" }), { + body: { supported: false, missing: [] }, + }); + try { + expect(screen.getByText(/Automatic app discovery and browser watching require Linux/)).toBeTruthy(); + expect(await screen.findByText("This virtual display provider does not support the selected node.")).toBeTruthy(); + } finally { + h.restore(); + } + }); + + it("shows a failed check and lets a read-only viewer retry it", async () => { + const readiness = { body: { message: "Bring this node online, then recheck." } as unknown, status: 409 }; + const h = mount( + node({ webPreviewsEnabled: true, virtualDisplayEnabled: true, canManage: false, access: "view" }), + readiness, + ); + try { + await screen.findByText(/Bring this node online, then recheck/); + expect(screen.queryByText("Virtual display requirements are met.")).toBeNull(); + readiness.body = { supported: true, missing: [] }; + readiness.status = 200; + fireEvent.click(screen.getByRole("button", { name: "Recheck" })); + await screen.findByText("Virtual display requirements are met."); + expect( + screen.getByRole("switch", { name: /allow virtual displays/i }).matches(":disabled, [aria-disabled=true]"), + ).toBe(true); + } finally { + h.restore(); + } + }); +}); + +it("apps need no display prerequisites and display has its own switch", async () => { + const h = mount(node({ webPreviewsEnabled: true, virtualDisplayEnabled: false })); + try { + expect(screen.getByRole("switch", { name: "Allow web previews" }).getAttribute("aria-checked")).toBe("true"); + const display = screen.getByRole("switch", { name: "Allow virtual displays" }); + expect(display.getAttribute("aria-checked")).toBe("false"); + expect(h.calls).toHaveLength(0); + fireEvent.click(display); + await waitFor(() => expect(h.calls.some((c) => c.method === "PUT")).toBe(true)); + expect(h.calls.find((c) => c.method === "PUT")?.body).toEqual({ on: true, feature: "display" }); + } finally { + h.restore(); + } +}); + +it("shows the provider selector when virtual displays are enabled", async () => { + const h = mount(node({ virtualDisplayEnabled: true, virtualDisplayProvider: "xvfb-x11vnc" })); + try { + const picker = screen.getByRole("combobox", { name: "Default display provider" }); + expect(picker.textContent).toContain("Xvfb + VNC"); + fireEvent.click(picker); + expect(await screen.findByRole("option", { name: "Xvfb + VNC" })).toBeTruthy(); + expect(screen.getByText(/Existing subshells keep their saved provider/)).toBeTruthy(); + } finally { + h.restore(); + } +}); diff --git a/apps/server/web/src/components/nodes/node-cross-agent-card.tsx b/apps/server/web/src/components/nodes/node-cross-agent-card.tsx index 9ec056dcc..ceca16a42 100644 --- a/apps/server/web/src/components/nodes/node-cross-agent-card.tsx +++ b/apps/server/web/src/components/nodes/node-cross-agent-card.tsx @@ -184,6 +184,14 @@ export function NodeCrossAgentCard({ node }: { node: NodeDetail }) { ))} + + Learn about cross-agent communication + {!node.canManage &&

{restriction}

} {problem &&

{problem}

} {save.isError && ( diff --git a/apps/server/web/src/components/nodes/node-display-providers.tsx b/apps/server/web/src/components/nodes/node-display-providers.tsx new file mode 100644 index 000000000..299feec1f --- /dev/null +++ b/apps/server/web/src/components/nodes/node-display-providers.tsx @@ -0,0 +1,102 @@ +import { Label, type NodeDetail } from "@internal/node-admin"; +import { DEFAULT_VIRTUAL_DISPLAY_PROVIDER, VIRTUAL_DISPLAY_PROVIDERS } from "@internal/subshell-protocol"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { NodeViewReadiness } from "./node-view-readiness"; +export function NodeDisplayProviders({ + node, + disabledReason, + onChange, +}: { + node: NodeDetail; + disabledReason?: string; + onChange: (providers: string[], provider?: string) => void; +}) { + const enabled = node.virtualDisplayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER]; + const current = node.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; + const choices = VIRTUAL_DISPLAY_PROVIDERS.filter((p) => enabled.includes(p.id)); + return ( +
+ {VIRTUAL_DISPLAY_PROVIDERS.map((p) => { + const id = `node-display-provider-${p.id}`; + const control = ( + { + const next = on ? [...enabled, p.id] : enabled.filter((id) => id !== p.id); + onChange(next, next.includes(current) ? current : next[0]); + }} + /> + ); + return ( +
+
+ {disabledReason ? ( + + + } + > + {control} + + {disabledReason} + + ) : ( + control + )} + +
+

{p.description}

+ + {!enabled.includes(p.id) && ( +

This provider is disabled for new virtual displays.

+ )} +
+ ); + })} + {choices.length > 0 ? ( +
+ + +
+ ) : ( +

Enable a provider before starting a subshell with a virtual display.

+ )} +

+ New subshells use the default unless you choose another enabled provider. Existing subshells keep their saved + provider when restarted. +

+
+ ); +} diff --git a/apps/server/web/src/components/nodes/node-preview-card.tsx b/apps/server/web/src/components/nodes/node-preview-card.tsx new file mode 100644 index 000000000..5378d3365 --- /dev/null +++ b/apps/server/web/src/components/nodes/node-preview-card.tsx @@ -0,0 +1,147 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + errMessage, + Label, + type NodeDetail, + Switch, +} from "@internal/node-admin"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useSetViewsEnabled } from "@/hooks/use-nodes"; +import { NodeDisplayProviders } from "./node-display-providers"; +import { NodeViewReadiness } from "./node-view-readiness"; + +/** + * The node page's independent preview switches, cloned from the SSH card's gate posture: + * the same owner-only flip (`canManage`, seeded-`local` aside), the same + * remedy lines for a non-owner, and the one `PUT /:id/views-enabled` call. + * The flags are the plane's record, so the switches work on an offline node too + * - the machine learns the value at its next connect - and the copy says what + * turning it on lets happen, not that it happens now. + */ +export function NodePreviewCard({ node }: { node: NodeDetail }) { + return ( + <> + + + + ); +} + +function NodePreviewFeatureCard({ node, feature }: { node: NodeDetail; feature: "web-previews" | "display" }) { + const display = feature === "display"; + const enabled = display ? node.virtualDisplayEnabled === true : node.webPreviewsEnabled === true; + const title = display ? "Virtual displays" : "Web previews"; + const id = display ? "node-virtual-display-enabled" : "node-preview-enabled"; + const reasonId = `${id}-reason`; + const update = useSetViewsEnabled(); + const disabledReason = !node.canManage + ? node.kind === "local" + ? "An admin can change this setting." + : "Only this node’s owner can change this setting." + : update.isPending + ? `Saving ${title.toLowerCase()}…` + : undefined; + const toggle = ( + update.mutate({ id: node.id, on, feature })} + /> + ); + return ( + + + {title} + + +
+ {disabledReason ? ( + + + } + > + {toggle} + + {disabledReason} + + ) : ( + toggle + )} + +
+ {node.canManage ? ( +

+ {display + ? "Allow subshells to run graphical apps on a machine without a monitor." + : "Open websites and saved web services, and watch browsers running in subshells on this node."} +

+ ) : ( +

+ {disabledReason} +

+ )} + {display ? ( + <> + {enabled && ( + + update.mutate({ id: node.id, on: enabled, feature: "display", providers, provider }) + } + /> + )} +

+ Each virtual display uses extra memory and CPU. Web previews don’t need one. +

+ {!enabled && } + + ) : ( +

+ {!enabled + ? "Web previews are disabled on this node." + : node.os !== "linux" + ? "Automatic app discovery and browser watching require Linux. You can still add HTTP and HTTPS services by port." + : "Web previews are available to all subshells on this node."} +

+ )} + + {display ? "Learn about virtual displays" : "Learn about web previews"} + + {node.canManage && update.isPending && ( +

+ {disabledReason} +

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

+ {errMessage(update.error, `Couldn’t update ${title.toLowerCase()}.`)} +

+ )} +
+
+ ); +} diff --git a/apps/server/web/src/components/nodes/node-ssh-card.tsx b/apps/server/web/src/components/nodes/node-ssh-card.tsx index 3afcba648..117b24dbf 100644 --- a/apps/server/web/src/components/nodes/node-ssh-card.tsx +++ b/apps/server/web/src/components/nodes/node-ssh-card.tsx @@ -74,6 +74,14 @@ export function NodeSshCard({ node, children }: { node: NodeDetail; children?: R )} )} + + Learn about SSH + {children} diff --git a/apps/server/web/src/components/nodes/node-view-readiness.tsx b/apps/server/web/src/components/nodes/node-view-readiness.tsx new file mode 100644 index 000000000..b6236a727 --- /dev/null +++ b/apps/server/web/src/components/nodes/node-view-readiness.tsx @@ -0,0 +1,40 @@ +import { Button, type NodeDetail } from "@internal/node-admin"; +import { useNodeDisplayReadiness } from "@/hooks/use-node-display-readiness"; + +/** Explain machine prerequisites beside the switch that permits these features. */ +export function NodeViewReadiness({ + node, + provider, + inspect = false, +}: { + node: NodeDetail; + provider?: string; + inspect?: boolean; +}) { + const { check, reason, needsSetup, shouldCheck } = useNodeDisplayReadiness(node, provider, inspect); + if (!node.virtualDisplayEnabled && !inspect) { + return

Virtual displays are disabled on this node.

; + } + return ( +
+

+ {reason ?? "Virtual display requirements are met."} +

+ {needsSetup && ( + + Set up virtual display + + )} + {shouldCheck && reason && !check.isFetching && ( + + )} +
+ ); +} diff --git a/apps/server/web/src/components/pane-focus-header.tsx b/apps/server/web/src/components/pane-focus-header.tsx index 110b549e7..4fc841e80 100644 --- a/apps/server/web/src/components/pane-focus-header.tsx +++ b/apps/server/web/src/components/pane-focus-header.tsx @@ -10,11 +10,13 @@ import { SubshellActionsMenu } from "@/components/subshell-actions-menu"; import { SubshellDevices } from "@/components/subshell-devices"; import { SubshellDot } from "@/components/subshell-dot"; import { TrustIndicators } from "@/components/trust-indicators"; +import { VirtualDisplayIndicator } from "@/components/virtual-display-indicator"; import { useCloseSubshellLanding } from "@/hooks/use-close-subshell-landing"; import { useHarnessSubagents } from "@/hooks/use-harness-subagents"; import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer"; import { useIsStackedHeader } from "@/hooks/use-is-stacked-header"; import { useNodes } from "@/hooks/use-nodes"; +import { useOpenVirtualDisplay } from "@/hooks/use-open-virtual-display"; import { usePaneCopyMode } from "@/hooks/use-pane-copy-mode"; import { usePaneDevices } from "@/hooks/use-pane-devices"; import { useSubshellRow } from "@/hooks/use-subshell-row"; @@ -85,6 +87,7 @@ export function PaneFocusHeader({ const focusedId = useWorkspaceFocusedId(); const focusedPane = panes.find((pane) => pane.subshellId === focusedId); const subshell = useSubshellRow(focusedPane?.subshellId ?? ""); + const openVirtualDisplay = useOpenVirtualDisplay(subshell); const devices = usePaneDevices(focusedPane?.id); const trustNotices = useTrustNotices(focusedPane ? subshell : undefined); const stacked = useIsStackedHeader(); @@ -155,6 +158,7 @@ export function PaneFocusHeader({ } actions={ <> + {isPane && } {isPane && subshell && } {isPane && } {devices && } diff --git a/apps/server/web/src/components/presets/preset-launch-fields.tsx b/apps/server/web/src/components/presets/preset-launch-fields.tsx index 21cb27d16..d9510a927 100644 --- a/apps/server/web/src/components/presets/preset-launch-fields.tsx +++ b/apps/server/web/src/components/presets/preset-launch-fields.tsx @@ -1,7 +1,9 @@ import { Button, FormSectionTitle, HelperCaption, Label, Switch } from "@internal/node-admin"; -import { CROSS_AGENT_NOTES_MAX } from "@internal/subshell-protocol"; +import { CROSS_AGENT_NOTES_MAX, VIRTUAL_DISPLAY_PROVIDERS } from "@internal/subshell-protocol"; import { X } from "lucide-react"; import { PromptStackSection } from "@/components/prompts/prompt-stack-section"; +import { DisplayProviderPicker } from "@/components/subshell-picker/display-provider-picker"; +import { Checkbox } from "@/components/ui/checkbox"; import type { ComboboxOption } from "@/components/ui/combobox"; import { Field, FieldLabel } from "@/components/ui/field"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -133,6 +135,30 @@ export function PresetLaunchFields({ {/* The stack shows itself behind a divider (ruling 2026-09-30), ONE section shared with the launch form: see prompt-stack-section. */} +
+
+ onChange({ ...value, screen: checked ? { width: 1280, height: 800 } : null })} + /> + +
+ {value.screen && ( + p.id)} + defaultProvider={selectedNode?.virtualDisplayProvider} + onChange={(provider) => + onChange({ ...value, screen: { ...(value.screen ?? { width: 1280, height: 800 }), provider } }) + } + /> + )} +

+ The node’s permissions and provider requirements are checked when you start a subshell. +

+
onChange({ ...value, promptBlocks })} diff --git a/apps/server/web/src/components/preview/__tests__/port-list.test.tsx b/apps/server/web/src/components/preview/__tests__/port-list.test.tsx new file mode 100644 index 000000000..a8621cb41 --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/port-list.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { PreviewInfo } from "@/hooks/use-subshell-preview"; +import { PortList } from "../port-list"; + +afterEach(cleanup); +it("finds a specific port in a large list and keeps display choices reachable", () => { + const ports = Array.from({ length: 25 }, (_, index) => ({ port: 10025 - index, proto: "tcp" as const, pid: 1 })); + const info: PreviewInfo = { + nodeEnabled: true, + ports: [...ports, { ...ports[0], proto: "tcp6" }], + cdpPort: null, + screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } }, + }; + const opened: number[] = []; + let screens = 0; + render( + opened.push(port)} + onWatch={() => {}} + onScreen={() => { + screens += 1; + }} + />, + ); + expect(screen.getByRole("status").textContent).toBe("25 web previews"); + expect(screen.getAllByRole("button", { name: /^Open web preview/ })[0].getAttribute("aria-label")).toBe( + "Open web preview on port 10001", + ); + fireEvent.click(screen.getByRole("button", { name: "View screen" })); + expect(screens).toBe(1); + const search = screen.getByRole("searchbox", { name: "Find a web preview or screen" }); + fireEvent.change(search, { target: { value: "10021" } }); + expect(screen.getAllByRole("button", { name: /^Open web preview/ })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Open web preview on port 10021" })); + expect(opened).toEqual([10021]); + fireEvent.change(search, { target: { value: "missing" } }); + expect(screen.getByText("No matching web previews or screens.")).toBeTruthy(); + fireEvent.change(search, { target: { value: "screen" } }); + expect(screen.getByRole("button", { name: "View screen" })).toBeTruthy(); + fireEvent.change(search, { target: { value: "" } }); + expect(screen.getAllByRole("button", { name: /^Open web preview/ })).toHaveLength(25); +}); diff --git a/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx b/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx new file mode 100644 index 000000000..96dacc840 --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/preview-section.test.tsx @@ -0,0 +1,429 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { PreviewSection } from "@/components/preview/preview-section"; +import type { PreviewInfo, UseSubshellPreview } from "@/hooks/use-subshell-preview"; +import { publishPreviewFrame } from "@/lib/preview-bus"; +import type { SubshellAccess, SubshellView } from "@/types/subshell"; + +/** + * The Preview section's render contract (spec §7, task-12 brief Step 1). + * The section is presentational: it takes the hook's controller as a prop, so + * these tests fake the controller and drive only what the surface decides - + * which row shows which verb, what a gated control says about itself, when + * the canvas is stale, and what never renders on a phone. + */ + +const VIEW_REASON = "Opening a forwarded port needs edit access to this subshell."; +const SCREEN_VIEW_REASON = "Virtual display needs edit access to this subshell."; +const SCREEN_PHONE_REASON = + "View screen is available on larger screens. You can still watch browser frames on this phone."; +const SWITCH_OFF_REASON = + "Web previews & screens is switched off on this node. Its owner can turn it on in node settings."; + +function controller(over: Partial = {}): UseSubshellPreview { + return { + info: undefined, + infoLoading: false, + error: null, + watching: false, + watchError: null, + openPort: async () => ({ proxyBase: "/pv/ref1/" }), + startWatch: async () => {}, + stopWatch: async () => {}, + refresh: () => {}, + ...over, + }; +} + +function info(over: Partial = {}): PreviewInfo { + return { + ports: [ + { proto: "tcp", port: 3000, pid: 11 }, + { proto: "tcp6", port: 9333, pid: 12 }, + ], + cdpPort: 9333, + screen: null, + nodeEnabled: true, + ...over, + }; +} + +function row(over: Partial = {}): SubshellView { + return { + id: "pane1", + name: "pane", + status: "running", + alive: true, + nodeId: "node1", + lastOutputAt: null, + waitingSince: null, + unseenPush: false, + access: "owner", + ssh: false, + ...over, + } as unknown as SubshellView; +} + +function mount( + over: { + access?: SubshellAccess; + info?: PreviewInfo; + subshell?: SubshellView; + controller?: Partial; + isPhone?: boolean; + bannerVisibleMs?: number; + } = {}, +) { + const c = controller({ ...(over.info === undefined ? {} : { info: over.info }), ...over.controller }); + const view = render( + , + ); + return { view, c }; +} + +afterEach(() => { + cleanup(); + localStorage.removeItem("subshell.trustNoticesSeen"); + // A test may have swapped the clock; always leave the real one behind. + const holder = globalThis as { __restoreNow?: () => void }; + if (holder.__restoreNow) { + holder.__restoreNow(); + delete holder.__restoreNow; + } +}); + +describe("gated controls explain themselves", () => { + it("a view grantee sees Open and Screen present but disabled, each with the real reason", () => { + mount({ + access: "view", + info: info({ screen: { display: ":90", viewer: { protocol: "vnc", port: 5900 } } }), + }); + // Two port rows carry two Open controls; the gate is the row-independent + // fact, so the first one states it. + // Cast-and-read, this repo's convention: no jest-dom dependency; the + // disabled-with-reason trio lives on `aria-disabled` + `aria-description` + // (ExplainedButton). + const open = screen.getAllByRole("button", { name: /^Open web preview/ })[0]; + expect(open.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(open.getAttribute("aria-description")).toBe(VIEW_REASON); + const watch = screen.getByRole("button", { name: "Watch browser" }); + expect(watch.matches(":disabled, [aria-disabled=true]")).toBe(false); + const showScreen = screen.getByRole("button", { name: "View screen" }); + expect(showScreen.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(showScreen.getAttribute("aria-description")).toBe(SCREEN_VIEW_REASON); + }); + + it("a node with the switch off answers with the switch's remedy, not a silent empty list", () => { + mount({ info: info({ ports: [], cdpPort: null, nodeEnabled: false, reason: "preview-disabled" }) }); + expect(screen.getByText(SWITCH_OFF_REASON)).toBeTruthy(); + }); + + it("the disabled Watch explains itself the way Open and Screen do", () => { + mount({ info: info({ nodeEnabled: false, reason: "preview-disabled" }) }); + const watch = screen.getByRole("button", { name: "Watch browser" }); + expect(watch.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(watch.getAttribute("aria-description")).toBe(SWITCH_OFF_REASON); + }); +}); + +describe("Open (edit unlocks it)", () => { + it("edit opens the panel hosting the proxy iframe and a new-tab link with the proxy href", async () => { + const { view } = mount({ access: "edit", info: info() }); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + await waitFor(() => expect(view.container.querySelector("iframe")).not.toBeNull()); + expect(view.container.querySelector("iframe")?.getAttribute("src")).toBe("/pv/ref1/"); + const link = screen.getByRole("link", { name: /open in new tab/i }); + expect(link.getAttribute("href")).toBe("/pv/ref1/"); + }); + + it("the panel closes again", async () => { + const { view } = mount({ access: "edit", info: info() }); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + await waitFor(() => expect(view.container.querySelector("iframe")).not.toBeNull()); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(view.container.querySelector("iframe")).toBeNull(); + }); +}); + +describe("the Watch canvas", () => { + function canvas(container: HTMLElement) { + return container.querySelector("[data-preview-canvas]"); + } + + it("swaps to the newest frame and ignores a lower seq", async () => { + const { view } = mount({ info: info(), controller: { watching: true } }); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 2, w: 10, h: 8, blob: "AA-BB-CC" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("2")); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 3, w: 10, h: 8, blob: "DD-EE" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("3")); + expect(canvas(view.container)?.getAttribute("data-bytes")).toBe("5"); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 1, w: 10, h: 8, blob: "OLD-BLOB-X" }); + expect(canvas(view.container)?.getAttribute("data-seq")).toBe("3"); + expect(canvas(view.container)?.getAttribute("data-bytes")).toBe("5"); + }); + + it("ignores frames for other panes", async () => { + const { view } = mount({ info: info(), controller: { watching: true } }); + publishPreviewFrame({ type: "preview-stream", id: "other-pane", seq: 9, w: 1, h: 1, blob: "X" }); + // Give any mis-wired subscription a chance to land before calling absence an assert. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(canvas(view.container)?.getAttribute("data-seq")).not.toBe("9"); + }); + + it("a fake clock past 5 s with no frame reads stale, and the fps/age badge freezes with it", async () => { + const realNow = Date.now; + let fake = 100_000; + Date.now = () => fake; + (globalThis as { __restoreNow?: () => void }).__restoreNow = () => { + Date.now = realNow; + }; + const { view } = mount({ info: info(), controller: { watching: true }, isPhone: false }); + // A tiny tick so the badge recomputes against the fake clock in test time. + const badge = () => view.container.querySelector("[data-preview-badge]"); + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 1, w: 10, h: 8, blob: "AA-BB" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("1")); + // A second frame a simulated second later: the badge must read age off + // the NEWEST arrival, not mount time and not the first frame. At fake + // 101 s the newest stamp is 101 s, so the line says "0.0 s ago"; an age + // anchored at mount or at the first frame would say 1.0 s instead. + fake = 101_000; + publishPreviewFrame({ type: "preview-stream", id: "pane1", seq: 2, w: 10, h: 8, blob: "AA-BB-CC" }); + await waitFor(() => expect(canvas(view.container)?.getAttribute("data-seq")).toBe("2")); + const frozen = badge()?.firstChild?.textContent; + expect(frozen).toMatch(/\d\.\d fps/); + expect(frozen).toMatch(/· 0\.0 s ago/); + // 5 s after the newest arrival: stale. + fake = 106_001; + await waitFor(() => expect(screen.getByText(/still there\?/i).textContent).toMatch(/still there\?/i), { + timeout: 2000, + }); + // Frozen while stale: the clock line holds its last live reading (the + // first text node; the stale span appended beside it is the notice, not + // the clock). + expect(badge()?.firstChild?.textContent).toBe(frozen); + // The canvas itself stays: a stale page, not a vanished one. + expect(canvas(view.container)?.getAttribute("data-seq")).toBe("2"); + }); + + it("cdpPort vanishing mid-watch says the browser is gone beside the stale canvas", () => { + const { view } = mount({ + access: "view", + info: info({ ports: [], cdpPort: null }), + controller: { watching: true }, + }); + expect(screen.getByText(/browser is no longer running/i)).toBeTruthy(); + expect(canvas(view.container)).not.toBeNull(); + }); + + it("a refused watch renders the named REST error, and no canvas claims frames", () => { + mount({ + info: info(), + controller: { watchError: "watch-port-conflict: the bridge is already streaming another port" }, + }); + expect(screen.getByRole("alert").textContent).toContain("watch-port-conflict"); + }); +}); + +describe("Watch verbs drive both halves through the controller", () => { + it("pressing Watch starts it on the browser row's port", async () => { + let asked: number | undefined; + mount({ + info: info(), + controller: { + startWatch: async (port) => { + asked = port; + }, + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Watch browser" })); + await waitFor(() => expect(asked).toBe(9333)); + }); + + it("while watching, the control stops the watch", async () => { + let stopped = 0; + mount({ info: info(), controller: { watching: true, stopWatch: async () => void stopped++ } }); + fireEvent.click(screen.getByRole("button", { name: /stop/i })); + await waitFor(() => expect(stopped).toBe(1)); + }); +}); + +describe("the screen row", () => { + it("a live screen shows its display and the Screen action for edit (Task 13's enabled contract; the viewer itself is screen-viewer.test)", () => { + mount({ + access: "edit", + info: info({ ports: [], cdpPort: null, screen: { display: ":90", viewer: { protocol: "vnc", port: 5901 } } }), + }); + // One render contract (round-14 MINOR): the row always carries the button; + // edit clears its reason, and no hint text replaces it. + expect(screen.getByText("Virtual display :90")).toBeTruthy(); + const button = screen.getByRole("button", { name: "View screen" }); + expect(button.matches(":disabled, [aria-disabled=true]")).toBe(false); + }); + + it("screenLost reads as the loss line, with no Screen control", () => { + mount({ info: info({ ports: [], cdpPort: null, screenLost: "x11vnc" }) }); + expect(screen.getByText("Display stopped: x11vnc exited")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "View screen" })).toBeNull(); + }); +}); + +describe("phone layouts trade the panel, never Watch", () => { + it("Screen renders present but disabled on a phone, with the layout as the reason", () => { + // The ruling after Task 14's mobile walk-through: an enabled Screen press + // on a phone opened a real lane no panel would ever host, and the press + // read as a failure. The affordance stays, the reason names the layout, + // and Watch - the verb a phone CAN serve - stays enabled beside it. + mount({ + access: "edit", + info: info({ screen: { display: ":90", viewer: { protocol: "vnc", port: 5900 } } }), + isPhone: true, + }); + const showScreen = screen.getByRole("button", { name: "View screen" }); + expect(showScreen.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(showScreen.getAttribute("aria-description")).toBe(SCREEN_PHONE_REASON); + const watch = screen.getByRole("button", { name: "Watch browser" }); + expect(watch.matches(":disabled, [aria-disabled=true]")).toBe(false); + }); + + it("prepares a real link on phones so a slow request cannot become a blocked popup", async () => { + const { view } = mount({ access: "edit", info: info(), isPhone: true }); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + const link = await screen.findByRole("link", { name: "Open port 3000 in new tab" }); + expect(link.getAttribute("href")).toBe("/pv/ref1/"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(view.container.querySelector("iframe")).toBeNull(); + }); +}); + +describe("the shared-pane banner variant", () => { + it("raises once per device on first Preview open, and stays down after", async () => { + const shared = row({ shareCount: 2, sharedWithEveryone: false }); + const { view } = mount({ info: info(), subshell: shared, bannerVisibleMs: 30 }); + // The PREVIEW variant's own copy: it says this surface shows more than the terminal. + expect(screen.getByText(/live view/i)).toBeTruthy(); + // Retire it (auto-fade + mark-seen runs at visibleMs + the fade). The cap is + // generous on purpose: the dwell is 30 ms, so the only thing this waits past + // is the event loop, not the product. In the SERIAL CI run every test file + // shares one process, and earlier files' leaked global timers make the loop + // crawl - a 2 s cap timed out on CI twice while the parallel local run flew + // (measured 2026-10-23: 29 s to format the failure). The 10 s cap above sits + // PAST bun's 5 s default test budget, so the test also carries an explicit + // 45 s budget (third arg): without it the loop starvation cut the test off + // at 5 s while the waitFor was still patient (both CI retries, measured). + // The disappearance poll asserts a BOOLEAN: `expect(node).toBeNull()` makes + // bun serialize the whole happy-dom graph on every failed poll, which is the + // 30 s-per-failure the CI run showed even with the cap (2026-10-23: 30350 ms + // for this test, formatting, on a job that ran the rest in 68 s). A banner + // that truly never retired would still fail here at the cap. + await waitFor(() => expect(screen.queryByText(/shared this subshell with 2 other people/i) === null).toBe(true), { + timeout: 10000, + }); + view.unmount(); + render(); + expect(screen.queryByText(/shared this subshell with 2 other people/i) === null).toBe(true); + }, 45000); + + it("says nothing when the pane is private", () => { + mount({ info: info(), subshell: row({ shareCount: 0 }) }); + expect(screen.queryByText(/shared/i)).toBeNull(); + }); +}); + +describe("unsupported discovery", () => { + it("explains the platform limit instead of promising ports will appear", () => { + mount({ info: info({ ports: [], cdpPort: null, nodeEnabled: true, reason: "preview-ports-unsupported" }) }); + expect(screen.getByText(/doesn’t support automatic port discovery/)).toBeTruthy(); + expect(screen.queryByText(/Nothing to preview yet/)).toBeNull(); + }); +}); + +it("fills a preview tab and resumes its selected app only once", async () => { + const opened: number[] = []; + const c = controller({ + info: info(), + openPort: async (port) => { + opened.push(port); + return { proxyBase: "/pv/tab/" }; + }, + }); + const view = render( + , + ); + await screen.findByTitle("Preview of port 3000"); + expect(screen.queryByText("Web preview") === null).toBe(true); + expect(screen.getByRole("region", { name: "Web previews & screens" }).className).toContain("h-full"); + view.rerender( + , + ); + expect(opened).toEqual([3000]); +}); + +it("replaces an app view when the browser watch is selected", async () => { + let starts = 0; + mount({ + info: info(), + isPhone: false, + controller: { + startWatch: async () => { + starts += 1; + }, + }, + }); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + await screen.findByText("Web preview"); + fireEvent.click(screen.getByRole("button", { name: "Watch browser" })); + expect(starts).toBe(1); + expect(screen.queryByText("Web preview") === null).toBe(true); +}); + +it("keeps the chooser free of a toolbar and shows it only after opening a view", async () => { + const c = controller({ info: info() }); + render(); + expect(screen.getAllByRole("button", { name: /^Open web preview/ }).length).toBeGreaterThan(0); + expect(screen.queryByRole("button", { name: "Open another preview" }) === null).toBe(true); + expect(screen.queryByRole("button", { name: "Refresh view" }) === null).toBe(true); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + await screen.findByTitle("Preview of port 3000"); + expect(screen.getByRole("button", { name: "Refresh view" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^Open web preview/ }) === null).toBe(true); +}); + +it("refreshes the selected app without allocating another forwarded lane", async () => { + let opened = 0; + const c = controller({ + info: info(), + openPort: async () => { + opened += 1; + return { proxyBase: "/pv/one-lane/" }; + }, + }); + render(); + fireEvent.click(screen.getAllByRole("button", { name: /^Open web preview/ })[0]); + const first = await screen.findByTitle("Preview of port 3000"); + fireEvent.load(first); + expect(screen.getByRole("button", { name: "Web preview loaded" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Refresh view" })); + expect(screen.getByTitle("Preview of port 3000").isSameNode(first)).toBe(false); + expect(opened).toBe(1); +}); diff --git a/apps/server/web/src/components/preview/__tests__/preview-toolbar.test.tsx b/apps/server/web/src/components/preview/__tests__/preview-toolbar.test.tsx new file mode 100644 index 000000000..c7b6d8e44 --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/preview-toolbar.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { PreviewToolbar, type PreviewToolbarProps } from "../preview-toolbar"; + +afterEach(cleanup); +function props(overrides: Partial = {}): PreviewToolbarProps { + return { + selected: "app:41430", + onRefresh: () => {}, + onDisconnect: () => {}, + onFullscreen: () => {}, + fullscreen: false, + fullscreenSupported: true, + appUrl: "/pv/example/", + busy: false, + connection: "connected", + ...overrides, + }; +} +it("keeps secondary actions in More, including an actual browser-tab link", async () => { + let closed = 0; + render( + { + closed += 1; + }, + })} + />, + ); + expect(screen.queryByRole("menuitem", { name: "Close view" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + const link = await screen.findByRole("menuitem", { name: "Open in browser tab" }); + expect(link.getAttribute("href")).toBe("/pv/example/"); + expect(link.getAttribute("target")).toBe("_blank"); + fireEvent.click(screen.getByRole("menuitem", { name: "Close view" })); + expect(closed).toBe(1); +}); +it("refreshes the current view and explains a pending selection", () => { + let refreshed = 0; + const view = render( + { + refreshed += 1; + }, + })} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh view" })); + expect(refreshed).toBe(1); + view.rerender(); + expect(screen.getByRole("button", { name: "Refresh view" }).getAttribute("aria-disabled")).toBe("true"); +}); + +it("offers full screen in More and puts its exit directly in the toolbar", async () => { + let toggled = 0; + const view = render( + { + toggled += 1; + }, + })} + />, + ); + expect(screen.queryByRole("button", { name: "Full screen" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Full screen" })); + expect(toggled).toBe(1); + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + expect(screen.queryByRole("menuitem", { name: "Full screen" })).toBeNull(); + expect(screen.getByRole("button", { name: "Exit full screen" })).toBeTruthy(); +}); + +it("keeps secondary menu actions inside the fullscreen viewer", async () => { + const host = document.createElement("section"); + document.body.append(host); + const descriptor = Object.getOwnPropertyDescriptor(document, "fullscreenElement"); + Object.defineProperty(document, "fullscreenElement", { configurable: true, value: host }); + try { + render(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + expect(host.contains(await screen.findByRole("menuitem", { name: "Open in browser tab" }))).toBe(true); + } finally { + if (descriptor) Object.defineProperty(document, "fullscreenElement", descriptor); + else Reflect.deleteProperty(document, "fullscreenElement"); + host.remove(); + } +}); + +it("offers maximize in More and restores directly from the toolbar", async () => { + let toggled = 0; + const workspace = { + maximized: false, + onToggleMaximize: () => { + toggled += 1; + }, + }; + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Maximize pane" })); + expect(toggled).toBe(1); + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "More view actions" })); + expect(screen.queryByRole("menuitem", { name: "Maximize pane" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Restore pane" })); + expect(toggled).toBe(2); +}); diff --git a/apps/server/web/src/components/preview/__tests__/screen-viewer.test.tsx b/apps/server/web/src/components/preview/__tests__/screen-viewer.test.tsx new file mode 100644 index 000000000..c6815f202 --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/screen-viewer.test.tsx @@ -0,0 +1,568 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { PREVIEW_CLOSE_REASONS } from "@internal/subshell-protocol"; +import * as novncReal from "@novnc/novnc"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { nodeReasonLine, SCREEN_VIEW_REASON } from "@/components/preview/port-list"; +import { PreviewSection } from "@/components/preview/preview-section"; +import { SCREEN_CLOSE_LINES, ScreenViewer } from "@/components/preview/screen-viewer"; +import type { PreviewInfo, UseSubshellPreview } from "@/hooks/use-subshell-preview"; +import { installRfbBounds } from "@/lib/preview-rfb-bounds"; +import { setConsoleErrorObserver } from "@/test-setup"; + +/** + * The Screen viewer and its control (spec §6, §7; Task 13). + * + * The RFB class is mocked at the module: what this surface owns is the + * WEBSOCKET (its URL, its lifecycle, its close event), and the point of the + * hand-the-socket construction is exactly that the wire's reason word reaches + * OUR listener. So the mock records what it was handed and stays out of the + * way. The module mock is restored per test so a serial run's later files get + * the real class back (the enroll-route.test.ts snapshot pattern). The file's + * last describe is the one exception: it pins the bun patch on the REAL class, + * and the eager `novncReal` import is how it reaches it untouched by the mock. + * + * Each disconnect word is fed the way it ACTUALLY arrives (Task 10's wire + * shape): every word rides pv-ws's close event (code 1011, the word verbatim + * in `reason`), while `viewer-left` never rides a close. It is what our own + * disconnect causes: the socket ends with nothing on it, and the component + * says so. `revoked` gets its own named line, not a generic loss. `over-cap` + * too: it means the byte-limit death (the node's frame guard, or the plane's + * oversized message), which rides the word. Only the lane-COUNT refusal is + * wordless, answered with a 503 at the upgrade before any close exists. + */ + +// Eager static copies taken before any mocking: the restore re-arms the real +// module, and a swapped-out global WebSocket never outlives this file. +const novncSnapshot = { ...novncReal }; +const realWebSocket = globalThis.WebSocket; + +class MockRFB { + _FBU = { width: 0, height: 0, encoding: 0 }; + _resize() {} + _handleRect() { + return true; + } + _fail() { + this.disconnect(); + return false; + } + _display = { _renderQ: [], imageRect() {} }; + static builds: MockRFB[] = []; + scaleViewport = false; + focusOnClick = false; + clipViewport = false; + disconnects = 0; + onConnect: (() => void) | undefined; + addEventListener(type: string, listener: () => void) { + if (type === "connect") this.onConnect = listener; + } + + constructor( + readonly target: HTMLElement, + readonly channel: unknown, + ) { + MockRFB.builds.push(this); + } + + disconnect(): void { + this.disconnects++; + } +} + +class MockSocket { + static made: MockSocket[] = []; + closed = 0; + readyState = 1; + private readonly listeners: { type: string; cb: (ev: unknown) => void }[] = []; + + constructor(readonly url: string) { + MockSocket.made.push(this); + } + + addEventListener(type: string, cb: (ev: unknown) => void): void { + this.listeners.push({ type, cb }); + } + + removeEventListener(type: string, cb: (ev: unknown) => void): void { + const i = this.listeners.findIndex((l) => l.type === type && l.cb === cb); + if (i >= 0) this.listeners.splice(i, 1); + } + + /** The browser's own close: the socket ends and the close carries no reason word. */ + close(): void { + this.closed++; + this.dispatchClose(1005, ""); + } + + dispatchClose(code: number, reason: string): void { + for (const l of [...this.listeners]) if (l.type === "close") l.cb({ code, reason }); + } +} + +/** + * A raw channel shaped exactly as noVNC's Websock wants it: the property list + * `core/websock.js` checks on attach, parked at CONNECTING (0) so the real + * RFB constructor reaches the code under test and stops there, no handshake, + * no pump, no timers. It doubles as the `WebSocket` the URL form opens for + * itself. Only the patch-pin describe uses it. + */ +class PatchProbeChannel { + binaryType = ""; + onerror: ((ev: unknown) => void) | null = null; + onmessage: ((ev: unknown) => void) | null = null; + onopen: (() => void) | null = null; + protocol = ""; + readyState = 0; + send(): void {} + close(): void {} +} + +beforeEach(() => { + MockRFB.builds = []; + MockSocket.made = []; + mock.module("@novnc/novnc", () => ({ default: MockRFB })); + globalThis.WebSocket = MockSocket as unknown as typeof WebSocket; +}); + +afterEach(() => { + cleanup(); + mock.module("@novnc/novnc", () => novncSnapshot); + globalThis.WebSocket = realWebSocket; +}); + +/** The tunnel root pv-ws serves: the same formula the component must use. */ +function tunnelUrl(proxyBase: string): string { + const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${wsProto}//${window.location.host}${proxyBase}`; +} + +function info(over: Partial = {}): PreviewInfo { + return { + ports: [], + cdpPort: null, + screen: { display: ":90", viewer: { protocol: "vnc", port: 5901 } }, + nodeEnabled: true, + ...over, + }; +} + +function controller(over: Partial = {}): UseSubshellPreview { + return { + info: info(), + infoLoading: false, + error: null, + watching: false, + watchError: null, + openPort: async () => ({ proxyBase: "/pv/ref1/" }), + startWatch: async () => {}, + stopWatch: async () => {}, + refresh: () => {}, + ...over, + }; +} + +describe("the viewer owns the socket", () => { + it("mounts one WebSocket at the session's tunnel root and hands the SOCKET to RFB", () => { + render( {}} />); + expect(MockSocket.made).toHaveLength(1); + expect(MockSocket.made[0].url).toBe(tunnelUrl("/pv/ref1/")); + expect(MockRFB.builds).toHaveLength(1); + const rfb = MockRFB.builds[0]; + // Not a URL string: the component keeps the socket, which is what puts + // pv-ws's 1011 + reason word within reach of its own listener. + expect(rfb.channel).toBe(MockSocket.made[0]); + expect(rfb.scaleViewport).toBe(true); + expect(rfb.focusOnClick).toBe(true); + expect(rfb.clipViewport).toBe(false); + }); + + it("unmounting disconnects: RFB let go and the socket closed, which is how the lane reads viewer-left", () => { + const view = render( {}} />); + const [ws] = MockSocket.made; + const [rfb] = MockRFB.builds; + view.unmount(); + expect(rfb.disconnects).toBe(1); + expect(ws.closed).toBe(1); + }); +}); + +describe("disconnect causes render by name", () => { + // Every protocol word is dispatch-rendered, not just map-tested: the + // listener's lookup path is half of the contract. `over-cap` earns its row + // here the same way as the rest, because its byte-limit deaths (the node's + // frame guard, the plane's oversized message) ride the wire word. + for (const word of Object.keys(SCREEN_CLOSE_LINES) as (keyof typeof SCREEN_CLOSE_LINES)[]) { + it(`a ${word} close, fed as pv-ws sends it (1011 + the word), renders its own line`, () => { + render( {}} />); + act(() => { + MockSocket.made[0].dispatchClose(1011, word); + }); + expect(screen.getByText(SCREEN_CLOSE_LINES[word])).toBeTruthy(); + }); + } + + it("revoked names the access change, not a generic loss", () => { + expect(SCREEN_CLOSE_LINES.revoked).toMatch(/access/i); + expect(SCREEN_CLOSE_LINES.revoked).not.toMatch(/connection lost/i); + }); + + it("every protocol close word owns its own line", () => { + expect(new Set(Object.keys(SCREEN_CLOSE_LINES))).toEqual(new Set(PREVIEW_CLOSE_REASONS)); + const lines = PREVIEW_CLOSE_REASONS.map((reason) => SCREEN_CLOSE_LINES[reason]); + for (const line of lines) expect(line.length).toBeGreaterThan(0); + expect(new Set(lines).size).toBe(lines.length); + }); + + it("a close carrying a word the protocol never defines reads as a lost link, not a named cause", () => { + render( {}} />); + act(() => { + MockSocket.made[0].dispatchClose(1011, "not-a-reason-of-ours"); + }); + expect(screen.getByText(/closed before/i)).toBeTruthy(); + expect(screen.queryByText(SCREEN_CLOSE_LINES["over-cap"])).toBeNull(); + }); + + it("the component's own disconnect renders the viewer-left line through the close it causes", () => { + render( {}} />); + const [ws] = MockSocket.made; + // The path pv-ws reserves for the viewer's own leaving: our close, and a + // close event with nothing on it. Not a hand-built reason prop. + fireEvent.click(screen.getByRole("button", { name: /disconnect/i })); + expect(ws.closed).toBe(1); + expect(screen.getByText(/you closed this preview/i)).toBeTruthy(); + }); + + it("a wordless close we did not start reads as a lost link, never as the user's own leave", () => { + render( {}} />); + act(() => { + MockSocket.made[0].dispatchClose(1006, ""); + }); + expect(screen.getByText(/closed before/i)).toBeTruthy(); + expect(screen.queryByText(/you closed this preview/i)).toBeNull(); + }); +}); + +describe("the Screen control", () => { + it("its presence follows info.screen", () => { + render( + , + ); + expect(screen.queryByRole("button", { name: "View screen" })).toBeNull(); + cleanup(); + render(); + expect(screen.getByRole("button", { name: "View screen" })).toBeTruthy(); + }); + + it("enabled-ness follows edit", () => { + render(); + expect(screen.getByRole("button", { name: "View screen" }).matches(":disabled, [aria-disabled=true]")).toBe(false); + cleanup(); + render(); + const gated = screen.getByRole("button", { name: "View screen" }); + expect(gated.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(gated.getAttribute("aria-description")).toBe(SCREEN_VIEW_REASON); + }); + + it("a node with the switch off keeps edit's control present and explains the switch", () => { + render( + , + ); + const screenButton = screen.getByRole("button", { name: "View screen" }); + expect(screenButton.matches(":disabled, [aria-disabled=true]")).toBe(true); + expect(screenButton.getAttribute("aria-description")).toBe(nodeReasonLine("preview-disabled")); + }); + + it("pressing the enabled control drives exactly one open against rfbPort, mounts the viewer on the answer, and refreshes the poll", async () => { + const opens: number[] = []; + let refreshes = 0; + const preview = controller({ + openPort: async (port) => { + opens.push(port); + return { proxyBase: "/pv/screen-ref/" }; + }, + refresh: () => { + refreshes++; + }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "View screen" })); + await waitFor(() => expect(opens).toEqual([5901])); + expect(opens).toHaveLength(1); + // The answer's proxyBase is the tunnel root the viewer dials, and the + // post-launch info refresh is what makes the fresh session's state land. + await waitFor(() => expect(MockSocket.made[0]?.url).toBe(tunnelUrl("/pv/screen-ref/"))); + expect(MockRFB.builds).toHaveLength(1); + expect(refreshes).toBe(1); + }); +}); + +describe("teardown on the paths that keep the instance alive", () => { + /** The live viewer's handle: its Disconnect button, which only the mounted viewer has. */ + function viewerAlive(): boolean { + return screen.queryByRole("button", { name: /disconnect/i }) !== null; + } + + it("StrictMode's simulated cleanup burns no close line into the viewer that survives it", () => { + // main.tsx wraps the whole app in StrictMode, and dev React double-invokes + // the effect on the SAME instance. The cleanup's own socket close must not + // be read as a cause: the viewer never died, and a line burned here poisons + // every later genuine close (leftRef is a ref, so it survives the swap). + render( + + {}} /> + , + ); + // The double-invoke really happened: the discarded first socket, then the live one. + expect(MockSocket.made).toHaveLength(2); + expect(screen.queryByText(/closed/i)).toBeNull(); + expect(viewerAlive()).toBe(true); + // And the live socket's own wordless close still reads as what it is. + act(() => { + MockSocket.made[1].dispatchClose(1006, ""); + }); + expect(screen.getByText(/closed before/i)).toBeTruthy(); + expect(screen.queryByText(/you closed this preview/i)).toBeNull(); + }); + + it("repointing the pane in place drops the screen session and the open panel with it", async () => { + // The mount site (subshells_.$id.tsx) holds no key on the Section: swipe + // navigation changes `paneId` on the SAME instance. Carrying pane A's + // state into B would render A's live VNC under B's name forever, and + // RFB's pump keeps pv-ws from ever idling the lane, pinning the slot. + const preview = controller({ + info: info({ ports: [{ proto: "tcp", port: 8080, pid: 4242 }] }), + openPort: async (port) => ({ proxyBase: port === 5901 ? "/pv/screen-ref/" : "/pv/port-ref/" }), + }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "View screen" })); + await waitFor(() => expect(viewerAlive()).toBe(true)); + fireEvent.click(screen.getByRole("button", { name: "Open web preview" })); + await waitFor(() => expect(screen.getByText("Port 8080")).toBeTruthy()); + view.rerender(); + expect(viewerAlive()).toBe(false); + expect(screen.queryByText("Port 8080")).toBeNull(); + }); + + it("an open whose pane changes mid-POST cannot repopulate the new pane", async () => { + // Both presses are in flight when the page repoints; the answers belong + // to the pane the user left, not the one now on screen. + const resolvers: ((v: { proxyBase: string }) => void)[] = []; + const preview = controller({ + info: info({ ports: [{ proto: "tcp", port: 8080, pid: 4242 }] }), + openPort: () => new Promise((resolve) => void resolvers.push(resolve)), + }); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "View screen" })); + fireEvent.click(screen.getByRole("button", { name: "Open web preview" })); + expect(resolvers).toHaveLength(2); + view.rerender(); + await act(async () => { + resolvers[0]?.({ proxyBase: "/pv/late-screen/" }); + resolvers[1]?.({ proxyBase: "/pv/late-port/" }); + }); + expect(viewerAlive()).toBe(false); + expect(MockSocket.made).toHaveLength(0); + expect(screen.queryByText("Port 8080")).toBeNull(); + }); +}); + +/** + * The pin on `patches/@novnc%2Fnovnc@1.7.0.patch` (operator ruling 2026-10-23, + * from the feature's leg log): on a plain-http LAN origin the Screen session + * works fine, so noVNC must not print "noVNC requires a secure context (TLS). + * Expect crashes!" for OUR construction. The patch narrows that warning to + * URL mode, where noVNC opens its own socket and will later reach for crypto + * and clipboard APIs the insecure context genuinely withholds. + * + * These two tests run the REAL class (the eager `novncReal` import, not the + * module mock) through the console observer the preload installs, because + * noVNC binds `console.error` at its own import and a spy attached afterward + * could never see the line. The URL-mode row is the permanent positive + * control: if a future `bun install` silently drops patches/ the socket row + * fails; if the capture itself ever dies, this row fails. Neither assertion + * can pass vacuously. + */ +describe("the secure-context warning speaks only in URL mode", () => { + const RealRFB = novncReal.default; + + /** The plain-http shape: a page whose origin is not a secure context. */ + function withPlainHttpPage(run: () => T): T { + const holder = window as unknown as { isSecureContext?: unknown }; + const previous = Object.getOwnPropertyDescriptor(window, "isSecureContext"); + Object.defineProperty(window, "isSecureContext", { value: false, configurable: true, writable: true }); + try { + return run(); + } finally { + if (previous) Object.defineProperty(window, "isSecureContext", previous); + else delete holder.isSecureContext; + } + } + + /** Everything `console.error` carried while `run` went through, one line per call. */ + function errorLinesFrom(run: () => void): string[] { + const lines: string[] = []; + setConsoleErrorObserver((args) => lines.push(args.map(String).join(" "))); + try { + run(); + } finally { + setConsoleErrorObserver(null); + } + return lines; + } + + it("the hand-the-socket form, the one Screen uses, logs nothing about the context", () => { + const lines = errorLinesFrom(() => + withPlainHttpPage(() => { + void new RealRFB(document.createElement("div"), new PatchProbeChannel() as unknown as WebSocket); + }), + ); + expect(lines.some((line) => line.includes("secure context") || line.includes("Expect crashes"))).toBe(false); + }); + + it("URL mode still carries the warning, which keeps this capture honest about it", () => { + const previousWebSocket = globalThis.WebSocket; + // URL mode opens its own socket; the probe channel answers for that too. + globalThis.WebSocket = PatchProbeChannel as unknown as typeof WebSocket; + try { + const lines = errorLinesFrom(() => + withPlainHttpPage(() => { + void new RealRFB(document.createElement("div"), "ws://plane.invalid/pv/probe/" as unknown as WebSocket); + }), + ); + expect(lines.some((line) => line.includes("Expect crashes"))).toBe(true); + } finally { + globalThis.WebSocket = previousWebSocket; + } + }); +}); + +it("opens a cached screen shortcut once under StrictMode, including in a phone workspace tab", async () => { + const ports: number[] = []; + const c = controller({ + info: info({ screen: { display: ":90", viewer: { protocol: "vnc", port: 5910 } } }), + openPort: async (port) => { + ports.push(port); + return { proxyBase: "/pv/shortcut/" }; + }, + }); + render( + + + , + ); + await waitFor(() => expect(MockSocket.made.filter((socket) => socket.closed === 0)).toHaveLength(1)); + expect(screen.queryByText("Virtual display") === null).toBe(true); + expect(ports).toEqual([5910]); + expect(MockSocket.made.filter((socket) => socket.closed === 0)).toHaveLength(1); +}); + +it("reports the RFB connection state instead of treating an allocated lane as connected", () => { + const states: string[] = []; + render( + {}} + compact + onConnectionChange={(state) => states.push(state)} + />, + ); + expect(states).toEqual(["connecting"]); + act(() => MockRFB.builds[0].onConnect?.()); + expect(states.at(-1)).toBe("connected"); + act(() => MockSocket.made[0].dispatchClose(1006, "")); + expect(states.at(-1)).toBe("disconnected"); +}); + +describe("real noVNC allocation guards", () => { + function bounded() { + const instance = new novncSnapshot.default( + document.createElement("div"), + new PatchProbeChannel() as unknown as WebSocket, + ); + const rfb = instance as unknown as { + _FBU: { width: number; height: number; encoding: number }; + _resize(w: number, h: number): void; + _handleRect(): boolean; + _fbWidth: number; + _fail(message: string): false; + _handleServerCutText(): boolean; + _sock: { rQwait(...args: unknown[]): boolean; rQskipBytes(n: number): void; rQshift32(): number }; + _display: { + _renderQ: { width: number; height: number }[]; + _drawCtx: { getImageData?: () => unknown; putImageData?: () => void }; + _targetCtx: { drawImage?: () => void }; + imageRect(x: number, y: number, w: number, h: number, mime: string, bytes: Uint8Array): void; + }; + }; + rfb._display._drawCtx.getImageData = () => null; + rfb._display._drawCtx.putImageData = () => {}; + rfb._display._targetCtx.drawImage = () => {}; + const failures: string[] = []; + rfb._fail = (message) => { + failures.push(message); + return false; + }; + installRfbBounds(instance); + return { rfb, failures }; + } + it("keeps ordinary resizing and rejects an oversized framebuffer before canvas allocation", () => { + const { rfb, failures } = bounded(); + rfb._resize(1280, 800); + expect(rfb._fbWidth).toBe(1280); + rfb._resize(65535, 65535); + expect(rfb._fbWidth).toBe(1280); + expect(failures).toHaveLength(1); + }); + it("rejects cursor/data allocations and unsupported video, while allowing empty pseudo rectangles", () => { + for (const encoding of [-239, 7, 50]) { + const { rfb, failures } = bounded(); + rfb._FBU = { width: 65535, height: 65535, encoding }; + expect(rfb._handleRect()).toBe(false); + expect(failures).toHaveLength(1); + } + const { rfb, failures } = bounded(); + rfb._FBU = { width: 0, height: 0, encoding: -224 }; + expect(rfb._handleRect()).toBe(true); + expect(failures).toHaveLength(0); + }); + it("bounds a single update's render queue before another rectangle decodes", () => { + const { rfb, failures } = bounded(); + rfb._display._renderQ.push({ width: 8192, height: 4096 }); + rfb._FBU = { width: 1, height: 1, encoding: 7 }; + expect(rfb._handleRect()).toBe(false); + expect(failures).toHaveLength(1); + }); + it("skips clipboard bytes without reaching extended-clipboard decompression", () => { + const { rfb, failures } = bounded(); + const skips: number[] = []; + rfb._sock.rQwait = () => false; + rfb._sock.rQshift32 = () => 0xfffffff0; + rfb._sock.rQskipBytes = (n) => { + skips.push(n); + }; + expect(rfb._handleServerCutText()).toBe(true); + expect(skips).toEqual([3, 16]); + rfb._sock.rQshift32 = () => 0x7fffffff; + expect(rfb._handleServerCutText()).toBe(false); + expect(failures).toHaveLength(1); + }); + it("checks encoded image dimensions even when the rectangle claims to be small", () => { + const { rfb, failures } = bounded(); + const oversizedJpeg = new Uint8Array([255, 216, 255, 192, 0, 11, 8, 255, 255, 255, 255, 1, 1, 17, 0, 255, 217]); + rfb._display.imageRect(0, 0, 10, 10, "image/jpeg", oversizedJpeg); + expect(failures).toHaveLength(1); + }); +}); diff --git a/apps/server/web/src/components/preview/__tests__/workspace-preview.test.tsx b/apps/server/web/src/components/preview/__tests__/workspace-preview.test.tsx new file mode 100644 index 000000000..0ae1130c4 --- /dev/null +++ b/apps/server/web/src/components/preview/__tests__/workspace-preview.test.tsx @@ -0,0 +1,23 @@ +import { afterEach, expect, it } from "bun:test"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { publishWorkspacePreviews } from "@/lib/workspace-preview"; +import type { SubshellView } from "@/types/subshell"; +import { WorkspacePreview } from "../workspace-preview"; + +afterEach(() => { + cleanup(); + publishWorkspacePreviews([], null); +}); +it("hides the terminal's shortcut while its preview tab is open and restores it on close", () => { + render(); + expect(screen.getByRole("button", { name: "Web previews & screens" })).toBeTruthy(); + act(() => + publishWorkspacePreviews( + [{ id: "preview:p1:screen", paneId: "p1", subshellId: "s1", workspaceId: "w1", view: { kind: "screen" } }], + "preview:p1:screen", + ), + ); + expect(screen.queryByRole("button", { name: "Web previews & screens" }) === null).toBe(true); + act(() => publishWorkspacePreviews([], null)); + expect(screen.getByRole("button", { name: "Web previews & screens" })).toBeTruthy(); +}); diff --git a/apps/server/web/src/components/preview/novnc.d.ts b/apps/server/web/src/components/preview/novnc.d.ts new file mode 100644 index 000000000..7f8392ddb --- /dev/null +++ b/apps/server/web/src/components/preview/novnc.d.ts @@ -0,0 +1,25 @@ +/** + * Types for the slice of noVNC this app uses. The published `@novnc/novnc` + * ships plain ESM (`exports: ./core/rfb.js`) with no declarations, so the + * used surface is declared here rather than papered over at the call site + * (the bcrypt-pbkdf.d.ts precedent). Widen it when something else needs more. + */ +declare module "@novnc/novnc" { + /** One RFB connection to a VNC server, rendered inside `target`. */ + export default class RFB extends EventTarget { + /** + * A WebSocket `channel` is spoken to directly: RFB attaches its own + * listeners to it and never opens a socket of its own, which is how the + * caller keeps the connection (and its close event) in hand. + */ + constructor(target: HTMLElement, channel: WebSocket, options?: { credentials?: Record }); + /** Scale the remote screen to fit its container. */ + scaleViewport: boolean; + /** Move keyboard focus into the remote screen on click. */ + focusOnClick: boolean; + /** Clip the remote screen to its container; when false, noVNC scrolls it. */ + clipViewport: boolean; + /** Begin a clean disconnect; the `disconnect` event follows when it lands. */ + disconnect(): void; + } +} diff --git a/apps/server/web/src/components/preview/open-panel.tsx b/apps/server/web/src/components/preview/open-panel.tsx new file mode 100644 index 000000000..3701d20c6 --- /dev/null +++ b/apps/server/web/src/components/preview/open-panel.tsx @@ -0,0 +1,74 @@ +import { Button, cn } from "@internal/node-admin"; +import { ExternalLink, X } from "lucide-react"; +import type { JSX } from "react"; + +/** + * The collapsible side panel one forwarded lane lives in (spec §7): the + * `/pv//` iframe itself, plus the escape hatch. The ref in `proxyBase` + * is the proxy's only secret, minted per open under the caller's own access, + * so this panel is just a viewer of a path that already exists. + */ + +import type { PreviewConnection } from "./preview-toolbar"; + +export interface OpenPanelProps { + compact?: boolean; + reloadKey?: number; + onConnectionChange?: (state: PreviewConnection) => void; + /** The forwarded port, for the title. */ + port: number; + /** The proxy path from `POST /views/open`, ending in a slash. */ + proxyBase: string; + /** Collapse the panel. The lane itself is the plane's to expire. */ + onClose: () => void; +} + +export function OpenPanel({ + port, + proxyBase, + onClose, + compact = false, + reloadKey, + onConnectionChange, +}: OpenPanelProps): JSX.Element { + return ( +
+ {!compact && ( +
+ Web preview + Port {port} + +
+ )} +