diff --git a/.changeset/attach-cursor-honest.md b/.changeset/attach-cursor-honest.md new file mode 100644 index 000000000..074067ed2 --- /dev/null +++ b/.changeset/attach-cursor-honest.md @@ -0,0 +1,6 @@ +--- +"@internal/server": patch +"@internal/node": patch +--- + +A pane viewer could open already garbled, and stay garbled for its whole life: the replay's cursor read accepts a blank answer as row zero. tmux occasionally prints `":"` for a pane caught mid-redraw, and `Number("")` is 0, so that moment became a confident "the cursor is top-left", the replay ended sixteen-odd rows off the pane, and every later frame that repositions relative to it (a TUI repainting its pinned spinner block) landed on the transcript instead. The read now answers the pane's real position or nothing at all, and "nothing" takes the known degrade: a replay without the restore rather than a wrong one. The attach journal line gained the matching witness, `cursor=x,y|none`, so the next "fixed elements overlap the output" report reads the base off the log instead of guessing. Also: the hand-run attach probes were repaired (stale dist paths, and `test-setup.ts` no longer loads outside the runner, so they get their own preload) and `probe-join.ts` joins a pane the way production does, overlap and all. diff --git a/.changeset/config.json b/.changeset/config.json index 31c12fafc..b0e4b77c5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,6 +14,7 @@ "@internal/backend-errors", "@internal/brand", "@internal/e2e", + "@internal/helper-panel", "@internal/mcp-core", "@internal/mobile", "@internal/node-admin", diff --git a/.changeset/copy-mode-toast-and-node-enabled.md b/.changeset/copy-mode-toast-and-node-enabled.md new file mode 100644 index 000000000..c32caced3 --- /dev/null +++ b/.changeset/copy-mode-toast-and-node-enabled.md @@ -0,0 +1,8 @@ +--- +"@internal/server": patch +"@internal/node": patch +--- + +A pane's **Enable text copying** toggle now asks the preference what the device actually holds before deciding what the press did. The header menu and the touch bar share one per-device set, and each used to flip its own React copy of it, so a press that followed another window's change could "turn copying off" on a pane that was already off and say so out loud. In a private window, where the device stores nothing at all, the two surfaces disagreed the other way: the one you clicked remembered your click while the other re-read an empty store. Both now take the answer the preference module holds, which is also what a refused write leaves the page with. + +`get_node` also holds its answer about a display provider to the same rule the launch path applies, mirroring all four of the plane's gates: the node switch, the provider catalog, the owner's enabled list, and whether that provider runs on this node's operating system, with the protocol's own default rather than a provider name typed into the tool. The last one used to be inferred from `status`, which is the node's own report rather than the plane's verdict, so a node answering optimistically about a platform it cannot serve had the tool recommending a launch the server refuses. `enabled` is now the plane's answer and `status` stays the node's, and when the node cannot be asked the tool says so and promises nothing else. The other three were already enforced on the way in, so nothing a user could click read wrong before this; what changes is that a row arriving by some other route, or one a later release shrinks out of the catalog, can no longer be reported ready here and then refused by the launch it recommended. diff --git a/.changeset/display-helper-copy.md b/.changeset/display-helper-copy.md new file mode 100644 index 000000000..ff72032ab --- /dev/null +++ b/.changeset/display-helper-copy.md @@ -0,0 +1,6 @@ +--- +"@internal/desktop-client": patch +"@internal/desktop-server": patch +--- + +Clarify virtual display setup and helper consent, permissions, sign-in, and quit messages. Keep shared-desktop warnings visible and explain when viewers disconnect while subshells keep running. Bump the bundled helper to 1.0.1 so its update is identified consistently across both desktop apps. diff --git a/.changeset/frozen-tail-recovery.md b/.changeset/frozen-tail-recovery.md new file mode 100644 index 000000000..7957713e1 --- /dev/null +++ b/.changeset/frozen-tail-recovery.md @@ -0,0 +1,7 @@ +--- +"@internal/server": patch +--- + +A pane terminal could freeze while the pane itself kept running: when the node's byte stream to the browser died, every device watching that subshell kept seeing the same stale screen, typing echoed nothing, and the state lasted until the server restarted. The dashboard builds one output pump per subshell and hands it to every later viewer, and a pump that went silent (a refused tail start, a relay that stopped delivering) never said so. Now it does: the server logs the failure, drops that pane's viewers with the retry close, and the re-attach that follows builds a fresh pump at the current end of the log. A shared pump also gets a watchdog: if the pane's log grows while the stream has delivered nothing for 25 seconds, the pump is declared dead the same way, and a genuinely quiet pane is left alone because the verdict compares the log's size against what the pump actually delivered. + +The workspace header's actions menu now offers the **Diagnostics** toggle for the focused pane, the same per-device HUD the subshell page has had since Wave C. It floats over the dock terminal the header stands above and reports the socket state, reconnects, viewers, input echo timings, and when the last pane byte arrived, which is what you want on screen while a terminal is misbehaving. diff --git a/.changeset/grid-resync-on-settle.md b/.changeset/grid-resync-on-settle.md new file mode 100644 index 000000000..ab1d967d0 --- /dev/null +++ b/.changeset/grid-resync-on-settle.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +A terminal no longer stays garbled after the pane it watches resizes. The reported shape: a TUI with a block that repaints constantly (a spinner, a pinned status line) would start drawing that block over the transcript, "where new output is mixed in", and keep doing it until the tab was closed. The window is the resize itself: bytes the pane emitted while changing its grid addressed a size the viewer never rendered, and every later frame the app positions relative to them lands a constant offset off. Only the attach can lay down a true base (absolute capture, cursor restore, confirmed geometry), so when a viewer adopts a grid other than the one its replay was painted into, and pane bytes arrived in between, it now silently re-attaches and heals. Once per observed change, debounced so a window drag settles before the replay, and gapped so a bouncing grid cannot turn the self-heal into a reconnect storm. The client never asks the server for a size, only for fresh truth. diff --git a/.changeset/helper-review-hardening.md b/.changeset/helper-review-hardening.md new file mode 100644 index 000000000..b681b62fd --- /dev/null +++ b/.changeset/helper-review-hardening.md @@ -0,0 +1,9 @@ +--- +"@internal/desktop-client": patch +"@internal/desktop-server": patch +"@internal/server": patch +--- + +Verify the installed display helper before opening it even when an update is blocked by another running helper. Preserve executable permissions when staging the helper, harden its bundled page's HTML escaping and native message boundary, and keep its state and menu updates consistent. + +Recover terminal grid changes that happen during the reconnect cooldown. Let native text selection reach the terminal rows in copy mode, and prevent input focus from clearing a new selection. diff --git a/.changeset/pipe-writer-rearm.md b/.changeset/pipe-writer-rearm.md new file mode 100644 index 000000000..00b8894dc --- /dev/null +++ b/.changeset/pipe-writer-rearm.md @@ -0,0 +1,5 @@ +--- +"@internal/node": patch +--- + +A node re-arms its own pane recorder now. Each pane's bytes reach the dashboard through a small writer process tmux pipes the pane into; if something kills just that writer (an OOM pass is the usual culprit) while the pane itself keeps running, the log stops growing, and every viewer sees a frozen terminal with a live pane behind it. tmux's own pipe flag cannot arbitrate (it has measured both on and off for a dead writer), so the exit watcher, which already touches every supervised pane, asks the honest question on that same beat: if the pane's screen moved and its log did not, repeatedly, for a little over a minute, the writer is dead, and the watcher re-arms the pipe on the same log file the long way: the form that cannot be talked out of it by tmux believing the pipe is already on. A quiet pane and a healthy writer both cost nothing beyond a periodic look, and the re-arm waits out three strikes so no single race spends one. diff --git a/.changeset/pr465-review-docs.md b/.changeset/pr465-review-docs.md new file mode 100644 index 000000000..7c0dcd1f6 --- /dev/null +++ b/.changeset/pr465-review-docs.md @@ -0,0 +1,7 @@ +--- +"@internal/docs": patch +--- + +Explain display-helper updates and signature failures, touch-device text copying, and terminal display recovery after resizing. Add screenshots of the copy controls and selected text, and show where to open helper settings. + +Explain private setup-key delivery, the CLI capability check, coordinated server and node upgrades, and the remaining shell-history and local-account trust limits. diff --git a/.changeset/pr465-review-hardening.md b/.changeset/pr465-review-hardening.md new file mode 100644 index 000000000..1f468955a --- /dev/null +++ b/.changeset/pr465-review-hardening.md @@ -0,0 +1,11 @@ +--- +"@internal/server": patch +"@internal/node": patch +"@internal/desktop-server": patch +"@internal/desktop-client": patch +"@internal/docs": patch +--- + +Keep preview redirects inside their proxy, preserve malformed app scripts for browser diagnostics, and bound concurrent rewriting by decoded bytes. Fix preview preferences on display-only nodes, reconnect grid tracking, launch-dialog cleanup, and node-settings links. Recover stalled capture writers between output bursts and rescue the Mac pointer after capture loss. Keep helper confirmations stable across state updates and clear them when settings reopen. Verify shared palette imports and refresh the preview documentation screenshots. + +Reserve terminal input capacity per node so an offline machine cannot consume the fleet's budget, give a keystroke on a live pane the capacity a stuck retry was holding before disconnecting anyone, and drop held input immediately during forced viewer recovery. Prune obsolete helper staging copies after a successful install while preserving recovery data on failure. Dismiss the helper's quit confirmation when Confirm is pressed, even where the native bridge is absent. diff --git a/.changeset/preview-compatibility-docs.md b/.changeset/preview-compatibility-docs.md new file mode 100644 index 000000000..76a38c5b0 --- /dev/null +++ b/.changeset/preview-compatibility-docs.md @@ -0,0 +1,5 @@ +--- +"@internal/docs": patch +--- + +Document personal preview settings, common URL rewriting, and browser compatibility limits. Explain live sharing revocation, terminal input bounds, private installer credentials, and Display Helper verification. Give Web previews & screens its own sidebar section with shared prerequisites and dedicated app, browser, Linux, macOS, and agent guides. Consolidate platform setup and preserve existing links. Start the overview with a loaded website preview and consolidate repeated prerequisites, access rules, and provider lifecycle explanations. diff --git a/.changeset/preview-proxy-compatibility.md b/.changeset/preview-proxy-compatibility.md new file mode 100644 index 000000000..71037fe9b --- /dev/null +++ b/.changeset/preview-proxy-compatibility.md @@ -0,0 +1,8 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Fix web preview asset paths and common development-server requests, including Next.js and Vite hot reload. Add personal proxy settings remembered for each app on a node, with controls for URL rewriting, the Host header, and redirects. + +Show the setup guide when either web previews or virtual displays is disabled, including when no views are discovered. diff --git a/.changeset/setup-command-credentials.md b/.changeset/setup-command-credentials.md new file mode 100644 index 000000000..f0bc96a00 --- /dev/null +++ b/.changeset/setup-command-credentials.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +Keep setup keys out of process arguments in dashboard-generated install and setup commands. Explain the current CLI requirement and the remaining shell-history and local-account trust limits. diff --git a/.changeset/ssh-bulletin-hardening.md b/.changeset/ssh-bulletin-hardening.md new file mode 100644 index 000000000..e52ac14ed --- /dev/null +++ b/.changeset/ssh-bulletin-hardening.md @@ -0,0 +1,8 @@ +--- +"@internal/server": patch +"@internal/node": patch +"@internal/desktop-server": patch +"@internal/desktop-client": patch +--- + +Keep SSH setup keys out of subprocess arguments, require stdin-capable connecting nodes, and refuse older downloaded node binaries before passing credentials. Bound retained terminal resize work during sustained updates and document the SSH security bulletin review. diff --git a/.changeset/terminal-input-budgets.md b/.changeset/terminal-input-budgets.md new file mode 100644 index 000000000..69ea2fa53 --- /dev/null +++ b/.changeset/terminal-input-budgets.md @@ -0,0 +1,7 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +"@internal/docs": patch +--- + +Bound terminal WebSocket input by frame count and UTF-8 payload bytes per connection and across the server, including dispatched writes and retries. Close connections that exceed the limits and prevent late failures from recreating retry queues after disconnect. Document the limits and recovery behavior. diff --git a/.changeset/terminal-share-revocation.md b/.changeset/terminal-share-revocation.md new file mode 100644 index 000000000..f18b86a11 --- /dev/null +++ b/.changeset/terminal-share-revocation.md @@ -0,0 +1,7 @@ +--- +"@internal/server": patch +"@internal/desktop-server": patch +"@internal/docs": patch +--- + +Revoke cached terminal access when subshell shares change, including connections still attaching and queued input retries. Recipients reconnect through the current access checks; the owner's terminal stays connected. Document the cutoff and the limits for commands already dispatched. diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md new file mode 100644 index 000000000..29a5f46d7 --- /dev/null +++ b/.changeset/virtual-display-rail-entry.md @@ -0,0 +1,28 @@ +--- +"@internal/desktop-client": minor +"@internal/desktop-server": minor +"@internal/server": patch +"@internal/node": patch +"@internal/docs": patch +--- + +Add a **Virtual Displays** section to both desktop assistants' sidebar, shown only where a display helper exists, so the helper is reachable from the window a person is already looking at instead of only from the macOS menu bar. The screen carries the steps the menu item never showed (enable the helper, grant Screen Recording and Accessibility to Subshell Display Helper, and what to do when macOS asks for a restart) above one button that opens the helper's own settings panel and a link to the setup guide. Document the sidebar as the way in. + +In the web dashboard, a node that cannot serve previews now says so in gold when the reason is that both of its preview switches are off, which an owner can fix, with a link to the guide that says how; an offline or outdated node stays muted, because no setting fixes that. A launch form, a preset, and the provider list no longer offer a virtual display provider the selected machine's operating system cannot run, and once the server knows the machine's OS it refuses one before the subshell row, its token, or a launch command exists, so a Mac is not asked to start Xvfb. A machine whose OS has not answered is not refused on that basis. Both shapes of no-answer count (no value at all, and an agent on a platform it cannot classify reporting the protocol's own `unknown`), because neither is a verdict: the list keeps what it held and the saved choice stays. Filtering on a non-answer would empty the provider card of a node that enrolled and never reported, which is the owner's only way to enable anything there, and would hide what a preset is about to launch. It is not a promise that such a machine can run the provider: the node answers that itself and refuses Xvfb on any non-Linux platform with `preview-display-unsupported`, so the server's check is a fast no rather than the only no. A provider the node has not enabled now stays named in its picker instead of hiding behind a trigger reading "Node default", since the line beside that row tells you to choose another provider and a choice you cannot see is not one you can change. + +A launch refused over its virtual display now reports the problem beside the **Virtual display** checkbox it belongs to, not under Save as preset; switch the checkbox off and the line moves back to the form's own message, so the reason is never both invisible and orphaned. A refusal the server raises itself now carries the node's own word for the same failure, so it lands in the same place whoever noticed it. On the machine, the display allocation says which readiness gate stayed shut (the Xvfb socket, the VNC port, or the listener's owner), logs what Xvfb and x11vnc printed before cleaning up, and gives a cold box 10 seconds instead of 5. + +The preview icon in a pane's header now wears a count when the pane has things you could open but have not saved: discovered app ports with no Saved services row, plus the screen and the browser watch when the pane offers them. It is the picker's own data, so the number and the list cannot disagree, and "you could" is read as the reader's own access: someone holding view sees the port and screen rows but cannot act on them, each disabled control saying why, so their badge counts only the browser watch and never numbers a control that answers "needs edit access". + +The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (The module is macOS-only, so no Linux lane even builds it, and the macOS lanes that do only fail at runtime on a machine with a real bundle: nothing in CI opens the helper's panel, so no lane ever pressed the path that checks a signature or reads a preference.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window. + +The panel itself is now the same product as the rest of Subshell, built from the components a person already knows in the dashboard: a card plate with its border and radius, `Fact` rows (a strong muted label above a readable value) for the login item and the two macOS permissions, and a form-section heading with its trailing rule for each group. Gold marks what you can still fix and plain ink what is settled, red what macOS refused, a live line names how many people are watching this Mac's screens, help sits at the bottom left and the exits at the bottom right in one shape, and a disabled control's reason is drawn in gold rather than hidden where a person who cannot hover will never find it. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css`, which every CSS surface imports rather than copies: the dashboard, both assistants and the helper's panel now read one file, while the node's own dashboard and the mobile app still keep a hand-maintained copy of the same values. The menu bar's menu now says it belongs to the Subshell virtual display helper instead of leaving you to guess from an icon, its **Minimize helper** item follows whether the panel is actually on screen, and the consent item turns itself into **Enable helper** once you disable the helper, raising the panel so you can read what you agreed to. It used to offer only Disable, greyed out once consent was off, which left the settings window as the only way back to running. + +**Quit helper** asks first, from the panel and from the menu bar's menu alike, and the question answers the part the button never said: watching stops now, your consent and the macOS permissions stay, and the helper +says what it will actually do next: it returns at sign-in only if you registered +it there, and the panel's **Stop at sign-in** button, not disabling, is what +takes it off that list. Quitting also stopped revoking your consent, which it had been doing silently along with unregistering the login item: an app's Close button destroying the very permission the panel exists to collect, so every reopen started from Disabled. + +Enabling the helper no longer adds it to macOS's sign-in list, and disabling no longer takes it off. **Start at sign-in** is its own row with its own button, and the row says which of the three states macOS actually reports, registered, waiting for your approval, or not registered at all. Reading the last two as "approved" is what let a turned-off sign-in start keep claiming it starts at sign-in. The panel re-reads the machine every five seconds while it is open, in every run-loop mode and with App Nap held off, so a switch you flip in System Settings changes what you see without reopening anything. + +Discarding the last virtual display on a Mac also hands the pointer back. A viewer driving that screen leaves the real cursor on a display that no longer exists, which looked like a vanished cursor to whoever was sitting at the machine. A virtual screen can outlive the helper, so the rescue runs on any helper start as well as on teardown: whoever opens the panel next finds their cursor back where they can see it. diff --git a/.changeset/website-virtual-displays.md b/.changeset/website-virtual-displays.md new file mode 100644 index 000000000..11cc7ccdb --- /dev/null +++ b/.changeset/website-virtual-displays.md @@ -0,0 +1,12 @@ +--- +"@internal/website": patch +"@internal/docs": patch +"@internal/server": patch +"@internal/desktop-server": patch +--- + +Add a virtual-display section to the website with a screenshot and Linux and macOS setup links. Refresh the display screenshot in the guide, and show SSH owner-only restrictions in gold like other node settings. + +Refresh the website-preview recording with a neutral demo path. + +Keep the phone demo at the same size when switching to the wider website layout. diff --git a/.claude/rules/design-system.md b/.claude/rules/design-system.md index b46effba6..23159f709 100644 --- a/.claude/rules/design-system.md +++ b/.claude/rules/design-system.md @@ -26,7 +26,13 @@ Colours by their shadcn names (`--foreground`, `--muted-foreground`, `--border`, …) on every surface; mobile in camelCase from `tokens.ts`. Spacing on the 4px grid. A `text-[13px]`, a `font-size: 14.5px`, a `fontSize: 17` or a hex outside a token file is refused by the check; if -the system lacks what you need, add the token to every surface in one change. +the system lacks what you need, add the colour to `packages/assistant/tokens.css` +once; the surfaces that draw the palette in CSS import that file (the SPA, both +assistants, the helper's panel), while the node's own dashboard and the mobile +app still keep a hand-maintained copy, so a colour change must be carried +across those two by hand and they are the first place to look when a surface +reads off-theme; a type role is restated in each surface's `@theme` block, so +change those together. **Aim for one or two short sentences per UI explanation.** Use more when consequences or limits need it. Keep warnings at the decision point; move diff --git a/AGENTS.md b/AGENTS.md index 0430a8a9e..e7dadb823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -646,8 +646,9 @@ are an explicit `gh workflow run release.yml -f app=all` (or a single component). **Tagging and releasing is owned by the workflow; never cut tags by hand.** -**Never write a changeset for an `ignore`d package** (ten workspaces in -`.changeset/config.json`, `@internal/server-web` among them): it can never +**Never write a changeset for an `ignore`d package** (fourteen workspaces in +`.changeset/config.json`, `@internal/server-web` and `@internal/helper-panel` +among them): it can never be consumed and it wedges the version PR with an empty release list on every later push. Describe that work in the changeset of the APP THAT SHIPS IT: SPA work belongs to `@internal/server`, pane-runtime or subshell-protocol diff --git a/apps/client/desktop/AGENTS.md b/apps/client/desktop/AGENTS.md index 3ef32ae17..6c0ca8c86 100644 --- a/apps/client/desktop/AGENTS.md +++ b/apps/client/desktop/AGENTS.md @@ -386,8 +386,9 @@ Server's assistant uses (spec 2026-09-12 § 6.4). Routing is ONE router, `lib/client-flow.ts`: `clientScreen({probe, settings, step, override})` asks what this person came to do before what this machine implies (the in-memory `FteStep` walk outranks the machine rules; a configured client lands on -**Control Plane**), and `railFor` shows the six standing sections (Control -Plane | Status | Service | Update | About | Reset) only for a settled +**Control Plane**), and `railFor` shows the standing sections (Control Plane | +Status | Service | Update | About | Virtual Displays | Reset, the Virtual +Displays one only where a display helper exists) only for a settled machine, every rail select being an override, as are the screens a person ASKS for (about, update, enroll). Confirmations answer in a dialog at the single `shell.confirm` slot; the rail's Reset is a door, not a section. The @@ -554,11 +555,24 @@ the first mutation. `planeUrl` is KEPT. **Working on reset: read ## macOS display helper -The native application menu's **Virtual Display Helper…** opens a shared -standalone AppKit settings panel. It is not part of first run and has no -Tauri IPC command. Both desktop apps bundle the same signed helper identity; -`desktop-core::display_helper` installs or reuses one per-user copy only on a -menu selection. The helper owns consent, OS permission requests, and login +The assistant's **Virtual Displays** rail section and the native menu's +**Virtual Display Helper…** open the same shared standalone AppKit settings +panel. The section is the door a person actually finds (added 2026-10-26, after +the menu bar proved undiscoverable), and it is the one that carries the +enablement steps: the menu item opened a panel and said nothing about the two +macOS permissions it then asks for. It is the rail's only platform FACT, dropped +wherever there is no helper (`railFor`), and it is not part of first run. The +panel itself stays the helper's: the command this section adds +(`node_open_display_helper`, no argument, assistant-only) opens it and reports +nothing back, because enabling and granting happen in another program's windows +and there is no status here to poll; a refusal is Rust's to say, in its native +dialog. The paragraph's guide link rides the `node_open_web` command the whole +window already has, naming the closed-set `display-guide` target so the docs URL +stays in Rust rather than the page. `display` joins `NodeUserScreen`, selected like +About or Update, and grants no reach beyond the command its press already +holds. Both desktop apps bundle the same signed helper identity; +`desktop-core::display_helper` installs or reuses one per-user copy only when a +door is taken. The helper owns consent, OS permission requests, and login registration. Read [the helper architecture](../../../docs/macos-display-helper.md) before changing bundling, activation, or local control. User instructions live -in [node management](../../docs/content/docs/nodes/manage.mdx#macos-display-helper). +in [macOS virtual displays](../../docs/content/docs/previews/macos.mdx). diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index d2e69a400..4346491a4 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "21.0.4", + "@internal/helper-panel": "workspace:*", "@internal/tsconfig": "workspace:*", "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "2.11.4", diff --git a/apps/client/desktop/src-tauri/capabilities/node.json b/apps/client/desktop/src-tauri/capabilities/node.json index 4999c6983..18792215b 100644 --- a/apps/client/desktop/src-tauri/capabilities/node.json +++ b/apps/client/desktop/src-tauri/capabilities/node.json @@ -27,6 +27,7 @@ "allow-node-pending-screen", "allow-node-about", "allow-node-open-web", + "allow-node-open-display-helper", "allow-node-check-app-update", "allow-node-install-app-update" ] diff --git a/apps/client/desktop/src-tauri/permissions/desktop.toml b/apps/client/desktop/src-tauri/permissions/desktop.toml index c7ae64665..70e0ae675 100644 --- a/apps/client/desktop/src-tauri/permissions/desktop.toml +++ b/apps/client/desktop/src-tauri/permissions/desktop.toml @@ -120,6 +120,11 @@ identifier = "allow-node-open-web" description = "Open ONE of three fixed pages in the system browser: the product's site, the repository's LICENSE, or the copyright holder's site. A closed enum, never a URL, so the page names which of the three and this side decides what it is." commands.allow = ["node_open_web"] +[[permission]] +identifier = "allow-node-open-display-helper" +description = "Open the macOS display helper's own settings panel, which is a separate signed program's window and one of its two consent doors: the panel's Enable/Disable button and the helper's own status-menu toggle write consent, and neither is this app. The command opens a window and nothing else. Grants no permission and changes nothing on this machine: macOS asks for screen recording and accessibility itself, and the answer goes to the helper, not to this app. Takes no argument. On a platform with no helper the command refuses, and the Virtual Displays screen never renders there." +commands.allow = ["node_open_display_helper"] + [[permission]] identifier = "allow-node-check-app-update" description = "Ask the project's release list whether a newer Subshell Client APP exists, not the node agent it wraps. Takes no argument, downloads nothing and changes nothing: one GET of a public JSON list, and one manifest the updater plugin verifies against the public key compiled into this build. The bundled page only, like every node verb." diff --git a/apps/client/desktop/src-tauri/src/control.rs b/apps/client/desktop/src-tauri/src/control.rs index d81c92d23..c1223086b 100644 --- a/apps/client/desktop/src-tauri/src/control.rs +++ b/apps/client/desktop/src-tauri/src/control.rs @@ -1998,6 +1998,10 @@ pub enum WebTarget { /// it shut here. #[serde(rename = "macports")] MacPorts, + /// The guide that walks through enabling the macOS display helper, offered + /// on the Virtual Displays screen (2026-10-26). Kebab-case spells it + /// `display-guide`, which is exactly what the page sends. + DisplayGuide, } /// Where the tmux screen sends someone whose Mac has no package manager at @@ -2007,9 +2011,14 @@ pub enum WebTarget { /// installs the manager itself. const HOMEBREW_URL: &str = "https://brew.sh"; const MACPORTS_URL: &str = "https://www.macports.org/install.php"; +/// Where the Virtual Displays screen sends someone who wants the full +/// enablement walk-through, the two macOS permissions and the restart case +/// included. The same address `apps/server/desktop` carries for it. +const DISPLAY_GUIDE_URL: &str = "https://docs.subshell.sh/previews/macos"; /// Open one of a fixed set of pages in the system browser: the three About -/// links, and the two package managers the tmux screen names. +/// links, the two package managers the tmux screen names, and the display +/// helper's enablement guide. /// /// The bundle's CSP makes an ordinary `` open inside the webview, /// which is the wrong window for a website — and this app's other window is a @@ -2022,12 +2031,76 @@ pub fn node_open_web(app: AppHandle, target: WebTarget) -> Result<(), String> { WebTarget::Company => legal::COMPANY_URL, WebTarget::Homebrew => HOMEBREW_URL, WebTarget::MacPorts => MACPORTS_URL, + WebTarget::DisplayGuide => DISPLAY_GUIDE_URL, }; app.opener() .open_url(url, None::<&str>) .map_err(|e| format!("could not open {url}: {e}")) } +/// Open the macOS display helper's own settings panel, the same panel the +/// application menu's **Virtual Display Helper…** item opens. +/// +/// The command lives here, ungated, while `display_helper` is a macOS-only +/// module: `generate_handler!` takes a plain list, so a command that exists on +/// one platform only has to exist on all of them or the Linux build fails to +/// compile. The refusal is the honest answer there rather than a stub, and the +/// Virtual Displays screen is drawn only on macOS, so nothing on Linux sends +/// this. +/// +/// Nothing comes back on success: the panel belongs to a separate signed +/// program, and enabling the helper and granting macOS its two permissions +/// both happen outside this app. `open_settings` reports its own failures in a +/// native dialog, so the page has no message left to render. +#[tauri::command(async)] +pub fn node_open_display_helper(app: AppHandle) -> Result<(), String> { + if let Some(refusal) = display_helper_refusal() { + let _ = app; + return Err(refusal); + } + #[cfg(target_os = "macos")] + crate::display_helper::open_settings(&app); + Ok(()) +} + +/// Why this machine cannot open the helper, or `None` where it can. +/// +/// The words sit in their own function so the arm that answers off macOS is +/// testable: the command needs an `AppHandle`, and the suite runs with no +/// running app. Nothing on this platform's page sends the command (the Virtual +/// Displays section is dropped off macOS by the rail), so an older page or a +/// hand-written invoke is all that gets here, and a success would report a +/// panel that does not exist. +fn display_helper_refusal() -> Option { + #[cfg(target_os = "macos")] + { + None + } + #[cfg(not(target_os = "macos"))] + { + Some("Virtual displays need the macOS display helper, and this is not macOS.".to_string()) + } +} + +#[cfg(test)] +mod display_helper_tests { + use super::display_helper_refusal; + + #[test] + fn a_machine_with_no_helper_refuses_instead_of_reporting_a_panel_it_opened() { + // Each host tests its own arm; the other one is what CI's macOS lane + // and the Linux builder image cover. What must not drift is the shape: + // a Mac answers None and opens the panel, anywhere else answers words. + #[cfg(target_os = "macos")] + assert!(display_helper_refusal().is_none(), "macOS refused its own helper"); + #[cfg(not(target_os = "macos"))] + { + let reason = display_helper_refusal().expect("a machine with no helper answered nothing"); + assert!(reason.contains("macOS"), "{reason}"); + } + } +} + /// The screen this window was opened for, taken exactly once. /// /// A PULL, for the reason `apps/server/desktop`'s twin documents: a push diff --git a/apps/client/desktop/src-tauri/src/display_helper.rs b/apps/client/desktop/src-tauri/src/display_helper.rs index 3055d7da4..5cdf6899e 100644 --- a/apps/client/desktop/src-tauri/src/display_helper.rs +++ b/apps/client/desktop/src-tauri/src/display_helper.rs @@ -1,4 +1,13 @@ -//! This entry point is reachable only from the native application menu. +//! Open the bundled display helper's settings panel. +//! +//! Two doors reach it, and they are the same act: the native application menu's +//! **Virtual Display Helper…** item (`menu.rs`) and the assistant window's +//! Virtual Displays screen (`control::node_open_display_helper`). The second +//! exists because the first is easy to miss, and the control plane's node +//! settings point here by name. +//! +//! The panel itself belongs to the helper, a separate signed program, so +//! enabling it and granting macOS its permissions both happen outside this app. use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::DialogExt; diff --git a/apps/client/desktop/src-tauri/src/lib.rs b/apps/client/desktop/src-tauri/src/lib.rs index e1026e226..f0c0e17b1 100644 --- a/apps/client/desktop/src-tauri/src/lib.rs +++ b/apps/client/desktop/src-tauri/src/lib.rs @@ -179,6 +179,7 @@ pub fn run() { control::node_service, control::node_about, control::node_open_web, + control::node_open_display_helper, control::node_pending_screen, control::node_open_path, control::node_logs, diff --git a/apps/client/desktop/src-tauri/tests/display_helper_staging.rs b/apps/client/desktop/src-tauri/tests/display_helper_staging.rs new file mode 100644 index 000000000..2bc4080c4 --- /dev/null +++ b/apps/client/desktop/src-tauri/tests/display_helper_staging.rs @@ -0,0 +1,12 @@ +//! The staging script's own tests. +//! +//! `native/display-helper/build_support.rs` is included only by the two apps' +//! `build.rs`, and `cargo test` never builds build scripts as test targets, so +//! without this shim its content-gating checks (the fingerprint, and +//! write-only-if-different) would compile nothing and run nowhere. The +//! `build()` body reads `OUT_DIR` and shells out to `xcrun`, which is fine: it +//! is never called here, only the pure helpers are. +#![allow(dead_code)] + +#[path = "../../../../../native/display-helper/build_support.rs"] +mod build_support; diff --git a/apps/client/desktop/ui/src/__tests__/app.test.tsx b/apps/client/desktop/ui/src/__tests__/app.test.tsx index a14b422e5..919fef5e6 100644 --- a/apps/client/desktop/ui/src/__tests__/app.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/app.test.tsx @@ -256,12 +256,19 @@ describe("the assistant frame", () => { expect(buttonOrNull("Re-enroll…")).toBeNull(); expect(screen.queryByText("More…")).toBeNull(); const rail = screen.getByRole("navigation", { name: "Main" }); + // Virtual Displays rides the rail on macOS only: the helper it opens is a + // BetterDisplay thing, so a Linux node would get a section that opens + // nothing. The regex is read here rather than imported from `lib/copy` + // because this assertion is the evidence that the gate works, and reading + // the app's own memo would just repeat its answer. + const onMac = /Macintosh|Mac OS X/.test(navigator.userAgent); expect([...rail.querySelectorAll("button")].map((b) => b.textContent)).toEqual([ "Control Plane", "Status", "Service", "Update", "About", + ...(onMac ? ["Virtual Displays"] : []), "Reset", ]); // Over to Service, where the node's own acts live (plane-list ruling): diff --git a/apps/client/desktop/ui/src/__tests__/client-flow.test.ts b/apps/client/desktop/ui/src/__tests__/client-flow.test.ts index 5c59e6e2c..bb9cccbe6 100644 --- a/apps/client/desktop/ui/src/__tests__/client-flow.test.ts +++ b/apps/client/desktop/ui/src/__tests__/client-flow.test.ts @@ -294,22 +294,35 @@ describe("registerSteps", () => { * machine's journey, not about who asked. */ describe("railFor", () => { - it("answers the six standing sections for a settled machine on a standing screen", () => { + it("answers the seven standing sections for a settled Mac on a standing screen", () => { // Service and Control Plane joined by operator ruling 2026-09-22 (live // screenshots): the node's machinery and the plane address's home move // out of the status screen, and the rail carries them. - for (const screen of ["status", "service", "plane", "update", "about"] as const) { - const sections = railFor(screen, true); + for (const screen of ["status", "service", "plane", "update", "about", "display"] as const) { + const sections = railFor(screen, true, true); expect( sections?.map((s) => s.id), screen, - ).toEqual(["plane", "status", "service", "update", "about", "reset"]); + ).toEqual(["plane", "status", "service", "update", "about", "display", "reset"]); expect( sections?.map((s) => s.label), screen, - ).toEqual(["Control Plane", "Status", "Service", "Update", "About", "Reset"]); + ).toEqual(["Control Plane", "Status", "Service", "Update", "About", "Virtual Displays", "Reset"]); } - expect(railFor("status", true)?.find((s) => s.id === "reset")?.danger).toBe(true); + expect(railFor("status", true, true)?.find((s) => s.id === "reset")?.danger).toBe(true); + }); + + it("leaves Virtual Displays off the rail where there is no display helper", () => { + // The section is platform FACT, not vocabulary: BetterDisplay is macOS + // only, so a Linux node's rail would otherwise carry a door onto nothing. + // The explicit `false` is the point of the parameter: read off the user + // agent, this assertion could only ever run on the runner's own OS. + const sections = railFor("plane", true, false); + expect(sections?.map((s) => s.id)).toEqual(["plane", "status", "service", "update", "about", "reset"]); + expect(sections?.some((s) => s.id === "display")).toBe(false); + // Reset keeps the last slot either way: the destructive door is last on + // every platform, and nothing joins the rail after it. + expect(sections?.at(-1)?.id).toBe("reset"); }); it("answers null for every FTE walk screen, enroll and the unread state", () => { @@ -326,8 +339,11 @@ describe("railFor", () => { it("carries the Reset DOOR on every standing section, danger-styled", () => { // Reset is not a section (dialog ruling 2026-09-22): the rail's sixth // item is a door that opens the confirmation over whatever stands. - const sections = railFor("plane", true); - expect(sections?.map((s) => s.id)).toEqual(["plane", "status", "service", "update", "about", "reset"]); + // Pinned to a Mac explicitly: left to the default, this assertion would + // read the RUNNER's platform and mean two different things on the two CI + // lanes. + const sections = railFor("plane", true, true); + expect(sections?.map((s) => s.id)).toEqual(["plane", "status", "service", "update", "about", "display", "reset"]); expect(sections?.find((s) => s.id === "reset")?.danger).toBe(true); }); @@ -335,7 +351,7 @@ describe("railFor", () => { // The tray can raise About mid-walk, and the router honours it; wave 2's // ruling keeps the render and takes away the rail — the exclusion is // about the machine's journey, not about who asked. - for (const screen of ["status", "service", "plane", "update", "about"] as const) { + for (const screen of ["status", "service", "plane", "update", "about", "display"] as const) { expect(railFor(screen, false), screen).toBeNull(); } }); @@ -346,6 +362,7 @@ describe("railFor", () => { expect(railActive("plane")).toBe("plane"); expect(railActive("update")).toBe("update"); expect(railActive("about")).toBe("about"); + expect(railActive("display")).toBe("display"); // Reset activates NOTHING (dialog ruling 2026-09-22): it is a door in // the rail, not a section, and the standing screen keeps the highlight // while the confirmation is up. diff --git a/apps/client/desktop/ui/src/__tests__/display-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/display-screen.test.tsx new file mode 100644 index 000000000..7fdf1c9b7 --- /dev/null +++ b/apps/client/desktop/ui/src/__tests__/display-screen.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { DisplayScreen } from "@/components/assistant/display-screen"; +import { installFakeIpc, renderApp } from "./harness"; + +// One document per bun test process: a render left mounted is DOM the next +// file in the shard reads as its own (`harness.tsx`). +afterEach(cleanup); + +const shell = { + title: "Virtual Displays", + subtitle: "A virtual display gives a subshell a screen for graphical apps without needing another physical monitor.", +}; + +describe("the Virtual Displays screen", () => { + /** + * The wire name, pinned where it is pressed. `node_open_display_helper` + * lives in three files (this call, `permissions/desktop.toml`, + * `capabilities/node.json`) and a rename in one of them is a runtime + * permission refusal in the window rather than a compile error. + */ + it("opens the native helper panel on the one press", () => { + const fake = installFakeIpc({ handlers: { node_open_display_helper: () => null } }); + try { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Open Display Helper settings" })); + expect(fake.callsTo("node_open_display_helper")).toHaveLength(1); + } finally { + fake.restore(); + } + }); + + /** + * A refusal arrives from macOS (no helper shipped in this bundle, say) and + * Rust answers it in a native dialog, so this screen has no message of its + * own. What a later edit could break is the screen itself: a rejection that + * unwound through the click handler would leave a blank window on the one + * platform where the screen exists. + */ + it("keeps its instructions when the panel cannot be opened", () => { + const fake = installFakeIpc({ + handlers: { + node_open_display_helper: () => { + throw new Error("display helper is not bundled"); + }, + }, + }); + try { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "Open Display Helper settings" })); + expect(screen.getByRole("button", { name: "Open Display Helper settings" })).toBeTruthy(); + // All three steps are still there to read. + expect(screen.getAllByRole("listitem")).toHaveLength(3); + } finally { + fake.restore(); + } + }); + + /** + * The steps must survive as an ORDERED list, not a paragraph. They are the + * reason the screen exists: the menu bar item opened a panel and said + * nothing about the two macOS permissions it would then ask for. + */ + it("puts the instructions in order, so the reader can tell which step is next", () => { + const fake = installFakeIpc({ handlers: { node_open_display_helper: () => null } }); + try { + renderApp(); + const steps = screen.getAllByRole("listitem").map((li) => li.textContent ?? ""); + expect(steps.some((s) => s.includes("Screen Recording"))).toBe(true); + // Enabling comes before granting, because the helper has to be running + // before macOS has anything to ask about. + expect(steps[0]).toContain("Enable helper"); + } finally { + fake.restore(); + } + }); + + /** + * The guide's word, pinned where it is pressed. `wire-names.test.ts` holds + * the TS union and Rust's enum equal, but only this call carries the fact + * that THIS screen sends that member: a drifted string is a runtime refusal + * no type check sees. + */ + it("names the display guide, so the system browser opens the enablement page", () => { + const fake = installFakeIpc({ handlers: { node_open_display_helper: () => null, node_open_web: () => null } }); + try { + renderApp(); + fireEvent.click(screen.getByRole("button", { name: "How to enable the display helper" })); + expect(fake.callsTo("node_open_web")).toEqual([{ target: "display-guide" }]); + } finally { + fake.restore(); + } + }); +}); diff --git a/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts b/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts index a9e1fbe64..9b582d70e 100644 --- a/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts +++ b/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts @@ -1,23 +1,29 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { COLOR_ROLES } from "../../../../../../scripts/design-tokens"; /** - * This stylesheet's palette is "COPIED VERBATIM from apps/server/web/src/styles.css" - * — its own header says so, and until 2026-09-14 nothing checked it. Mobile - * made the same claim and drifted a whole palette behind it (spec - * 2026-09-14 § 1). A copy held together by a comment is a copy that has - * already started to differ; this is the test that comment should have been. + * Until 2026-10-27 this stylesheet's palette was "COPIED VERBATIM from + * apps/server/web/src/styles.css", its own header said so, and nothing checked + * it until mobile made the same claim and drifted a whole palette behind it + * (spec 2026-09-14 § 1). The colours now live in one file, + * `packages/assistant/tokens.css`, which every surface imports, so "verbatim" + * is structural rather than a promise. What is left to check is that both + * surfaces really do import it, that the shared file carries the roles, and + * that each surface's own block still matches the other's. * - * Compared: every `--name: value;` declaration in the `:root, .dark` block and + * Compared per block: every `--name: value;` declaration in `:root, .dark` and * every one in `@theme inline`, EXCEPT the terminal trio (`--terminal-*` and - * their `--color-terminal-*` aliases) — this page has no terminal and does not - * carry them. Comments and blank lines are not compared: the two files are - * free to explain themselves differently. + * their `--color-terminal-*` aliases), which this page has no terminal for. + * Comments and blank lines are not compared; the two files may explain + * themselves differently. */ const HERE = import.meta.dir; const CLIENT = join(HERE, "../styles.css"); const SPA = join(HERE, "../../../../../server/web/src/styles.css"); +// HERE is ui/src/__tests__: five levels up is apps/, six is the repo root. +const SHARED = join(HERE, "../../../../../../packages/assistant/tokens.css"); /** The declarations of one named block, normalised to `--name: value`. */ function declarations(css: string, opener: RegExp): string[] { @@ -34,6 +40,40 @@ function declarations(css: string, opener: RegExp): string[] { describe("the client's tokens are the SPA's", () => { const client = readFileSync(CLIENT, "utf8"); const spa = readFileSync(SPA, "utf8"); + const shared = readFileSync(SHARED, "utf8"); + + test("both surfaces import the one palette rather than restating it", () => { + expect(spa).toContain('@import "../../../../packages/assistant/tokens.css";'); + expect(client).toContain('@import "../../../../../packages/assistant/tokens.css";'); + }); + + test("the shared file declares every colour role the system requires", () => { + const declared = declarations(shared, /^:root \{/m); + for (const role of COLOR_ROLES) { + expect( + declared.some((d) => d.startsWith(`--${role}:`)), + `--${role} missing from ${SHARED}`, + ).toBe(true); + } + }); + + test("neither surface shadows a shared colour with its own value", () => { + // A local restatement wins on cascade order, which is how a surface gets a + // private palette while importing everyone else's. + const sharedDecls = new Set(declarations(shared, /^:root \{/m)); + for (const [name, css] of [ + ["spa", spa], + ["client", client], + ] as const) { + for (const decl of declarations(css, /^:root,\n\.dark \{/m)) { + if (decl.includes("var(")) continue; + const role = decl.slice(2, decl.indexOf(":")).trim(); + if (COLOR_ROLES.includes(role as (typeof COLOR_ROLES)[number])) { + expect(sharedDecls.has(decl), `${name} re-declares --${role} locally`).toBe(true); + } + } + } + }); test(":root, .dark declares the same values, terminal trio aside", () => { expect(declarations(client, /^:root,\n\.dark \{/m)).toEqual(declarations(spa, /^:root,\n\.dark \{/m)); @@ -45,8 +85,10 @@ describe("the client's tokens are the SPA's", () => { test("and there is something to compare", () => { // A regex that stops matching would make both lists empty and the - // equality above vacuous. - expect(declarations(spa, /^:root,\n\.dark \{/m).length).toBeGreaterThan(20); + // equalities above vacuous. The colours left for the shared file, so the + // floor is the surface's own extras (button gradients, tints, aliases). + expect(declarations(spa, /^:root,\n\.dark \{/m).length).toBeGreaterThan(10); expect(declarations(spa, /^@theme inline \{/m).length).toBeGreaterThan(20); + expect(declarations(shared, /^:root \{/m).length).toBeGreaterThan(COLOR_ROLES.length - 1); }); }); diff --git a/apps/client/desktop/ui/src/app.tsx b/apps/client/desktop/ui/src/app.tsx index 786dde1ec..10561abf3 100644 --- a/apps/client/desktop/ui/src/app.tsx +++ b/apps/client/desktop/ui/src/app.tsx @@ -31,6 +31,7 @@ import { useEffect, useRef, useState } from "react"; import { AboutScreen } from "@/components/assistant/about-screen"; import { ChoiceScreen } from "@/components/assistant/choice-screen"; import { ConnectScreen } from "@/components/assistant/connect-screen"; +import { DisplayScreen } from "@/components/assistant/display-screen"; import { Frame, type FrameShell } from "@/components/assistant/frame"; import { PlaneScreen } from "@/components/assistant/plane-screen"; import { ProgressScreen } from "@/components/assistant/progress-screen"; @@ -602,6 +603,12 @@ export function App() { ); case "about": return setOverride(null)} />; + case "display": + // macOS only: the rail's Virtual Displays section is the only thing + // that sets this override, and `railFor` drops that section on every + // other platform. The screen's one press opens a native panel, so it + // needs no runner and reports no output. + return ; case "update": return ( } + barRight={ + + } + > +

+ The helper starts disabled when this app installs it. To let anyone view or control a virtual display, enable it + in its settings and grant both macOS permissions. +

+
    + {STEPS.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+

+ Requires macOS 14 or later. Virtual displays use your current Mac login and share its keyboard focus and + pointer. Viewers may see other windows on the display and can control this Mac, so enable the helper only for + people you trust. +

+

+ The helper runs as Subshell Display Helper and has its own display icon in + the macOS menu bar. Use that menu to open its settings or disable the helper at any time. explains + the setup, including what BetterDisplay needs. +

+ + ); +} + +/** + * The one link this screen offers, carrying the word the command knows. An + * ordinary `
` would open inside the webview under the bundle's CSP, so the + * press names a member of Rust's closed set and the SYSTEM browser opens it. + */ +function GuideLink(): ReactElement { + return ( + + ); +} diff --git a/apps/client/desktop/ui/src/components/assistant/subtitles.ts b/apps/client/desktop/ui/src/components/assistant/subtitles.ts index 82bd8a185..9861fc57c 100644 --- a/apps/client/desktop/ui/src/components/assistant/subtitles.ts +++ b/apps/client/desktop/ui/src/components/assistant/subtitles.ts @@ -112,5 +112,10 @@ export function subtitleFor( // is the table's answer and never a subtitle drawn before the probe and // the release check have landed. return "This app and the node CLI that ships inside it."; + case "display": + // Says what the thing IS rather than what pressing the button does, + // because the screen's one press opens a panel this app does not own: + // the steps below the sentence are the part with the choices in it. + return "A virtual display gives a subshell a screen for graphical apps without needing another physical monitor."; } } diff --git a/apps/client/desktop/ui/src/lib/client-flow.ts b/apps/client/desktop/ui/src/lib/client-flow.ts index 944155752..6836aebb1 100644 --- a/apps/client/desktop/ui/src/lib/client-flow.ts +++ b/apps/client/desktop/ui/src/lib/client-flow.ts @@ -27,6 +27,7 @@ * cheap. */ import type { RailSection } from "@internal/assistant"; +import { isMacos } from "@/lib/copy"; import type { NodeSettings, Probe } from "@/lib/ipc"; import type { NodeScreenId, NodeUserScreen } from "@/lib/node-assistant-state"; @@ -334,9 +335,20 @@ export const CLIENT_RAIL_SECTIONS: RailSection[] = [ { id: "service", label: "Service" }, { id: "update", label: "Update" }, { id: "about", label: "About" }, + // Virtual Displays is the macOS-only section: the helper it opens is a + // BetterDisplay thing, so a Linux node has nothing for the screen to open. + // {@link CLIENT_RAIL_SECTIONS_OFF_MAC} is the same list without it, and + // {@link railFor} picks between the two by platform. + { id: "display", label: "Virtual Displays" }, { id: "reset", label: "Reset", danger: true }, ]; +/** + * The rail on a platform with no display helper, computed once so the two + * lists are stable identities rather than a filter run per render. + */ +const CLIENT_RAIL_SECTIONS_OFF_MAC: RailSection[] = CLIENT_RAIL_SECTIONS.filter((s) => s.id !== "display"); + /** * Whether THIS screen gets the rail, and which sections it shows (wave 3; * the same rule the server's `railFor` carries — spec 2026-09-21, with the @@ -355,16 +367,25 @@ export const CLIENT_RAIL_SECTIONS: RailSection[] = [ * The active section is not folded in here — a `RailSection` is * `{id, label}` by design — so {@link railActive} answers it, keyed on the * same screen. + * + * `macos` decides which of the two lists comes back, because one section is + * platform fact rather than vocabulary: Virtual Displays opens the macOS + * display helper, and a Linux node would get a door onto nothing. It is a + * PARAMETER rather than a read of the user agent so both branches are + * testable on whichever platform the test process happens to run on + * (`copy.ts` explains why a module-level const cannot answer that). */ -export function railFor(screen: NodeScreenId | null, settled: boolean): RailSection[] | null { +export function railFor(screen: NodeScreenId | null, settled: boolean, macos = isMacos()): RailSection[] | null { if (!settled) return null; + const sections = macos ? CLIENT_RAIL_SECTIONS : CLIENT_RAIL_SECTIONS_OFF_MAC; switch (screen) { case "status": case "service": case "plane": case "update": case "about": - return CLIENT_RAIL_SECTIONS; + case "display": + return sections; default: return null; } @@ -383,6 +404,8 @@ export function railActive(screen: NodeScreenId | null): string | null { return "update"; case "about": return "about"; + case "display": + return "display"; default: return null; } diff --git a/apps/client/desktop/ui/src/lib/ipc.ts b/apps/client/desktop/ui/src/lib/ipc.ts index 90d1f631e..4ccc2b92f 100644 --- a/apps/client/desktop/ui/src/lib/ipc.ts +++ b/apps/client/desktop/ui/src/lib/ipc.ts @@ -567,7 +567,7 @@ export function nodeReset(args: { typed: string }): Promise { * for is a runtime `unknown variant` refusal in the window, which no type * check can see. */ -export type WebTarget = "website" | "license" | "company" | "homebrew" | "macports"; +export type WebTarget = "website" | "license" | "company" | "homebrew" | "macports" | "display-guide"; /** * The event `node_install_tmux` streams the package manager's output on, one @@ -627,6 +627,20 @@ export function nodeOpenWeb(target: WebTarget): Promise { return invoke("node_open_web", { target }); } +/** + * Open the native macOS display helper's own settings panel. + * + * Nothing comes back, because nothing about this machine has changed yet: the + * panel is a separate signed program's window, and whether the helper ends up + * enabled, and whether macOS grants screen recording and accessibility, are + * answers the person gives OUTSIDE this app. A rejection means the panel could + * not be opened at all, and Rust already puts that in a native dialog, so the + * screen has nothing further to say. + */ +export function nodeOpenDisplayHelper(): Promise { + return invoke("node_open_display_helper"); +} + /** * What an app-update check found. `AppUpdateCheck` in `app_update.rs`. * diff --git a/apps/client/desktop/ui/src/lib/node-assistant-state.ts b/apps/client/desktop/ui/src/lib/node-assistant-state.ts index 588812115..aeb3a5d08 100644 --- a/apps/client/desktop/ui/src/lib/node-assistant-state.ts +++ b/apps/client/desktop/ui/src/lib/node-assistant-state.ts @@ -50,7 +50,8 @@ export type NodeScreenId = | "plane" | "connect" | "about" - | "update"; + | "update" + | "display"; /** * The runtime list beside the type, for whatever has to iterate the set — @@ -70,6 +71,7 @@ export const NODE_SCREEN_IDS: readonly NodeScreenId[] = [ "connect", "about", "update", + "display", ]; /** @@ -91,8 +93,12 @@ export const NODE_SCREEN_IDS: readonly NodeScreenId[] = [ * the machine can nevertheless RAISE: a marker left by an app update is a * consented act with a half still outstanding, and `app.tsx` opens this screen * once per launch when the probe reports one (spec 2026-09-18 § 4.2). + * + * `display` joined them on 2026-10-26 with the rail's Virtual Displays + * section. It asks nothing of the machine's own state and no probe implies + * it: the screen explains and opens a native panel that this app never owns. */ -export type NodeUserScreen = "about" | "update" | "service" | "plane" | "status"; +export type NodeUserScreen = "about" | "update" | "service" | "plane" | "status" | "display"; /** * ONE word for where you are, on both platforms (operator's call, 2026-09-12). @@ -175,6 +181,11 @@ export function screenTitle(screen: NodeScreenId): string { // differed by a possessive, for one thing a person experiences once. // The name became TRUE rather than being disambiguated. return "Update Subshell Client"; + case "display": + // The rail section's own words, and the words the control plane's node + // settings page puts on the same subject, so the two screens a person + // reads in sequence call the thing one thing. + return "Virtual Displays"; } } diff --git a/apps/client/desktop/ui/src/styles.css b/apps/client/desktop/ui/src/styles.css index e39d81db4..58ff0640c 100644 --- a/apps/client/desktop/ui/src/styles.css +++ b/apps/client/desktop/ui/src/styles.css @@ -6,6 +6,7 @@ nothing. */ @import "tailwindcss" source(none); @import "tw-animate-css"; +@import "../../../../../packages/assistant/tokens.css"; @source "./"; @source "../index.html"; @source "../../../../../packages/assistant/src"; @@ -16,73 +17,20 @@ * Subshell UI theme: dark only by design (no light variants exist). * Tokens follow the shadcn/ui convention (oklch values). * - * COPIED VERBATIM from apps/server/web/src/styles.css — this is the product - * palette ("Dreamframe", the Castorice palette, spec 2026-09-02) and the two - * surfaces are one product, so the values are not re-derived here. Copied - * rather than imported for the same reason the primitives under - * `components/ui/` are: extracting them into a shared package later should be a - * straight move, and until then a diff between these two blocks is the honest - * signal that they have drifted. Only the dockview theme import is dropped — - * this page has no workspace panes. + * The product palette is imported from packages/assistant/tokens.css. + * Keep local layout tokens here; shared colours belong in that file. */ :root, .dark { - /* Ground is the operator-approved slate-indigo #1d182a (oklch 0.224); the ramp - steps around it, hue 296° — indigo side of the family, calmer than the - 318° magenta first pass. */ - --background: oklch(0.224 0.035 296); - --foreground: oklch(0.92 0.03 312); - - --card: oklch(0.255 0.032 296); - --card-foreground: oklch(0.92 0.03 312); - - --popover: oklch(0.255 0.032 296); - --popover-foreground: oklch(0.92 0.03 312); - - --primary: oklch(0.75 0.17 322); - --primary-foreground: oklch(0.16 0.05 322); - - --secondary: oklch(0.29 0.034 296); - --secondary-foreground: oklch(0.9 0.025 312); - - --muted: oklch(0.29 0.034 296); - --muted-foreground: oklch(0.74 0.04 310); - - --accent: oklch(0.33 0.04 296); - --accent-foreground: oklch(0.95 0.02 315); - - --destructive: oklch(0.65 0.2 25); - --destructive-foreground: oklch(0.98 0 0); - - --border: oklch(0.33 0.035 296); - --input: oklch(0.33 0.035 296); - --ring: oklch(0.62 0.13 322); - - /* Status colors for anything outside Badge (dots, inline confirmations). - Seeded from ui/badge.tsx's success/warning variant values (Tailwind - emerald-400 / amber-400) so chips and dots finally share one palette. */ - --success: oklch(0.765 0.177 163.223); - --warning: oklch(0.828 0.189 84.429); - - /* The modal scrim, behind a dialog or sheet. Hex with alpha rather than a - role colour: it is BLACK at 70%, not a tint of the palette, and every - surface draws the same one — mobile's `colors.scrim` is this value, and - `lint:design` compares them. It was `bg-black/70` written twice at the - callsites, which is the one colour literal Tailwind's own utility made - invisible to the scanner (it sees hex and oklch, not colour WORDS). */ - --scrim: #000000b3; - - /* Emphasis surfaces the role palette has no name for: the sunk-plum - primary-button gradient, the sidebar's active-nav gradient and the - "view + edit" share chip. Lifted out of component literals 2026-09-14, - so every value named here lives in a token. Not the whole product yet: - some badges still use named Tailwind palette classes (emerald/amber) — + /* The palette, and why each value is what it is, lives in packages/assistant/tokens.css. */ + + /* Emphasis surfaces the role palette has no name for: the sidebar's + active-nav gradient and the "view + edit" share chip. Lifted out of + component literals 2026-09-14, so every value named here lives in a + token. The primary button's gradient is the shared palette's, declared in + packages/assistant/tokens.css. Not the whole product yet: some badges + still use named Tailwind palette classes (emerald/amber) — a § 8 follow-on, not one home. */ - --button-primary-from: oklch(0.34 0.1 322); - --button-primary-to: oklch(0.4 0.1 340); - --button-primary-hover-from: oklch(0.4 0.11 322); - --button-primary-hover-to: oklch(0.46 0.11 340); - --button-primary-foreground: oklch(0.9 0.05 320); --nav-active-from: oklch(0.3 0.1 322); --nav-active-to: oklch(0.38 0.11 340); /* The rail's flat-grid machine plates, verbatim from the SPA's styles.css diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md index 394e674c0..7a1cbcd1c 100644 --- a/apps/docs/AGENTS.md +++ b/apps/docs/AGENTS.md @@ -7,7 +7,7 @@ It builds to a static export. Pages are MDX under `content/docs/`, with explicit ## Coverage and structure -The root sidebar order is `index`, `about`, `get-started`, `install`, `guides`, `presets`, `prompts`, `agents`, +The root sidebar order is `index`, `about`, `get-started`, `install`, `guides`, `previews`, `presets`, `prompts`, `agents`, `nodes`, `administration`, `networking`, `mcp`, `automation`, `concepts`, `reference`, `troubleshooting`, and `developers`. `lib/navigation.ts` defines this order and the AI index's section names. Every section has an index page. @@ -17,6 +17,12 @@ Document shipped behavior, including browser/PWA access from phones and tablets. Do not document the native mobile app, its setup, availability, development workflow, or roadmap. The product documentation excludes it. +Web previews & screens has its own section under `/previews`: shared prerequisites, +web apps, browser watching, Linux and macOS virtual displays, and agent workflows. +Keep platform installation, configuration, and usage together there; node management +and security pages link to those procedures. Preserve the legacy preview route and +anchors when moving content. + Presets and Reusable Prompts have their own sections. Keep prompt text, library-stack references, and copied launch defaults distinct. Dashboard injection types text without submitting it. MCP has its own section. Keep tool reference at `/mcp/tools`; link there from @@ -78,6 +84,10 @@ compiled with `remarkMdxMermaid` and rendered to static SVG by the provided `Mermaid` component. Include a `%%` description for accessibility and prose explaining the relationships. Follow the visual rules in `STYLE.md`. +Prefer landscape captures or focused dialog crops for preview guides. Keep text +and controls readable at the displayed width; avoid full-height phone screenshots +when they add empty space or shrink the interface. + ## SEO and AI exports Use unique descriptive titles and summaries, one rendered H1, logical headings, diff --git a/apps/docs/content/docs/automation/rest-api.mdx b/apps/docs/content/docs/automation/rest-api.mdx index dfcdafe3b..ede2ff231 100644 --- a/apps/docs/content/docs/automation/rest-api.mdx +++ b/apps/docs/content/docs/automation/rest-api.mdx @@ -95,7 +95,7 @@ connection disconnects its open previews without stopping the service. The returned `proxyBase` is a private access link: anyone holding it can use it while it's valid. Keep it out of logs and public links. -See [Web previews & screens](/guides/preview#add-a-service-started-elsewhere) for the UI flow. +See [Web previews & screens](/previews/apps#add-a-service-started-elsewhere) for the UI flow. ## Handle failures diff --git a/apps/docs/content/docs/concepts/architecture.mdx b/apps/docs/content/docs/concepts/architecture.mdx index d9ebc1380..9c6d3eee5 100644 --- a/apps/docs/content/docs/concepts/architecture.mdx +++ b/apps/docs/content/docs/concepts/architecture.mdx @@ -65,7 +65,7 @@ credentials. The server can still read forwarded website content and screen images. Preview links grant access to anyone who holds them while they're valid; see [Preview security](/concepts/security#preview-security). -For setup, follow [Web previews & screens](/guides/preview). For the implementation, +For setup, follow [Web previews & screens](/previews). For the implementation, read [Web preview architecture](/developers/web-preview-architecture) or [Virtual display architecture](/developers/virtual-display-architecture). diff --git a/apps/docs/content/docs/concepts/security.mdx b/apps/docs/content/docs/concepts/security.mdx index ad5ebdb22..ebd42fbad 100644 --- a/apps/docs/content/docs/concepts/security.mdx +++ b/apps/docs/content/docs/concepts/security.mdx @@ -67,6 +67,12 @@ Owners can share sessions with named users or **Everyone**, meaning all signed-i A view grant can expose credentials printed in terminal output. An edit grant lets the recipient send input to a real shell or agent, except in an SSH subshell. SSH input requires its owner’s browser session; edit grantees and bearer credentials cannot type into it. Sharing also exposes attached viewer information, such as device labels and whether a viewer can type. These permissions don't create an isolated copy of the process. +Changing a subshell's shares closes its non-owner terminal connections, including +connections still attaching. Reconnecting checks the current effective grant: +users who no longer have access can't view the terminal, and users with only +**View** access can't send input. +This doesn't stop the running shell or undo commands already accepted. + 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 @@ -88,17 +94,34 @@ 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 proxy can rewrite app HTML, styles, and module imports, and inject a +compatibility script to keep common app requests under the private preview URL. +This changes the responses your browser receives. Integrity hashes on rewritten +local script and stylesheet references no longer apply and are removed; unrelated +external resources keep their integrity checks. The server remains a trusted +intermediary. A target's Content Security Policy stays effective apart from its +framing restrictions and can still block the compatibility script. + +[Personal preview settings](/previews/apps#configure-an-app-preview) control URL +rewriting, redirects, and which Host header the app receives. They're saved for +you by node, host, protocol, and port, and apply when a fresh preview opens. +They don't relax the sandbox, grant access to another target, disable HTTPS +certificate checks, or make every app compatible. Text rewriting and +decompression have size and concurrency limits; these aren't general +protection against denial-of-service attacks. + #### Link lifetime and revocation 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. Turning off **Allow web previews** or **Allow -virtual displays** blocks new operations for that feature without immediately -closing existing connections. To revoke access, remove the sharing grant or -end the subshell. +without traffic; losing the required sharing permission, account disable, +subshell death, and node loss also cut off affected access. Losing edit access +closes app forwarding and virtual-display access. Turning off **Allow web +previews** or **Allow virtual displays** blocks new operations for that feature +without immediately closing existing connections. To revoke access, remove the +grants that provide it or end the subshell. #### Transport and network reach @@ -117,7 +140,7 @@ refuses main, built-in, and mirrored screens, but this isn't isolation from the rest of the account. Notifications or other windows on that virtual screen may be visible. Its native **Disable helper** control stops all capture and input; the dashboard and MCP cannot turn it back on. See -[helper setup and disable instructions](/nodes/manage#macos-display-helper). +[helper setup and disable instructions](/previews/macos). #### VNC and local users @@ -131,7 +154,7 @@ execution-account permissions. Don't expose the VNC port through a network port forward. A copied preview link also grants control until it expires or is revoked; view-only sharing doesn't offer a read-only VNC viewer. -Follow [Web previews & screens](/guides/preview) for setup and the +Follow [Web previews & screens](/previews) for setup and the [preview architecture](/concepts/architecture#web-previews-and-virtual-displays) for the data path. ### Machine credentials @@ -160,7 +183,9 @@ Use HTTPS for browser access across a network. The browser-to-server HTTP and We WebSocket attachment tokens expire after 30 seconds and can be redeemed once. Bearer-minted tokens are bound to one session; a pane token can mint one only for its own session. The dashboard feed refuses these session-bound tokens. Same-host attachment can also authenticate with the browser session cookie, so not every WebSocket connection uses an attachment token. -Already-connected WebSockets aren't continuously re-authenticated. Account disabling explicitly disconnects them; password resets don't. Don't assume changing a credential disconnects every live connection. +Already-connected WebSockets aren't continuously re-authenticated. Account disabling explicitly disconnects them, and subshell sharing changes close non-owner terminal connections so their grants are checked again. Password resets don't close already-connected WebSockets. Don't assume changing a credential disconnects every live connection. + +Terminal WebSocket input has per-message, per-connection, and instance-wide limits. Oversized or excessive input closes the affected connection. Pending input is discarded when the viewer disconnects; already-dispatched work remains counted until it settles. These bounds limit retained input work, not the commands an authorized user can run or the resources those commands can consume. For a public Cloudflare Tunnel, Subshell requires a Cloudflare Access gate and verifies signed Access assertions with the configured issuer and audience. The gate doesn't replace Subshell sign-in. Follow [Cloudflare Tunnel](/networking/cloudflare); publishing a hostname without the verified gate doesn't satisfy the trusted-network deployment model. @@ -228,6 +253,13 @@ Stopped server-host session logs age out after 30 days by default; remote nodes Pane bearer tokens and typed input can also appear in process arguments or tmux metadata. A local process with sufficient access can read them. Curated launch environments avoid forwarding server application secrets automatically, but they don't prevent agents from reading files available to their OS account. +Generated node-enrollment installers keep setup keys out of child-process +arguments, including during **Set up Subshell here** over SSH. This doesn't +protect them from root or the same OS account. Copied commands can remain in +shell history, and manually passing `--key` still exposes that argument to +process inspection. Treat setup commands as credentials; see +[Make the destination a node](/guides/ssh#make-the-destination-a-node). + Browser push notifications pass through the browser's push service and can display a session name on a lock screen. Notifications are owner-targeted; sharing a session doesn't notify every grantee. Agent-provider traffic is separate from Subshell: an agent may send project content to its configured provider. Self-hosting Subshell doesn't make that provider interaction local. ## Backup and restore protection @@ -250,6 +282,11 @@ Administrator-requested agent installers run vendor installation commands under Subshell's update paths verify a publisher-signed release manifest and the downloaded artifact's SHA-256 digest. Nodes verify updates independently rather than accepting the server's assurance alone. This protects against an update host replacing an artifact with unsigned code; it doesn't protect against compromise of the publisher's signing key or replay of an older signed release. +The desktop apps also verify Display Helper's bundle identity and, in release +builds, its publisher signature before using an installed helper, not only when +installing a new copy. Signature verification doesn't replace the local consent +and macOS permissions described above. + First-install shell scripts have a different trust boundary: their integrity checks use digests served by the same release source, so initial installation still relies on HTTPS and repository trust. Don't assume update verification makes every installation method identical. See [Update the server](/administration/updates). ## Audit and incident response diff --git a/apps/docs/content/docs/developers/betterdisplay-protocol.mdx b/apps/docs/content/docs/developers/betterdisplay-protocol.mdx index 05c4acdf8..c537ab90e 100644 --- a/apps/docs/content/docs/developers/betterdisplay-protocol.mdx +++ b/apps/docs/content/docs/developers/betterdisplay-protocol.mdx @@ -12,7 +12,7 @@ for the noVNC viewer. The local helper protocol is internal and versioned independently from the server/node wire protocol. It isn't a third-party plugin API. For setup, follow -[Manage nodes](/nodes/manage#macos-display-helper). For the wider data path and +[macOS virtual displays](/previews/macos). For the wider data path and trust boundaries, see [Architecture](/concepts/architecture#web-previews-and-virtual-displays) and [Security](/concepts/security#preview-security). diff --git a/apps/docs/content/docs/developers/virtual-display-provider.mdx b/apps/docs/content/docs/developers/virtual-display-provider.mdx index 6cbd347d3..4ff41bf4e 100644 --- a/apps/docs/content/docs/developers/virtual-display-provider.mdx +++ b/apps/docs/content/docs/developers/virtual-display-provider.mdx @@ -14,7 +14,7 @@ for the contract and trust boundaries. This example uses the checkout's internal interfaces, not a published provider package or versioned plugin SDK. Run the example on Linux with `Xvfb`, `x11vnc`, and `xauth` available on PATH. -Follow [Linux display setup](/guides/preview#use-a-virtual-display-on-linux) +Follow [Linux display setup](/previews/linux) for installation. The tests create real temporary displays and loopback VNC listeners; use a development machine without untrusted local OS users. They don't require a running Subshell server or an account, and don't use port 3080. diff --git a/apps/docs/content/docs/developers/web-preview-architecture.mdx b/apps/docs/content/docs/developers/web-preview-architecture.mdx index 8ce6a5a1b..774cc1957 100644 --- a/apps/docs/content/docs/developers/web-preview-architecture.mdx +++ b/apps/docs/content/docs/developers/web-preview-architecture.mdx @@ -99,12 +99,19 @@ the target directly. The proxy: - Strips dashboard cookies, HTTP authorization, API keys, and recognized reverse-proxy identity headers before forwarding. -- Sets the target Host header, streams request and response bodies, and - doesn't retry a partially written request. +- Uses the target Host header by default; **Pass the preview host** opts into + forwarding the browser's host. It doesn't retry a partially written request. +- Streams request bodies and untransformed responses. URL rewriting buffers + supported HTML, CSS, and JavaScript responses within a 16 MiB decoded limit. - Retains target cookies in a bounded server-side session jar rather than installing them on the dashboard origin. -- Prefixes root-relative redirects with the capability path. It doesn't - rewrite HTML, JavaScript, or asset URLs. +- With **Rewrite app URLs** on, rewrites supported HTML URL attributes, CSS URLs + and imports, and static or literal dynamic module imports into the capability + path. An injected browser script handles common fetch, XMLHttpRequest, + WebSocket, EventSource, and DOM URL assignments. External destinations stay + external. +- With **Rewrite redirects** on, maps redirects to the target app into the + capability path without following them server-side. - Applies a Content Security Policy (CSP) sandbox without `allow-same-origin`, plus `no-store` and `no-referrer`. Embedded previews also have an iframe sandbox. - Removes target headers that could change dashboard-origin state or policy, @@ -115,6 +122,18 @@ authenticated API responses. Preview-specific CORS allows module loading and fetches under the capability path, including opaque `Origin: null` requests. That exception doesn't apply to dashboard API or WebSocket origin checks. +Proxy settings are personal and persist by authenticated user, node, normalized +host, protocol, and port, with a cap of 256 saved targets per user. Defaults are +URL rewriting on, preview-host forwarding off, and redirect rewriting on. A new +session snapshots them; changing settings doesn't mutate an existing session. +The settings endpoint uses the same execute/edit and enabled-node gates as an +app open, and it cannot authorize a destination. Settings store no capability +URLs. See [Configure an app preview](/previews/apps#configure-an-app-preview). + +Rewriting covers common app patterns rather than arbitrary JavaScript. A target +CSP can block the injected script; Subshell doesn't relax that policy to make +rewriting work. Opaque-origin storage and service-worker restrictions still apply. + The cookie jar isn't a full browser cookie implementation: it keys by cookie name. Applications that require service workers, browser storage, JavaScript-managed cookies, or a normal website login context should run in diff --git a/apps/docs/content/docs/developers/web-preview-development.mdx b/apps/docs/content/docs/developers/web-preview-development.mdx index d1b1c1f65..8440ccb10 100644 --- a/apps/docs/content/docs/developers/web-preview-development.mdx +++ b/apps/docs/content/docs/developers/web-preview-development.mdx @@ -21,8 +21,10 @@ application security template or a replacement for Subshell's preview gates. Create an empty directory on the development node and save these files there. The page uses relative paths so its HTTP and WebSocket requests stay under the -preview capability prefix. A root-relative `/api/message` URL would instead -point at the Subshell server's API namespace. +preview capability prefix even when **Rewrite app URLs** is off. With that +setting on by default, Subshell also rewrites supported root-relative URLs and +common browser requests. Keep explicit relative paths in this example so its +behavior doesn't depend on the compatibility script running. ```ts title="server.ts" const page = ` @@ -152,7 +154,7 @@ Keep the capability URL private. Don't attach it or authorization headers to a bug report. Report the failure category and relevant sanitized logs instead. For a target that requires storage, service workers, or normal website login, use the node's browser with a virtual display rather than removing preview -isolation. See [Web previews & screens](/guides/preview). +isolation. See [Web previews & screens](/previews). ## Change the owning layer diff --git a/apps/docs/content/docs/guides/index.mdx b/apps/docs/content/docs/guides/index.mdx index 126490a73..66a4b800e 100644 --- a/apps/docs/content/docs/guides/index.mdx +++ b/apps/docs/content/docs/guides/index.mdx @@ -9,6 +9,10 @@ Start with [Find your way around](/guides/navigation) and [Launch a subshell](/g Use [Connect over SSH](/guides/ssh) to open a terminal on another machine through one of your nodes. +Use [Web previews & screens](/previews) to check websites and graphical apps beside +your agent. Its setup guides cover web previews, browser watching, and virtual +displays on Linux and macOS. + ## Organize work [Workspaces](/guides/workspaces) arrange existing subshells in a layout. [Presets](/presets) save agent settings and optional launch defaults. [Reusable Prompts](/prompts) store reusable task text that you can inject into a live session. diff --git a/apps/docs/content/docs/guides/meta.json b/apps/docs/content/docs/guides/meta.json index 9e39f1310..6d632f517 100644 --- a/apps/docs/content/docs/guides/meta.json +++ b/apps/docs/content/docs/guides/meta.json @@ -5,7 +5,6 @@ "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 deleted file mode 100644 index 142fc89e1..000000000 --- a/apps/docs/content/docs/guides/preview.mdx +++ /dev/null @@ -1,254 +0,0 @@ ---- -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. - -## Choose a view - -| View | Use it to | -| --- | --- | -| **Web preview** | Check a website by interacting with it in your browser. | -| **Browser** | Watch a browser on the node without controlling it. | -| **Virtual display** | Control a browser or another graphical app on the node. | - -The node running the subshell determines which features are available. You can -view them from a browser on another device or from Subshell Client. - -## Enable web previews and displays on the machine - -In the dashboard sidebar, open **Settings** → **Nodes**, select the machine -running your subshell, and open **Overview**. 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 - -If your subshell runs on a Linux node, follow the Linux steps below. Install -any display programs on that node; the Mac you're viewing from doesn't need them. - -If the subshell runs on the Mac itself, use **Add service** for a web preview, -since automatic port and browser discovery require Linux. For graphical apps, -follow [Use a virtual display on macOS](#use-a-virtual-display-on-macos). - -## 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. 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 - -Use a saved service when your web server runs outside the subshell or its port -isn't discovered automatically. You don't need a virtual display. - -1. Use the Web previews & screens icon in the subshell header, 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. - -On a phone, previews use the workspace's tab strip without changing your desktop -split layout. From the standalone subshell page, the header icon takes you into -the workspace and opens the picker there. - -## 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. - -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). - -1. Install **Xvfb**, **x11vnc**, and **xauth** on the Linux machine running the - subshell. -2. In the dashboard, open **Settings** → **Nodes**, select that machine, and - open **Overview**. Turn on **Allow virtual displays**, enable **Allow Xvfb + - VNC**, and select it as the **Default display provider**. -3. 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. - After installing missing programs, choose **Recheck**. -4. 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. -5. 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. - -You can [save the display choice in a preset](/presets/launch-defaults#save-a-virtual-display-choice). -Review it when changing nodes: if the new node can't provide the display, the -launch form turns the option off and explains why. A pending or failed -requirements check blocks launch until it can be checked or you turn the option off. - -## Use a virtual display on macOS - -Choose the **BetterDisplay** provider after following -[macOS display helper setup](/nodes/manage#macos-display-helper) on the node's -Mac. Both Subshell desktop apps include the helper, which stays disabled until -you enable it in its native settings panel and grant macOS permissions. - -Unlike a private Linux display, a macOS virtual screen shares your login -session, keyboard focus, and pointer. Apps don't automatically open on it; -move their windows onto the screen before using its viewer. The helper's -**Disable helper** control stops capture and input without closing terminals. - -You don't need to configure BetterDisplay as a VNC server or expose a VNC port. -Subshell handles the connection. Its local VNC listener has no separate password; -don't enable virtual displays on a Mac 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 example below uses a Linux node. A configured macOS node -can use `betterdisplay` instead; MCP can check its requirements but cannot -enable the helper or grant macOS permissions. 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. - -If the app rejects the preview's host or origin, check the app's allowed-host -and origin settings. Subshell doesn't bypass the app's access 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. - -For implementation details, read [Web preview architecture](/developers/web-preview-architecture) and [Develop and extend web previews](/developers/web-preview-development). diff --git a/apps/docs/content/docs/guides/sharing.mdx b/apps/docs/content/docs/guides/sharing.mdx index 461203c8e..0146ae1d7 100644 --- a/apps/docs/content/docs/guides/sharing.mdx +++ b/apps/docs/content/docs/guides/sharing.mdx @@ -17,7 +17,7 @@ Only the owner can change sharing. The recipient needs an account on the same in 4. Review the listed recipients and their access levels, then select **Save**.
- +
Select an access level for each recipient, then save the grants. Select the image to enlarge it.
@@ -40,7 +40,7 @@ Attached viewers can see other viewers' device names and terminal dimensions. No ## Revoke access -Remove the relevant grants in the sharing dialog. An agent's pane token can list shared sessions, but it can't inspect or change another user's session through that grant. +Remove the relevant grants in the sharing dialog. Saving changes reconnects recipients' open terminals and checks their current access. A removed recipient can no longer read or type; someone changed from Edit to View reconnects with a read-only terminal. Your own terminal stays connected. Commands already sent may finish. An agent's pane token can list shared sessions, but it can't inspect or change another user's session through that grant. ## Next steps diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx index 3494b1503..638efb0ef 100644 --- a/apps/docs/content/docs/guides/ssh.mdx +++ b/apps/docs/content/docs/guides/ssh.mdx @@ -174,6 +174,8 @@ The **SSH connection** tooltip shows the declared forwards, not a live check of As the SSH subshell's owner, open its **⋯** menu and choose **Set up Subshell here…** when available. Review the confirmation before running the installer: it installs Subshell on the destination and enrolls it as a node you own. Your SSH subshell keeps running. +Update the server and connecting node together before using this action. The installer keeps its setup key out of process arguments. If it reports that the downloaded node cannot accept a setup key privately, update the server’s node download, check the destination, and run **Set up Subshell here…** again. + A subshell opened with a one-time key cannot reuse that key for this installer. Run the node setup command in its existing terminal, or reconnect using a node's keys first. This requires node enrollment to be allowed and an SSH login that works without interactive prompts. Password-only login can't supply a stored password to the installer. You can close and reopen the dialog to check progress. Results remain available for one hour while the server keeps running. Progress isn't retained across a server restart; check **Nodes** and the destination before retrying an interrupted installation. If it reports enrollment without a confirmed connection, open the new node's page and check its service before trying another installation. diff --git a/apps/docs/content/docs/guides/workspaces.mdx b/apps/docs/content/docs/guides/workspaces.mdx index b88863bd5..5b48e8ffd 100644 --- a/apps/docs/content/docs/guides/workspaces.mdx +++ b/apps/docs/content/docs/guides/workspaces.mdx @@ -20,7 +20,7 @@ The sidebar’s **Workspaces** selector switches between workspaces and offers * 3. On a subshell’s own page, **Add a subshell to a workspace** opens a workspace with both subshells as tabs.
- +
Add an existing subshell or launch a new one into the current workspace. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 173e288c3..14f00c4ba 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -17,6 +17,7 @@ Follow [Get started](/get-started) to install Subshell Server, create your accou - [Installation](/install) covers desktop, CLI, Docker, and Proxmox deployment. - [Use Subshell](/guides) covers sessions, workspaces, sharing, and notifications. +- [Web previews & screens](/previews) covers app previews, browser watching, and virtual display setup. - [Presets](/presets) save agent configurations and launch defaults. - [Reusable Prompts](/prompts) save task instructions and ordered stacks. - [Manage nodes](/nodes) explains how to run agents on other machines. diff --git a/apps/docs/content/docs/mcp/tools.mdx b/apps/docs/content/docs/mcp/tools.mdx index bcd93a5b9..9cc9a5d8a 100644 --- a/apps/docs/content/docs/mcp/tools.mdx +++ b/apps/docs/content/docs/mcp/tools.mdx @@ -80,7 +80,7 @@ For BetterDisplay on macOS, `enabled` describes node policy, not local helper consent. `missing-programs` can include a disabled helper or missing macOS permissions as well as missing software; read the `missing` list. MCP cannot enable the helper or grant those permissions. Follow -[macOS helper setup](/nodes/manage#macos-display-helper) on that node. +[macOS helper setup](/previews/macos) on that node. 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. @@ -94,7 +94,7 @@ For an app, pass `{ "subshell_id": "SUBSHELL_ID", "kind": "app", "port": 3000 }` 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). +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](/previews/apps#browser-compatibility-and-private-links). ## watch_subshell_browser diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 1ca0fd23d..b2ca622ff 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -5,6 +5,7 @@ "get-started", "install", "guides", + "previews", "presets", "prompts", "agents", diff --git a/apps/docs/content/docs/nodes/desktop.mdx b/apps/docs/content/docs/nodes/desktop.mdx index beda1a2fe..eb88eb05f 100644 --- a/apps/docs/content/docs/nodes/desktop.mdx +++ b/apps/docs/content/docs/nodes/desktop.mdx @@ -10,7 +10,7 @@ Make your desktop machine a node through Subshell Client. Follow [Install Subshell Client](/install/desktop-client), then open **Settings** → **Nodes** in the server dashboard's sidebar and select **Add node** at the top of the page. Choose a reachable address, generate a setup key, and select **Desktop App** to find the enrollment values.
- +
Select Desktop App in Add node to find the values needed for enrollment. The address is an example; no setup key has been generated. Select the image to enlarge it.
@@ -34,6 +34,14 @@ Install coding agent CLIs under the node's OS account. Set [Directory restrictio Agents run on the node. The server stores your instance settings and serves the dashboard. +## Run graphical apps on this machine + +Follow [Preview prerequisites and setup](/previews/setup) to enable web previews +or virtual displays on the enrolled node. For a Mac, follow +[macOS virtual displays](/previews/macos) to open **Virtual Displays** in the +local node window, enable the helper, and grant macOS permissions. For a Linux +machine, follow [Linux virtual displays](/previews/linux). + ## Re-register an existing node Use re-registration to recover a machine's connection while keeping its existing node entry, name, shares, settings, and session records. You must own the node. diff --git a/apps/docs/content/docs/nodes/enroll.mdx b/apps/docs/content/docs/nodes/enroll.mdx index e292114e1..ceb47c257 100644 --- a/apps/docs/content/docs/nodes/enroll.mdx +++ b/apps/docs/content/docs/nodes/enroll.mdx @@ -23,17 +23,29 @@ Sign in to the server with permission to enroll nodes. Use a supported Linux or 5. Choose the machine's display name and whether to install the background service. 6. Confirm that the new node appears online. -The command downloads a compatible node binary from the control plane and runs `subshell setup`. The name is chosen on the node, not by the setup key. +The command downloads a compatible node binary from the control plane and runs +`subshell setup`. The name is chosen on the node, not by the setup key. The +generated command sends the download URL to curl through standard input so the +setup key doesn't appear in curl's process arguments. + +Treat the copied command as a credential: it still contains the setup key and +may be saved in your shell history. Don't paste it into shared logs or messages. ## Use an existing binary -When `subshell` is already installed, use the server and key from the dialog: +When a current `subshell` binary is already installed, use the server and key +from the dialog. Check that `subshell --help` lists `SUBSHELL_SETUP_KEY`; update +an older binary before using this form: ```bash -subshell setup --server https://server.example.com --key nsk_REPLACE_ME +SUBSHELL_SETUP_KEY='nsk_REPLACE_ME' subshell setup --server https://server.example.com ``` -Replace the example address and key before running it. A setup key is single-use and must not be confused with a rotated node credential. +Replace the example address and key before running it. The environment input +keeps the key out of command arguments; privileged processes and processes +running as your OS user can still inspect it. The CLI consumes it before +starting child processes. A setup key is single-use and must not be confused +with a rotated node credential. Setup-key rows remain available to their owner; an unused key's **Setup** action reopens the instructions. diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx index 65db0c2e9..ff402ee4c 100644 --- a/apps/docs/content/docs/nodes/manage.mdx +++ b/apps/docs/content/docs/nodes/manage.mdx @@ -25,89 +25,37 @@ Adding or removing a directory also saves any pending notes. Agents read both fi ## 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. +The node owner controls web previews and virtual displays independently; an +administrator manages the server's built-in node. Follow +[Preview prerequisites and setup](/previews/setup) for the opt-in controls, +access requirements, display providers, and the effects of changing settings. -- **Allow web previews** lets people with edit access open web apps and saved services. People with view access can watch the browser discovered for their subshell. 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. +## Linux virtual display 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** requires Linux. -**BetterDisplay** requires macOS 14 or later and the separately enabled helper described below. The card checks requirements on the selected node, -which may be a different machine from the one displaying the dashboard. +Follow [Linux virtual displays](/previews/linux) to install dependencies on the +node, check readiness, and launch a screen for your subshell. -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. +## macOS display helper -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. + -## macOS display helper +Follow [macOS virtual displays](/previews/macos) for BetterDisplay requirements, +local helper consent, macOS permissions, and shared-desktop limits. + + + +[Enable the helper](/previews/macos#enable-the-helper) on the Mac before +requesting a virtual display from that node. + + + +[Update the helper](/previews/macos#update-the-helper) through an updated desktop +app, including the steps to quit a running helper before replacing it. + + -Subshell Client and Subshell Server both include **Subshell Display Helper**. -Use either desktop app to set it up on a Mac running macOS 14 or later. The -helper stays disabled until you enable it locally and grant macOS permissions. - -### Before you start - -Use the Mac that runs the selected node, with your macOS account signed in. -Keep another screen as the main display; the helper refuses a virtual screen -that is main or mirrored. A Mac whose only screen is the new virtual display -isn't supported by this provider. -BetterDisplay must be installed and running with CLI access enabled and a -license or trial that supports virtual screens. Subshell doesn't install or -purchase BetterDisplay for you. See [BetterDisplay's integration guide](https://betterdisplay.pro/guide/integration/). - -macOS displays share the same desktop, keyboard focus, and pointer. Apps and -notifications on the virtual screen can appear in its stream. This isn't a -separate login session or a sandbox. Only enable it for people you trust to -control that Mac. Keyboard input requires a focused window entirely inside the -virtual screen; click that window before typing. - -### Enable the helper - -1. On that Mac, open **Subshell Client** → **Virtual Display Helper…** or - **Subshell Server** → **Virtual Display Helper…** from the macOS menu bar. - This opens settings; the helper stays disabled until you choose otherwise. -2. Read the shared-desktop warning, then choose **Enable helper**. This also - registers the helper to start when you sign in. It can keep running after - you close the desktop app. If the panel reports pending background approval, - choose **Open login items…** and allow it in System Settings. -3. Choose **Allow screen recording…** and **Allow accessibility…**. In macOS - System Settings, grant both permissions to **Subshell Display Helper**. - Screen Recording lets it capture the virtual screen; Accessibility lets it - send keyboard and pointer input. Subshell can't grant these permissions - through node settings or MCP. If macOS requires a restart, choose **Quit - Helper** from the helper's display icon in the menu bar, reopen its settings - through either desktop app, and enable it again. -4. In the server dashboard, open **Settings** → **Nodes**, select that Mac, - and open **Overview**. Enable **Allow virtual displays** and **Allow - BetterDisplay**, then choose it as the default if desired. Choose **Recheck** - if requirements still show as missing. -5. Start a subshell on that node with **Virtual display** and **BetterDisplay** - selected. Open its screen through **Web previews & screens**. - -### Use and stop the display - -macOS doesn't automatically place an app on the subshell's screen. Move the -app's window onto that screen on the Mac, or use the app's own display-selection -support. -Capture runs at up to five frames per second; audio and clipboard sharing -aren't supported. Native letter shortcuts use the Mac's ANSI key positions; -non-US keyboard layouts may interpret shortcuts differently. - -To stop access, open the same panel and choose **Disable helper**, or choose -**Disable Helper** from its menu-bar icon. This disconnects all helper captures -and stops input for both desktop apps, and unregisters startup at login. -Your terminal subshells keep running. macOS permission grants remain in place; -you can revoke them separately in System Settings. **Quit Helper** also -disables it. Existing virtual screens can remain until their subshells close -or recovery can contact BetterDisplay again. - -Turning off **Allow virtual displays** in node settings blocks new allocations; -it doesn't replace the helper's immediate disable control. If the Mac has no -running helper, the node reports that requirement instead of launching it. +[Use and stop the display](/previews/macos#use-and-stop-the-display) explains +window placement, input limits, immediate disable, and sign-in behavior. ## Names and ownership diff --git a/apps/docs/content/docs/presets/create.mdx b/apps/docs/content/docs/presets/create.mdx index 9fa9b5dfb..4da5388d3 100644 --- a/apps/docs/content/docs/presets/create.mdx +++ b/apps/docs/content/docs/presets/create.mdx @@ -29,7 +29,7 @@ codex --sandbox workspace-write --ask-for-approval on-request 7. Select **Create preset**.
- +
This preset reuses two Codex flags. Subshell resolves the Codex executable on the selected execution machine. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/presets/index.mdx b/apps/docs/content/docs/presets/index.mdx index dec5e0898..9105beabc 100644 --- a/apps/docs/content/docs/presets/index.mdx +++ b/apps/docs/content/docs/presets/index.mdx @@ -28,6 +28,6 @@ Saving a preset changes future launches that use it. It doesn't reconfigure a ru For an existing subshell, follow [Clone and switch presets](/guides/clone) to start with different settings.
- +
Keep launch settings in your preset library and manage each preset from its actions menu. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/presets/launch-defaults.mdx b/apps/docs/content/docs/presets/launch-defaults.mdx index d2a7fef60..b9901b5dd 100644 --- a/apps/docs/content/docs/presets/launch-defaults.mdx +++ b/apps/docs/content/docs/presets/launch-defaults.mdx @@ -19,7 +19,7 @@ Create a preset and have launch access to the intended machine. Use an absolute 6. Select **Save**.
- +
The example combines a project location with review instructions. Its optional MCP switch allows agents to use the preset. Select the image to enlarge it.
@@ -36,9 +36,12 @@ Prompt text guides the agent; it isn't a security boundary. Agent flags and perm 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. +The list shows only providers the selected machine's operating system can run, +and it follows the machine: choosing one that can't run what you'd saved clears +the pick back to **Node default** rather than storing a launch the node will +refuse. 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). +See [Web previews & screens](/previews/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 diff --git a/apps/docs/content/docs/presets/use.mdx b/apps/docs/content/docs/presets/use.mdx index 40685276f..3c5f1e53b 100644 --- a/apps/docs/content/docs/presets/use.mdx +++ b/apps/docs/content/docs/presets/use.mdx @@ -37,7 +37,7 @@ To revoke access, open **Share preset**, select **Remove** beside the grant, and Machine tokens don't inherit preset sharing. MCP helpers can still use only their owner's presets.
- +
Sharing applies to the saved preset. Node access and SSH destination approvals remain separate. Select the image to enlarge it.
diff --git a/apps/docs/content/docs/previews/agents.mdx b/apps/docs/content/docs/previews/agents.mdx new file mode 100644 index 000000000..12dc269fe --- /dev/null +++ b/apps/docs/content/docs/previews/agents.mdx @@ -0,0 +1,54 @@ +--- +title: "Preview workflows for agents" +description: "Use MCP to launch a helper subshell with a virtual display, start a browser, discover views, and clean up afterward." +--- + +Agents can use MCP to prepare a preview in a helper subshell. + +## Before you start + +An agent can use MCP to start a helper on another enrolled node owned by the +same account. Complete [Preview prerequisites and setup](/previews/setup) before +asking an agent to launch one. The example below assumes you've completed +[Linux virtual displays](/previews/linux). A Mac configured through +[macOS virtual displays](/previews/macos) can use `betterdisplay` instead; MCP +can check its requirements but can't enable its helper or grant macOS permissions. + +Subshell tokens can discover only enrolled nodes owned by the same account. +Shared nodes and the server's built-in node aren't available through these tools, +and the tools don't grant access to another user's shared subshell. + +## Launch and inspect a helper + +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 display provider, or include `provider` to check + another enabled choice. Confirm `virtualDisplay.enabled` is true and its + status is `ready`. +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. + `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. diff --git a/apps/docs/content/docs/previews/apps.mdx b/apps/docs/content/docs/previews/apps.mdx new file mode 100644 index 000000000..112bc97da --- /dev/null +++ b/apps/docs/content/docs/previews/apps.mdx @@ -0,0 +1,127 @@ +--- +title: "Web app previews" +description: "Open discovered dev servers and saved services, configure personal preview settings, and protect private links." +--- + +Open a website running on your node beside its subshell. + +## Before you start + +Follow [Preview prerequisites and setup](/previews/setup) for the node setting +and access you need. Automatic port discovery requires Linux; use a saved +service for a web server on macOS. + +## 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. 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. + Its badge counts views you can open that haven't been saved: discovered ports + without a saved service, plus an available screen and browser watch. Opening + a port doesn't save it; **Add service** removes that port from the count. +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. + +
+ +
The badge shows 3 available views: two discovered web ports plus one browser watch.
+
+ + +See [Arrange and refresh views](/previews#arrange-and-refresh-views) for workspace tabs and toolbar controls. + +## Add a service started elsewhere + +Use a saved service when your web server runs outside the subshell or its port +isn't discovered automatically. + +1. Use the Web previews & screens icon in the subshell header, 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.
+
+ +The subshell must be running. 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. People with view access +can see the list but can't open or change its connections. + +
+ +
After saving a service, select its row to open the web preview.
+
+ +
+ +
The saved website service open in a web preview. Navigate and interact with it directly in your workspace.
+
+ +## Configure an app preview + +
+ +
Personal settings remembered for this app on this node.
+
+ +In the subshell header's **Open web preview or screen** dialog, select +**Configure preview before opening**, then choose a discovered port or saved service. For an app that's already open, use +**More view actions** → **Configure preview** in its toolbar. + +| Setting | Default | What it changes | +| --- | --- | --- | +| **Rewrite app URLs** | On | Routes supported app URLs through the private preview path. | +| **Pass the preview host** | Off | Sends your browser's preview host to the app instead of the target host. Use it only when the app expects that host. | +| **Rewrite redirects** | On | Keeps redirects to this app under the preview path. | + +Choose **Save and open** to save your choices and open a fresh preview. +**Reset to defaults** removes the saved choices and opens a fresh preview with +the defaults above. **Cancel** keeps the current preview. + +These settings belong to your account and follow you across devices and +subshells for the same node, host, protocol, and port. They don't change anyone +else's preview or grant access to a target. A saved service and a discovered +port share settings when they identify the same target. + +## 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 a [virtual display](/previews#choose-a-view). + +With **Rewrite app URLs** enabled, Subshell rewrites supported HTML attributes, +CSS URLs, and module imports. It also adds a browser compatibility script for +common requests such as fetch, XMLHttpRequest, WebSocket, and EventSource. +This helps apps that assume they're hosted at `/`, but it doesn't make every +app compatible. A target's strict Content Security Policy can block the +compatibility script or other resources. Preview settings don't relax the +sandbox. + +If the app rejects the preview's host or origin, check the app's allowed-host +and origin settings. Subshell doesn't bypass the app's access checks. + +> [!WARNING] +> 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. Remove a saved service to revoke its connections, or remove +> access or end the subshell to revoke existing access. Turning off previews on +> the node doesn't immediately revoke existing connections. + +The server can read forwarded app traffic. See +[Preview security](/concepts/security#preview-security) for the trust boundaries +and [Architecture](/concepts/architecture#web-previews-and-virtual-displays) for the data path. + +For implementation details, read [Web preview architecture](/developers/web-preview-architecture) and [Develop and extend web previews](/developers/web-preview-development). diff --git a/apps/docs/content/docs/previews/browser.mdx b/apps/docs/content/docs/previews/browser.mdx new file mode 100644 index 000000000..c3e1aae77 --- /dev/null +++ b/apps/docs/content/docs/previews/browser.mdx @@ -0,0 +1,45 @@ +--- +title: "Browser watching" +description: "Watch a Linux node browser through a read-only screencast and understand its access limits." +--- + +Browser watching shows live frames from a browser running in your subshell. + +## Before you start + +Complete [Preview prerequisites and setup](/previews/setup) for web previews +on a Linux node. You'll also need Chrome or Chromium with its Chrome DevTools +Protocol (CDP) remote-debugging interface enabled. + +Enable remote debugging using your browser's supported configuration. Chrome +requires a separate user data directory when using `--remote-debugging-port`; +see [Chrome's remote debugging requirements](https://developer.chrome.com/blog/remote-debugging-port). +Keep the debugging listener on loopback and use a dedicated testing profile; +don't publish it to your network. Installing a browser or opening a website +alone doesn't enable CDP. + +You need view access to watch the browser discovered for the subshell. A +view-only share can't choose another browser port on the node. + +## Open and stop watching + +1. Start the browser from the subshell with remote debugging enabled. +2. Use the Web previews & screens icon in the subshell header to open **Open web + preview or screen**. +3. Select **Browser** when it appears. Its live frames open in a workspace tab. +4. Close the preview tab when you're done watching. The browser and subshell + keep running. + +If no browser appears, check that it belongs to the selected subshell and its +remote-debugging interface is available on the node's loopback address. The +viewer reports when it's waiting for frames or has no recent frames. + +## Screencast limits + +This is a read-only screencast at up to five frames per second. It doesn't +send keyboard or pointer input to the browser. Use +[Linux virtual displays](/previews/linux) when you need to control it. + +See [Arrange and refresh views](/previews#arrange-and-refresh-views) for workspace +controls. The server can read browser frames; review +[Preview security](/concepts/security#preview-security) before sharing a subshell. diff --git a/apps/docs/content/docs/previews/index.mdx b/apps/docs/content/docs/previews/index.mdx new file mode 100644 index 000000000..4aac6e8e0 --- /dev/null +++ b/apps/docs/content/docs/previews/index.mdx @@ -0,0 +1,76 @@ +--- +title: "Web previews & screens" +description: "Choose a web app preview, browser watch, or virtual display and arrange it beside your subshell." +--- + +Web previews & screens lets you check a website or graphical app alongside the agent building it. + +
+ +
A website running on the node, opened in a web preview. You can navigate and interact with it inside your Subshell workspace.
+
+ +## Prerequisites + + + +Follow [Preview prerequisites and setup](/previews/setup) to connect a node and +enable the views you need. For a virtual screen, complete the +[Linux](/previews/linux) or [macOS](/previews/macos) setup on that node too. +The device you view from only needs a browser or Subshell Client. + +## Choose a view + + + + + + + + + + +| View | Use it to | +| --- | --- | +| [Web app previews](/previews/apps) | Interact with a website through a private preview connection. | +| [Browser watching](/previews/browser) | Watch a Chrome or Chromium browser running on a Linux node without controlling it. | +| [Linux virtual displays](/previews/linux) | Give a subshell its own screen and control graphical apps there. | +| [macOS virtual displays](/previews/macos) | Control apps on a virtual screen in the Mac's shared login session. | + +## Arrange and refresh views + +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. + +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. +On a phone, previews use the workspace's tab strip without changing your desktop +split layout. From the standalone subshell page, the header icon takes you into +the workspace and opens the picker there. + +## Agent workflows and security + + + +Follow [Preview workflows for agents](/previews/agents) to launch a helper +through MCP and clean it up afterward. + +Read [Preview security](/concepts/security#preview-security) before sharing a +preview link or enabling remote screen control. For the data path and developer +guidance, see [Architecture](/concepts/architecture#web-previews-and-virtual-displays), +[Web preview architecture](/developers/web-preview-architecture), and +[Develop and extend web previews](/developers/web-preview-development). diff --git a/apps/docs/content/docs/previews/linux.mdx b/apps/docs/content/docs/previews/linux.mdx new file mode 100644 index 000000000..33521aa1d --- /dev/null +++ b/apps/docs/content/docs/previews/linux.mdx @@ -0,0 +1,51 @@ +--- +title: "Linux virtual displays" +description: "Install Xvfb, x11vnc, and xauth, configure the node, and launch a controllable screen for graphical apps." +--- + +A Linux virtual display gives graphical programs in a subshell their own screen without a physical monitor. + +## Before you start + +You'll need permission to install packages on the Linux node. Follow +[Preview prerequisites and setup](/previews/setup) for node ownership and view +access requirements. + +## Install and configure + +Install the dependencies on the Linux machine that runs the subshell, even +when you're viewing it from another device. + +1. Use the Linux distribution's package manager to install **Xvfb**, **x11vnc**, + and **xauth**. Package names vary by distribution; the node needs the + `Xvfb`, `x11vnc`, and `xauth` commands available on its execution account's + `PATH`. Install the browser or GUI app you want to use separately. +2. Follow [Enable node features](/previews/setup#enable-node-features) to allow + virtual displays with **Xvfb + VNC** on this node. Once its requirements are + ready, launch a subshell as described below. + +> [!WARNING] +> The display runs with the subshell's execution account permissions. It isn't a +> sandbox for applications, and its local VNC listener has no separate password. +> Don't enable virtual displays on a host shared with untrusted local OS users, +> and don't publish the listener through a port forward. See +> [Preview security](/concepts/security#preview-security). + +## Launch and open a display + +1. [Launch a subshell](/guides/launch) on that node with **Virtual display** enabled. Choose + **Xvfb + VNC** under **Display provider**, or keep **Node default** if it + selects that provider. +2. Launch your browser or GUI app from that subshell so it inherits the display + settings. +3. 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.
+
+ +See [Preview prerequisites and setup](/previews/setup#enable-node-features) to +save the display choice in a preset or understand how provider changes affect +restarts. diff --git a/apps/docs/content/docs/previews/macos.mdx b/apps/docs/content/docs/previews/macos.mdx new file mode 100644 index 000000000..7db748b8e --- /dev/null +++ b/apps/docs/content/docs/previews/macos.mdx @@ -0,0 +1,132 @@ +--- +title: "macOS virtual displays" +description: "Set up BetterDisplay and the signed display helper, approve Mac permissions, and manage shared-desktop screen access." +--- + +A macOS virtual display gives your subshell a screen within the Mac's signed-in desktop. + + + +## Before you start + +Follow [Preview prerequisites and setup](/previews/setup) to connect the node +and check access requirements. On a Mac running macOS 14 or later, install +[Subshell Client](/install/desktop-client) or [Subshell Server](/install/desktop-server); +both include **Subshell Display Helper**. Use the Mac that runs the selected +node, with your macOS account signed in. + +Keep another screen as the main display; the helper refuses a virtual screen +that is main or mirrored. A Mac whose only screen is the new virtual display +isn't supported by this provider. + +Install [BetterDisplay from its official download page](https://betterdisplay.pro/), +choosing a release for your Mac's operating system, and keep it running. You need +CLI access enabled and a license or trial that supports virtual screens. Subshell +doesn't install or purchase BetterDisplay for you. + +In BetterDisplay, open **Settings** → **Application** → **Integration** and +turn on CLI access if it's off. See +[BetterDisplay's integration guide](https://betterdisplay.pro/guide/integration/). + +> [!WARNING] +> macOS displays share your login session, keyboard focus, and pointer. Apps and +> notifications on the virtual screen can appear in its stream. This isn't a +> separate login session or a sandbox; applications retain the execution account's +> permissions. Only enable it for people you trust to control that Mac. +> +> You don't need to configure BetterDisplay as a VNC server or expose a VNC port. +> Subshell's local VNC listener has no separate password. Don't enable virtual +> displays on a Mac shared with untrusted local OS users, and don't publish the +> listener through a port forward. Read +> [Preview security](/concepts/security#preview-security). + +## Enable the helper + +1. On that Mac, open **Subshell Client** or **Subshell Server**. From the app's + menu bar icon, choose **Open Client App** or **Open Server Controls**, + respectively. In that window, choose **Virtual Displays** → **Open Display + Helper settings**. You can also open these settings from the helper's own + menu bar icon. Opening settings doesn't enable the helper. +2. Read the shared-desktop warning, then choose **Enable helper**. Capture and + input require your consent and both macOS permissions. If you want the helper + to come back when you sign in, choose **Start at sign-in** in the panel's + **Launch at sign-in** section, and choose **Open + login items…** if that row says macOS is still waiting for your approval. +3. Choose **Allow** on the **Screen recording** row and **Allow** on the + **Accessibility** row. In macOS System Settings, grant both permissions to + **Subshell Display Helper**. + Screen Recording lets it capture the virtual screen; Accessibility lets it + send keyboard and pointer input. Subshell can't grant these permissions + through node settings or MCP. If macOS requires a restart, choose **Quit + helper** from the helper's display icon in the menu bar and reopen its settings + through either desktop app. Your consent stays, so you don't have to enable it + again. +4. Follow [Enable node features](/previews/setup#enable-node-features) to allow + virtual displays with **BetterDisplay** on this node. +5. [Launch a subshell](/guides/launch) on that node with **Virtual display** enabled and + **BetterDisplay** selected under **Display provider**, or **Node default** + if it selects BetterDisplay. Use the Web previews & screens icon in the + subshell header, then choose **Virtual display** to open its screen. + +
+ +
Open the helper's settings from either desktop app's Virtual Displays page.
+
+ +
+ +
Read the shared-desktop warning before choosing **Enable helper**. Both macOS permissions are required.
+
+ +
+ +
The enabled helper shows who's watching, its sign-in setting, and both approved macOS permissions.
+
+ +## Update the helper + +After updating either desktop app, open its controls as described above and choose +**Virtual Displays** → **Open Display Helper settings** to check and install +its bundled helper. Both apps use the same installed helper; opening an older +app doesn't downgrade a valid newer installation. + +A running helper can't be replaced, even while disabled. If an update is +waiting, choose **Quit helper** from the helper's menu-bar icon, then reopen +settings through the updated desktop app. Quitting disconnects anyone watching +its screens and stops their input; terminal subshells keep running. The update +doesn't change your consent or macOS privacy settings. + +The desktop app verifies the helper before opening it, including when a +running helper prevents an update. If verification fails, don't bypass the +signature check. Quit the running helper if present and reopen settings through +a trusted installation of the desktop app. If that still fails, follow +[Update troubleshooting](/troubleshooting/updates). + +## Use and stop the display + +macOS doesn't automatically place an app on the subshell's screen. Move the +app's window onto that screen on the Mac, or use the app's own display-selection +support. +Keyboard input requires a focused window entirely inside the virtual screen; +click that window before typing. macOS secure input and protected system prompts +can prevent remote input; complete those interactions on the Mac. + +Capture runs at up to five frames per second; audio and clipboard sharing +aren't supported. Native letter shortcuts use the Mac's ANSI key positions; +non-US keyboard layouts may interpret shortcuts differently. + +To stop access, open the same panel and choose **Disable helper**, or choose +**Disable helper** from its menu-bar icon. This disconnects all helper captures +and stops input for both desktop apps. Your terminal subshells keep running. +If you chose to start it at sign-in, it stays in macOS's login list; +when it starts again, it finds itself disabled and exits. To take it out of +the list, use the panel's **Stop at sign-in** button. macOS permission +grants remain in place; you can revoke them separately in System Settings. +**Quit helper** asks for confirmation, then closes the helper and disconnects +anyone watching. Existing virtual screens can remain until their subshells +close or recovery can contact BetterDisplay again. + +The helper's **Disable helper** control stops access immediately; the +[node switch](/previews/setup#enable-node-features) only blocks new operations. +If the Mac has no running helper, the node reports that requirement instead of +launching it. diff --git a/apps/docs/content/docs/previews/meta.json b/apps/docs/content/docs/previews/meta.json new file mode 100644 index 000000000..ca10231c5 --- /dev/null +++ b/apps/docs/content/docs/previews/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Web previews & screens", + "pages": [ + "index", + "setup", + "apps", + "browser", + "linux", + "macos", + "agents" + ] +} diff --git a/apps/docs/content/docs/previews/setup.mdx b/apps/docs/content/docs/previews/setup.mdx new file mode 100644 index 000000000..7b6379785 --- /dev/null +++ b/apps/docs/content/docs/previews/setup.mdx @@ -0,0 +1,73 @@ +--- +title: "Preview prerequisites and setup" +description: "Enable web previews and display providers on a node, with the required access and installation steps." +--- + +Enable the preview features you want on the machine that runs your subshell. + +## Before you start + +For a graphical machine, follow [Install Subshell Client](/install/desktop-client) +and [Desktop node setup](/nodes/desktop). For a headless machine, follow +[Enroll a node with the CLI](/nodes/enroll), including its installation steps. +You can also use the server's built-in node. + +You must own the node to change its preview settings. An administrator configures +the server's built-in node. For a virtual display, first install the dependencies +on that machine by following [Linux virtual displays](/previews/linux#install-and-configure) +or [macOS virtual displays](/previews/macos#before-you-start). + +## Enable node features + +Both preview switches are off by default and work independently. + +1. In the server dashboard, open **Settings** → **Nodes**, select the machine, + and open **Overview**. +2. Turn on **Allow web previews**, **Allow virtual displays**, or both, + depending on the views you need. Each switch has its own effect: + + **Allow web previews** enables [Web app previews](/previews/apps) and + [Browser watching](/previews/browser). + + **Allow virtual displays** lets subshells request a screen for graphical + apps. Each display uses extra memory and CPU; web app previews don't need one. + +3. Under **Allow virtual displays**, enable the providers you want this node to + use and choose a **Default display provider**. Choose **Recheck** after + resolving missing requirements. Each enabled provider can be selected when + starting a subshell. + +Choose **Xvfb + VNC** for Linux or **BetterDisplay** for macOS. The card checks +requirements on the selected node, which may be a different machine from the one +displaying the dashboard. A pending or failed requirements check blocks a launch +with a virtual display until you resolve it or turn off the launch's display option. + +Each subshell saves its provider and display size for restarts. Changing the +node's default doesn't change existing subshells. 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. + +You can [save a display choice in a preset](/presets/launch-defaults#save-a-virtual-display-choice). +Review it when changing nodes: if the new node can't provide that display, the +launch form turns the option off and explains why. + +> [!WARNING] +> Turning either switch off blocks new operations without stopping existing +> subshells or immediately closing existing connections. Remove access or end the +> subshell when you need to revoke an existing connection. On a Mac, use +> [Disable helper](/previews/macos#use-and-stop-the-display) to stop capture and +> input immediately. + +## Access to views + +You need edit access to open or configure app previews, manage saved services, +or control a virtual display. View access lets you discover views, see saved +services, and watch the browser found for that subshell. Unavailable app and +screen controls explain the access they need. The header badge counts only +views you can open. If both features are off, the picker explains this and +links to this setup guide; the preview tab also offers **Open node settings**. + +
+ +
The picker links to setup when the node's preview features are off.
+
diff --git a/apps/docs/content/docs/reference/node-protocol.mdx b/apps/docs/content/docs/reference/node-protocol.mdx index 036621be6..99f688ccb 100644 --- a/apps/docs/content/docs/reference/node-protocol.mdx +++ b/apps/docs/content/docs/reference/node-protocol.mdx @@ -128,7 +128,7 @@ Most commands execute in arrival order on a shared queue, so a long archive oper | Machine information | `inventory`, `detect`, `ping`. | | Node management | `service`, `agent_log_read`, `set_server_url`, `set_log_level`, `set_maintenance`, `set_ssh_enabled`, `update`. | | SSH discovery and trust | `ssh_discover_aliases`, `ssh_resolve_config`, `ssh_register_identity`, `ssh_agent_identities`, `ssh_host_key`, `ssh_machine_pin_repair`. | -| SSH relay and remote setup | `ssh_relay_open`, `ssh_relay_close`, `ssh_exec`, `ssh_exec_status`. | +| SSH relay and remote setup | `ssh_relay_open`, `ssh_relay_close`, `ssh_exec_stdin` (setup scripts on stdin), legacy `ssh_exec`, `ssh_exec_status`. | A command existing on the wire doesn't grant every user access to it. Server routes enforce ownership, grants, and administrator requirements before dispatch. The daemon also applies local checks, including directory rules, maintenance refusals, and service-manager safety checks. @@ -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 **24**. 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 **25**. 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. diff --git a/apps/docs/content/docs/troubleshooting/terminal.mdx b/apps/docs/content/docs/troubleshooting/terminal.mdx index 2a8f9b106..c82b7b334 100644 --- a/apps/docs/content/docs/troubleshooting/terminal.mdx +++ b/apps/docs/content/docs/troubleshooting/terminal.mdx @@ -9,23 +9,50 @@ Diagnose terminal input and rendering problems. Check that the session's process is running and that you have edit access. View-only access lets you watch but doesn't let you type. In an SSH subshell, only its owner can type, even if someone else has edit access. MCP and other bearer credentials can't send SSH terminal input either. -On touch devices, **Enable text copying** switches away from input mode. Choose **Enable text input** when you want to type again. +On a phone or tablet, open the subshell’s **⋯** actions menu and choose **Enable text input** if text copying is enabled. Copy mode disables typing, Paste, and the key bar’s input buttons; scrolling remains available. An offline node makes its stream unreachable even if its process continues on the machine. +## Select and copy terminal text + +On a phone or tablet, open the subshell’s **⋯** actions menu and choose +**Enable text copying**. Press and hold the text, adjust the selection handles, +then use your browser or device’s copy action. Tapping an HTTP or HTTPS link +opens it; dragging a selection doesn’t open the link. + +
+ +
Choose Enable text copying in the subshell’s actions menu.
+
+ +
+ +
Copy mode lets you select rendered terminal text. Your device supplies the copy action.
+
+ +Copy mode keeps the keyboard closed while you select text. Choose **Enable +text input** in the same menu to type again. This setting belongs to your +browser for that subshell and doesn't change anyone else's input access. + ## Page loads but output doesn't stream Verify WebSocket forwarding through the proxy and confirm that the API can mint the terminal's single-use token. A successful static page load doesn't test either operation. Try the server's direct trusted address from an appropriate device to isolate a proxy problem. +## Connection closes with an input limit + +A `terminal input limit exceeded` close means one input message was too large or too much input is still waiting to finish. Wait for the node to respond before sending more. The current client splits large pastes and limits outstanding messages automatically; custom clients must do the same. + +A disconnect doesn't undo commands already sent. Check the terminal before repeating a command whose result is uncertain. + ## Terminal becomes smaller Everyone watching a terminal shares its size. If someone opens it in a smaller window, the terminal can shrink for everyone. Check the attached devices and close any views you no longer need. ## Corrupted display -Note the agent and component versions, and whether the display breaks after reconnecting or resizing. Terminal connection entries in the server log can help identify sizing or output-replay problems. +After a resize, the terminal can briefly reconnect to refresh its display from the running subshell. This doesn’t restart the agent or shell. If the display stays corrupted, note the agent and component versions, and whether the display breaks after reconnecting or resizing. Terminal connection entries in the server log can help identify sizing or output-replay problems. > [!warning] Screen dumps > Attach-debug output can include secrets and project contents. Don't enable it or upload its files without reviewing what will be captured. diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index 3de1496c4..b78783898 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -4,6 +4,7 @@ export const DOC_SECTIONS = [ { slug: "get-started", title: "Get started" }, { slug: "install", title: "Installation" }, { slug: "guides", title: "Use Subshell" }, + { slug: "previews", title: "Web previews & screens" }, { slug: "presets", title: "Presets" }, { slug: "prompts", title: "Reusable Prompts" }, { slug: "agents", title: "Agents" }, diff --git a/apps/docs/public/_redirects b/apps/docs/public/_redirects index 0bafd2679..e39d2f024 100644 --- a/apps/docs/public/_redirects +++ b/apps/docs/public/_redirects @@ -84,3 +84,5 @@ /guides/presets /presets 301 /guides/prompts /prompts 301 + +/guides/preview /previews 301 diff --git a/apps/docs/public/screenshots/add-subshell.png b/apps/docs/public/screenshots/add-subshell.png index 2cf01b091..71e9fe046 100644 Binary files a/apps/docs/public/screenshots/add-subshell.png and b/apps/docs/public/screenshots/add-subshell.png differ diff --git a/apps/docs/public/screenshots/assistant-virtual-displays.png b/apps/docs/public/screenshots/assistant-virtual-displays.png new file mode 100644 index 000000000..eb2117a94 Binary files /dev/null and b/apps/docs/public/screenshots/assistant-virtual-displays.png differ diff --git a/apps/docs/public/screenshots/display-helper-enable.png b/apps/docs/public/screenshots/display-helper-enable.png new file mode 100644 index 000000000..a3e845f32 Binary files /dev/null and b/apps/docs/public/screenshots/display-helper-enable.png differ diff --git a/apps/docs/public/screenshots/display-helper-panel.png b/apps/docs/public/screenshots/display-helper-panel.png new file mode 100644 index 000000000..746e6479a Binary files /dev/null and b/apps/docs/public/screenshots/display-helper-panel.png differ diff --git a/apps/docs/public/screenshots/harness-subagents.png b/apps/docs/public/screenshots/harness-subagents.png index 47b00f958..58d2b56da 100644 Binary files a/apps/docs/public/screenshots/harness-subagents.png and b/apps/docs/public/screenshots/harness-subagents.png differ diff --git a/apps/docs/public/screenshots/node-enrollment.png b/apps/docs/public/screenshots/node-enrollment.png index a3623777e..64b450999 100644 Binary files a/apps/docs/public/screenshots/node-enrollment.png and b/apps/docs/public/screenshots/node-enrollment.png differ diff --git a/apps/docs/public/screenshots/node-switch-off.png b/apps/docs/public/screenshots/node-switch-off.png new file mode 100644 index 000000000..9784dd6fe Binary files /dev/null and b/apps/docs/public/screenshots/node-switch-off.png differ diff --git a/apps/docs/public/screenshots/preset-codex-configuration.png b/apps/docs/public/screenshots/preset-codex-configuration.png index f4c6f5741..5d1ce9515 100644 Binary files a/apps/docs/public/screenshots/preset-codex-configuration.png and b/apps/docs/public/screenshots/preset-codex-configuration.png differ diff --git a/apps/docs/public/screenshots/preset-launch-defaults.png b/apps/docs/public/screenshots/preset-launch-defaults.png index 77f15f969..c9ab4efd2 100644 Binary files a/apps/docs/public/screenshots/preset-launch-defaults.png and b/apps/docs/public/screenshots/preset-launch-defaults.png differ diff --git a/apps/docs/public/screenshots/preset-library.png b/apps/docs/public/screenshots/preset-library.png index 0c84ddccf..c1c75442f 100644 Binary files a/apps/docs/public/screenshots/preset-library.png and b/apps/docs/public/screenshots/preset-library.png differ diff --git a/apps/docs/public/screenshots/preset-sharing.png b/apps/docs/public/screenshots/preset-sharing.png index 848211505..3ed9c7cdf 100644 Binary files a/apps/docs/public/screenshots/preset-sharing.png and b/apps/docs/public/screenshots/preset-sharing.png differ diff --git a/apps/docs/public/screenshots/preview-badge.png b/apps/docs/public/screenshots/preview-badge.png new file mode 100644 index 000000000..314cbc66e Binary files /dev/null and b/apps/docs/public/screenshots/preview-badge.png differ diff --git a/apps/docs/public/screenshots/preview-display.png b/apps/docs/public/screenshots/preview-display.png index a6920dc1e..5da289ed9 100644 Binary files a/apps/docs/public/screenshots/preview-display.png and b/apps/docs/public/screenshots/preview-display.png differ diff --git a/apps/docs/public/screenshots/preview-intro.png b/apps/docs/public/screenshots/preview-intro.png new file mode 100644 index 000000000..3e5bdabc9 Binary files /dev/null and b/apps/docs/public/screenshots/preview-intro.png differ diff --git a/apps/docs/public/screenshots/preview-picker.png b/apps/docs/public/screenshots/preview-picker.png index 9cc95df0c..c87afaf16 100644 Binary files a/apps/docs/public/screenshots/preview-picker.png and b/apps/docs/public/screenshots/preview-picker.png differ diff --git a/apps/docs/public/screenshots/preview-settings.png b/apps/docs/public/screenshots/preview-settings.png new file mode 100644 index 000000000..163dd6cd9 Binary files /dev/null and b/apps/docs/public/screenshots/preview-settings.png differ diff --git a/apps/docs/public/screenshots/preview-workspace.png b/apps/docs/public/screenshots/preview-workspace.png deleted file mode 100644 index a8f637211..000000000 Binary files a/apps/docs/public/screenshots/preview-workspace.png and /dev/null differ diff --git a/apps/docs/public/screenshots/server-addresses.png b/apps/docs/public/screenshots/server-addresses.png index 0479c0eae..d6bc0ec62 100644 Binary files a/apps/docs/public/screenshots/server-addresses.png and b/apps/docs/public/screenshots/server-addresses.png differ diff --git a/apps/docs/public/screenshots/session-sharing.png b/apps/docs/public/screenshots/session-sharing.png index 7dddfd532..42f128fb4 100644 Binary files a/apps/docs/public/screenshots/session-sharing.png and b/apps/docs/public/screenshots/session-sharing.png differ diff --git a/apps/docs/public/screenshots/ssh-connection.png b/apps/docs/public/screenshots/ssh-connection.png index d4779676e..35cc14529 100644 Binary files a/apps/docs/public/screenshots/ssh-connection.png and b/apps/docs/public/screenshots/ssh-connection.png differ diff --git a/apps/docs/public/screenshots/terminal-copy-menu.png b/apps/docs/public/screenshots/terminal-copy-menu.png new file mode 100644 index 000000000..871152c3f Binary files /dev/null and b/apps/docs/public/screenshots/terminal-copy-menu.png differ diff --git a/apps/docs/public/screenshots/terminal-copy-selection.png b/apps/docs/public/screenshots/terminal-copy-selection.png new file mode 100644 index 000000000..783013e28 Binary files /dev/null and b/apps/docs/public/screenshots/terminal-copy-selection.png differ diff --git a/apps/docs/public/screenshots/workspace-browser.png b/apps/docs/public/screenshots/workspace-browser.png index 8418c891c..2d41352ac 100644 Binary files a/apps/docs/public/screenshots/workspace-browser.png and b/apps/docs/public/screenshots/workspace-browser.png differ diff --git a/apps/docs/public/screenshots/workspace-layout.png b/apps/docs/public/screenshots/workspace-layout.png index 2ff10b393..9f45e81a0 100644 Binary files a/apps/docs/public/screenshots/workspace-layout.png and b/apps/docs/public/screenshots/workspace-layout.png differ diff --git a/apps/node/agent/AGENTS.md b/apps/node/agent/AGENTS.md index ec6233e61..9da967240 100644 --- a/apps/node/agent/AGENTS.md +++ b/apps/node/agent/AGENTS.md @@ -258,7 +258,7 @@ precedes tmux so the refusal fails the launch with its named reason). - **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 + `preview-display-missing-requirements` 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, diff --git a/apps/node/agent/src/__tests__/commands-launch.test.ts b/apps/node/agent/src/__tests__/commands-launch.test.ts index 6d07b862a..2bc2a6a27 100644 --- a/apps/node/agent/src/__tests__/commands-launch.test.ts +++ b/apps/node/agent/src/__tests__/commands-launch.test.ts @@ -1,5 +1,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TmuxRunner } from "@internal/pane-runtime"; @@ -11,6 +20,8 @@ import { execLaunch } from "../commands/launch.js"; import { buildSubshellsReport, EXIT_WATCH_INTERVAL_MS, + PIPE_GUARD_CHECK_TICKS, + PIPE_GUARD_MISSED_LIMIT, runExitWatchTick, startExitWatcher, stopWatcher, @@ -75,7 +86,15 @@ type ProbeAnswer = { ok: true; names: string[] } | { ok: false; detail: string } interface Spec { newSubshell?: (socket: string, id: string, cwd: string, cmd: string, exitHook?: string) => void; - pipePane?: (socket: string, id: string, out: string) => void; + pipePane?: ( + socket: string, + id: string, + out: string, + child?: { command: string; args: string[] }, + opts?: { replace?: boolean }, + ) => void; + /** Screen the capture-child guard reads per check tick (report.ts). */ + capturePane?: (socket: string, id: string) => string | Promise; resizeWindow?: (socket: string, id: string, cols: number, rows: number) => void; hasSubshell?: (socket: string, id: string) => boolean; listSubshellNames?: (socket: string) => string[]; @@ -154,6 +173,7 @@ function makeCtx( // SUCCESSFUL probe — pre-threshold tests keep their exact meaning. return { ok: true, names: listSubshellNames(socket) as string[] }; }, + capturePane: method("capturePane"), paneExitCode: method("paneExitCode"), killSubshell: method("killSubshell"), run: method("run"), @@ -1037,6 +1057,119 @@ describe("exit watcher (report.ts) — one shared tick", () => { * the old swallow-everything probe produced — the RED marker: against the old * tick those fixtures fire an exit event on the FIRST blip. */ +describe("exit watcher — the capture-child guard", () => { + // The child tmux pipes pane bytes to (our `pane-log` verb) can die on its + // own — an OOM killer takes it. `#{pane_pipe}` is not consulted (measured + // both on and off for a dead writer on the same build), so the guard's + // signal is the invariant itself: screen moves, log does not. It re-arms + // WITHOUT -o, the form that cannot be no-op'd by tmux believing the pipe is + // already on. + + /** Enough checks to reach the strike limit after the first (baseline) check. */ + const ticksToArm = PIPE_GUARD_CHECK_TICKS * (PIPE_GUARD_MISSED_LIMIT + 1); + + async function aliveCtx(spec: Spec) { + const dataDir = await freshDataDir("pipe-guard"); + const events: NodeEvent[] = []; + const built = makeCtx(dataDir, { listSubshellNames: () => [S1], pipePane: () => {}, ...spec }, events); + startExitWatcher(built.ctx, S1, "sock-a", 10_000); + return { ...built, dataDir }; + } + + it("retains strikes through quiet intervals between output bursts", async () => { + let screen = "baseline"; + const { ctx, calls } = await aliveCtx({ capturePane: () => screen }); + const check = async () => { + for (let tick = 0; tick < PIPE_GUARD_CHECK_TICKS; tick++) await runExitWatchTick(ctx); + }; + await check(); + for (let burst = 0; burst < PIPE_GUARD_MISSED_LIMIT; burst++) { + screen = `burst-${burst}`; + await check(); + await check(); + } + expect(calls.filter((call) => call.method === "pipePane")).toHaveLength(1); + stopWatcher(ctx, S1); + }); + + it("doesn't report a re-arm when tmux refuses it", async () => { + let frame = 0; + const { ctx } = await aliveCtx({ + capturePane: () => `screen-${++frame}`, + pipePane: () => { + throw new Error("pane gone"); + }, + }); + const { lines, restore } = captureLogs(); + try { + for (let i = 0; i < ticksToArm; i++) await runExitWatchTick(ctx); + } finally { + restore(); + } + expect(lines.some((line) => line.includes("re-armed the capture child"))).toBe(false); + stopWatcher(ctx, S1); + }); + + it("a pane printing into a dead writer gets the child re-armed, once per drift storm", async () => { + let frame = 0; + const { ctx, calls, dataDir } = await aliveCtx({ capturePane: () => `screen-${++frame}` }); + const { lines, restore } = captureLogs(); + try { + for (let i = 0; i < ticksToArm; i++) await runExitWatchTick(ctx); + } finally { + restore(); + } + const rearms = calls.filter((c) => c.method === "pipePane"); + expect(rearms).toHaveLength(1); // not once per strike: the counter resets + // The agent log is the only field trace of a re-arm (there is no wire + // event for it); deleting the log line must not stay green. + expect(lines.some((l) => l.includes("re-armed the capture child") && l.includes(S1))).toBe(true); + const rearms0 = rearms[0]; + expect(rearms0).toBeDefined(); + const [socket, id, out, child, opts] = (rearms0 as { args: unknown[] }).args as [ + string, + string, + string, + { command: string; args: string[] }, + { replace?: boolean }, + ]; + expect([socket, id]).toEqual(["sock-a", S1]); + expect(out).toBe(join(dataDir, "subshells", `${S1}.log`)); + expect(child.args.at(-1)).toBe("pane-log"); // the same verb the launch armed, not cat + expect(opts).toEqual({ replace: true }); + stopWatcher(ctx, S1); + }); + + it("a quiet pane (screen unchanged) never spends a re-arm", async () => { + const { ctx, calls } = await aliveCtx({ capturePane: () => "same old screen" }); + for (let i = 0; i < ticksToArm * 2; i++) await runExitWatchTick(ctx); + expect(calls.filter((c) => c.method === "pipePane")).toHaveLength(0); + stopWatcher(ctx, S1); + }); + + it("a log that keeps pace with the screen is never a strike", async () => { + const frame = { n: 0 }; + const { ctx, calls, dataDir } = await aliveCtx({ + capturePane: () => `screen-${++frame.n}`, + }); + const logFile = join(dataDir, "subshells", `${S1}.log`); + mkdirSync(join(dataDir, "subshells"), { recursive: true }); + writeFileSync(logFile, "seed\n"); + let grown = 1; + // Every CHECK tick the screen moves AND the file grows between checks: + // a healthy writer, however busy. + const origTick = runExitWatchTick; + for (let i = 0; i < ticksToArm; i++) { + if (i % PIPE_GUARD_CHECK_TICKS === PIPE_GUARD_CHECK_TICKS - 1) { + appendFileSync(logFile, `line-${grown++}\n`); + } + await origTick(ctx); + } + expect(calls.filter((c) => c.method === "pipePane")).toHaveLength(0); + stopWatcher(ctx, S1); + }); +}); + describe("exit watcher — unreachable threshold (design §1)", () => { it("blip: ok:false then ok:true-with-pane ⇒ ZERO exit events, counter reset by the ok tick", async () => { const dataDir = await freshDataDir("watcher-blip"); diff --git a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts index 6a70bd5ed..5aebf4aaf 100644 --- a/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh-exec.test.ts @@ -98,15 +98,15 @@ function fakeRun( opts: { defer?: boolean } = {}, ): { seams: SshExecSeams; - calls: { argv: readonly string[]; env: Record; timeoutMs: number }[]; + calls: { argv: readonly string[]; env: Record; timeoutMs: number; input?: string }[]; finish: (r?: Partial) => void; } { - const calls: { argv: readonly string[]; env: Record; timeoutMs: number }[] = []; + const calls: { argv: readonly string[]; env: Record; timeoutMs: number; input?: string }[] = []; let settle: ((r: Partial) => void) | null = null; const seams: SshExecSeams = { resolveSshBin: async () => "/usr/bin/fake-ssh", - runProcess: ((argv, env, timeoutMs) => { - calls.push({ argv, env, timeoutMs }); + runProcess: ((argv, env, timeoutMs, input) => { + calls.push({ argv, env, timeoutMs, input }); const full: SshProcessResult = { code: 0, stdout: "", stderr: "", timedOut: false, spawnError: false, ...result }; if (opts.defer) { return new Promise((resolve) => { @@ -120,6 +120,12 @@ function fakeRun( } describe("ssh_exec gate and path discipline", () => { + it("dispatches the stdin-only kind through the same SSH gate", async () => { + const ctx = makeCtx("stdin-dispatch", { gateOn: false }); + const result = await dispatchCommand(ctx, { ...execCmd(ctx.config.dataDir), type: "ssh_exec_stdin" }); + expect(result).toEqual({ ok: false, error: GATE_REFUSAL }); + }); + it("the gate speaks first: a mirror not ON refuses before any spawn", async () => { const ctx = makeCtx("gate-off", { gateOn: false }); const { seams, calls } = fakeRun({}); @@ -180,7 +186,7 @@ describe("ssh_exec kick + status", () => { mode: 0o600, }); const { seams, calls, finish } = fakeRun({}, { defer: true }); - const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + const kick = await execSshExec(ctx, { ...execCmd(ctx.config.dataDir), type: "ssh_exec_stdin" }, seams); expect(kick).toEqual({ ok: true, data: { started: true, execId: EXEC_ID } }); const configPath = buildSshConfigPath(ctx.config.dataDir, EXEC_ID); @@ -188,12 +194,10 @@ describe("ssh_exec kick + status", () => { // 0600 in a 0700 dir, the launch ssh-member rule (umask cannot leak past enforceMode). expect(statSync(configPath).mode & 0o777).toBe(0o600); - // One argv element for the command: ssh joins operands itself. - expect(calls[0]?.argv).toEqual([ - "/usr/bin/fake-ssh", - ...execCmd(ctx.config.dataDir).presetFlags, - execCmd(ctx.config.dataDir).command, - ]); + // Only a fixed shell command goes in argv; the setup key travels on stdin. + expect(calls[0]?.argv).toEqual(["/usr/bin/fake-ssh", ...execCmd(ctx.config.dataDir).presetFlags, "bash -s"]); + expect(calls[0]?.argv.join(" ")).not.toContain(KEY); + expect(calls[0]?.input).toBe(`${execCmd(ctx.config.dataDir).command}\n`); expect(calls[0]?.env.SSH_AUTH_SOCK).toBe(socket); // derived, never claimed expect(calls[0]?.timeoutMs).toBe(600_000); @@ -232,7 +236,7 @@ describe("ssh_exec kick + status", () => { "==> done.", ].join("\n"); const { seams } = fakeRun({ code: 0, stdout: leaky, stderr: leaky }); - const kick = await execSshExec(ctx, execCmd(ctx.config.dataDir), seams); + const kick = await execSshExec(ctx, { ...execCmd(ctx.config.dataDir), type: "ssh_exec_stdin" }, seams); expect(kick.ok).toBe(true); await new Promise((r) => setTimeout(r, 5)); const status = await execSshExecStatus(ctx, statusCmd(), seams); diff --git a/apps/node/agent/src/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts index 061b4c040..a1ce44c43 100644 --- a/apps/node/agent/src/__tests__/preview-display.test.ts +++ b/apps/node/agent/src/__tests__/preview-display.test.ts @@ -117,6 +117,9 @@ interface DepOverrides { xSocketReady?: boolean; /** Readiness budget override. */ readyBudgetMs?: number; + /** Exit this child the moment it spawns: the way a display server that dies + * on its own (missing peer, exhausted /dev/shm, bad auth) presents. */ + exitsEarly?: "Xvfb" | "x11vnc"; } /** @@ -139,7 +142,9 @@ function makeDeps(over: DepOverrides = {}) { }; let pidSeq = 4100; let clock = Date.now(); + const lines: string[] = []; const deps: DisplayDeps = { + log: (line) => lines.push(line), spawnSync: (argv) => { calls.push({ kind: "spawnSync", argv }); if (String(argv[0]).endsWith("xauth")) writeFileSync(String(argv[2]), "", { mode: 0o600 }); @@ -148,8 +153,13 @@ function makeDeps(over: DepOverrides = {}) { spawn: (argv, opts) => { const child = new FakeChild(pidSeq++); alivePids.add(child.pid); + if (over.exitsEarly && String(argv[0]).endsWith(over.exitsEarly)) child.fireExit(1); const rfbIdx = argv.indexOf("-rfbport"); if (over.x11vncListens !== false && rfbIdx >= 0) busy.add(Number(argv[rfbIdx + 1])); + // The real spawn appends both child streams to `logPath`; a refusal + // reads those tails back into the daemon log, so the double has to put + // something there or the tail lines would be testing their own absence. + writeFileSync(opts.logPath, `# fake ${String(argv[0]).split("/").pop()} output\n`, { flag: "a" }); const jsonFile = join(opts.logPath, "..", "display.json"); calls.push({ kind: "spawn", @@ -189,7 +199,7 @@ function makeDeps(over: DepOverrides = {}) { calls.filter((c): c is Extract => c.kind === "spawn"); const killPids = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal !== 0 ? [c.pid] : [])); const probes = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal === 0 ? [c.pid] : [])); - return { deps, calls, busy, alivePids, spawns, killPids, probes, advanceClock: (ms: number) => (clock += ms) }; + return { deps, calls, busy, alivePids, spawns, killPids, probes, lines, advanceClock: (ms: number) => (clock += ms) }; } /** Let the resolved `exited` promises run their watch handlers. */ @@ -418,6 +428,23 @@ describe("PreviewDisplays.openFor", () => { expect(reg.getFor(PANE)).toBeNull(); }); + it("refuses the moment a display child dies, rather than waiting out the budget", async () => { + // A child that has already exited can never become ready, and the loop + // used to have exactly one way out. Every spawn-time death - a missing + // peer, an exhausted /dev/shm, bad auth - therefore burned the whole + // readiness budget per launch, doubling the wait a Create spinner shows + // and eating a third of the plane's 60 s launch window to learn nothing + // the first poll had not already told. + const dataDir = onDir("child-dies"); + const { deps, killPids } = makeDeps({ xSocketReady: false, exitsEarly: "Xvfb", readyBudgetMs: 10_000 }); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(/gave up: Xvfb exited/); + // Same teardown as a budget expiry: both children signalled, dir and lock gone. + expect(killPids().length).toBe(2); + 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("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 @@ -430,6 +457,53 @@ describe("PreviewDisplays.openFor", () => { expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false); }); + it("names which readiness gate failed when a display is refused", async () => { + // The bare "not answering" sentence sent operators to check the wrong + // program: a missing X socket, a port that never binds, and a listener + // owned by someone else look nothing alike on a slow box. The refusal + // must say which of the three gates was still shut when the budget ran + // out, so the node's own Xvfb.log / x11vnc.log tail (the next case) + // points at the right suspect. + const socket = makeDeps({ xSocketReady: false, readyBudgetMs: 200 }); + const socketReg = new PreviewDisplays(onDir("gate-socket"), socket.deps); + await expect(socketReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow("Xvfb socket never appeared"); + + const bind = makeDeps({ x11vncListens: false, readyBudgetMs: 200 }); + const bindReg = new PreviewDisplays(onDir("gate-bind"), bind.deps); + await expect(bindReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow("rfb port answered nothing"); + + const owner = makeDeps({ readyBudgetMs: 200 }); + const ownerReg = new PreviewDisplays(onDir("gate-owner"), { ...owner.deps, ownsRfbListener: () => false }); + await expect(ownerReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow( + "listener not owned by the x11vnc pid", + ); + }); + + it("logs both children's output when a display is refused, before the dir is reaped", async () => { + // The refusal reaps the pane dir, which held Xvfb.log and x11vnc.log - + // so without this, a slow or broken box left NO trace of why (the AI PC + // failure had nothing to read). The provider must hand both tails to its + // `log` dep before teardown so the daemon journal keeps the evidence. + const dataDir = onDir("refusal-tails"); + const { deps, lines } = makeDeps({ x11vncListens: false, readyBudgetMs: 200 }); + const reg = new PreviewDisplays(dataDir, deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY); + expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false); + expect(lines.some((line) => line.includes("Xvfb.log tail") && line.includes("# fake Xvfb output"))).toBe(true); + expect(lines.some((line) => line.includes("x11vnc.log tail") && line.includes("# fake x11vnc output"))).toBe(true); + }); + + it("stays silent about tails when the refusal predates both children", async () => { + // An xauth mint that fails throws before anything is spawned: the two + // logs are empty pre-created files, and dumping "(empty)" twice is noise + // in the very journal the evidence is meant to enrich. No children were + // started, so no lines about them. + const { deps, lines } = makeDeps({ xauthRc: 1 }); + const reg = new PreviewDisplays(onDir("refusal-early"), deps); + await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY); + expect(lines.some((line) => line.includes("tail"))).toBe(false); + }); + it.skipIf(process.platform === "linux")( "openFor refuses preview-display-unsupported on non-Linux, before touching fs", async () => { diff --git a/apps/node/agent/src/__tests__/setup.test.ts b/apps/node/agent/src/__tests__/setup.test.ts index 3c2eb7b51..c1c5a9f88 100644 --- a/apps/node/agent/src/__tests__/setup.test.ts +++ b/apps/node/agent/src/__tests__/setup.test.ts @@ -466,3 +466,28 @@ test("--json --no-service reports the skip rather than a silent false", async () expect(body.service).toEqual({ installed: false, reason: "declined" }); expect(s.calls).toEqual([]); }); + +test("setup consumes the installer key from its environment without passing it to child processes", async () => { + let receivedKey: unknown; + const url = fakeControlPlane(async (request) => { + receivedKey = (await request.json()).setupKey; + expect(process.env.SUBSHELL_SETUP_KEY).toBeUndefined(); + return Response.json(CANNED, { status: 201 }); + }); + const s = serviceStub(); + const previous = process.env.SUBSHELL_SETUP_KEY; + process.env.SUBSHELL_SETUP_KEY = "nsk_test_0123456789"; + try { + const res = await run( + ["setup", "--server", url, "--name", "private setup", "--data-dir", dataDir, "--no-service"], + { service: s.deps, interactive: false }, + ); + expect(res.code).toBe(0); + expect(receivedKey).toBe("nsk_test_0123456789"); + expect(process.env.SUBSHELL_SETUP_KEY).toBeUndefined(); + expect(res.out + res.err).not.toContain("nsk_test_0123456789"); + } finally { + if (previous === undefined) delete process.env.SUBSHELL_SETUP_KEY; + else process.env.SUBSHELL_SETUP_KEY = previous; + } +}); diff --git a/apps/node/agent/src/cli.ts b/apps/node/agent/src/cli.ts index 87226ed0b..f6432beb5 100644 --- a/apps/node/agent/src/cli.ts +++ b/apps/node/agent/src/cli.ts @@ -76,6 +76,7 @@ const USAGE = `subshell: node daemon usage: subshell setup --server --key [--name ] [--data-dir ] [--no-service] [--yes] [--json] + The installer can supply SUBSHELL_SETUP_KEY instead of --key. check tmux, name this machine, and enroll it. Then choose whether to run it in the background at login. Without --name, setup asks for a name and suggests the @@ -559,6 +560,10 @@ export interface RunDeps { * Never throws for expected failures; the messages are operator-actionable. */ export async function run(argv: string[], deps: RunDeps = {}): Promise { + // The installer supplies this only to setup's process. Drop the inherited + // copy before any probe or service child can receive the credential. + const environmentSetupKey = process.env.SUBSHELL_SETUP_KEY; + delete process.env.SUBSHELL_SETUP_KEY; let parsed: ParsedArgs; try { parsed = parseArgs(argv); @@ -820,7 +825,7 @@ export async function run(argv: string[], deps: RunDeps = {}): Promise"); if (!key) missing.push("--key "); diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index a2e1535cb..6ce0ddeaa 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -78,6 +78,12 @@ export interface WatcherRegistration { * OBJECT reference — this symbol is the explicit handle for callers/tests. */ token: symbol; + /** + * Capture-child guard state (see `guardPipeChild` in report.ts): the last + * screen and log size read on a check tick, the consecutive drift count, + * and the tick counter that spaces the checks. Lazily created. + */ + pipeGuard?: { lastScreen: string; lastSize: number; missed: number; ticks: number }; /** * Consecutive ticks on which this registration's socket probe FAILED to * answer (`listSubshellsChecked` ok:false). An authoritative ok:true answer diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index bc7b3154a..709ff5d4c 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -195,6 +195,7 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) case "ssh_relay_close": return execSshRelayClose(ctx, cmd); case "ssh_exec": + case "ssh_exec_stdin": return await execSshExec(ctx, cmd); case "ssh_exec_status": return await execSshExecStatus(ctx, cmd); diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index d19ade993..32a746fc7 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -10,6 +10,7 @@ import type { import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; import { readPreviewEnabled, reportablePreviewEnabled } from "../preview/flags.js"; +import { selfInvocation } from "../self-invoke.js"; import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; import type { CommandContext, WatcherRegistration } from "./context.js"; @@ -44,6 +45,27 @@ export const EXIT_WATCH_INTERVAL_MS = 2_000; */ export const NODE_EXIT_UNREACHABLE_TICKS = 2; +/** + * How many shared ticks (≈2 s each) between two looks at a live pane's + * capture child. `#{pane_pipe}` is NOT relied on as a liveness signal: two + * measurements of the same tmux 3.6 disagreed on what a killed writer leaves + * it reading (0 vs 1, depending on whether the tracked shell or its grandchild + * died), which is exactly the unreliability that makes the flag unusable as + * the detector. The honest signal is the invariant the whole path serves: + * if the pane's SCREEN moves and the LOG does not, the writer is dead. + */ +export const PIPE_GUARD_CHECK_TICKS = 10; + +/** + * Accumulated drift observations before re-arming, across checks (so the + * re-arm lands a little over a minute into "printing into a dead pipe"). + * Generous on purpose: the check + * itself spawns tmux, and a single race (output landing between the capture + * and the stat) must not spend a re-arm — replacing the pipe closes the old + * child and opens the new one, and bytes in that microsecond gap are lost. + */ +export const PIPE_GUARD_MISSED_LIMIT = 3; + /** * Announce one maintenance state to the plane and remember having done so. * @@ -398,6 +420,7 @@ export async function runExitWatchTick(ctx: CommandContext): Promise { for (const { subshellId, reg } of entries) { if (alive.has(subshellId)) { reg.unreachable = 0; // an authoritative answer breaks any unreachable streak + await guardPipeChild(ctx, socket, subshellId, reg); // the writer can die quietly; see its docstring continue; } // Re-check OWNERSHIP, not just membership: execKill/execTerminate may @@ -431,6 +454,51 @@ export async function runExitWatchTick(ctx: CommandContext): Promise { if (ctx.watchers.size === 0) stopLoop(ctx); // last pane left the building — no idle tick } +/** + * One look at a live pane's capture child: compare its screen with its log. + * Both unchanged → nothing (a quiet pane is not a dead writer). Log moved → + * healthy, reset. Screen moved while the log stood still → strike; enough + * strikes without log progress re-arm the pipe (without `-o`, which replaces the + * child — with it tmux would call the dead writer "already on"). + * + * Every call runs inside the tick's per-pane flow but cannot wound it: any + * failure (a throwing probe, an unstubbed fake in tests) is swallowed — the + * death watch is the tick's real job, this is its insurance. + */ +async function guardPipeChild( + ctx: CommandContext, + socket: string, + subshellId: string, + reg: WatcherRegistration, +): Promise { + try { + let g = reg.pipeGuard; + if (!g) { + g = { lastScreen: "", lastSize: -1, missed: 0, ticks: 0 }; + reg.pipeGuard = g; + } + if (++g.ticks < PIPE_GUARD_CHECK_TICKS) return; + g.ticks = 0; + const screen = await ctx.tmux.capturePane(socket, subshellId); + const size = Bun.file(ctx.meta.logPath(subshellId)).size; + if (size !== g.lastSize) { + g.missed = 0; // the log moved with the screen: healthy + } else if (screen !== g.lastScreen) { + g.missed += 1; // Quiet intervals neither prove health nor add strikes. + } + g.lastScreen = screen; + g.lastSize = size; + if (g.missed < PIPE_GUARD_MISSED_LIMIT) return; + g.missed = 0; + const logFile = ctx.meta.logPath(subshellId); + ctx.tmux.pipePane(socket, subshellId, logFile, selfInvocation("pane-log"), { replace: true }); + log(`exit watcher: pane ${subshellId} printed while its replay log stood still; re-armed the capture child`); + } catch { + // The guard must never cost the tick its death watch (or a test suite its + // unstubbed fakes). + } +} + /** * The per-pane death sequence — SHARED by both report paths (confirmed-absent * on an ok:true probe, and threshold-escalated unreachable) so a blip that diff --git a/apps/server/api/docs/backups.md b/apps/server/api/docs/backups.md index 4ef7b169c..c88f705e3 100644 --- a/apps/server/api/docs/backups.md +++ b/apps/server/api/docs/backups.md @@ -25,6 +25,12 @@ validated configuration payload and exposes only public form fields. Older archives may omit source locations, with local fallback only for missing values. Legacy `.db` is explicitly database-only and refuses all journal sidecars. +When adding an application migration, advance `LATEST_BACKUP_MIGRATION` in +`modules/backups/database.ts` with the static boot migration map. Otherwise a +server rejects its own new backups as newer than its supported schema. The +backup engine test pins the two values, and the older-upgrade subprocess fixture +removes and reapplies the migration tail, including each new table or column. + Config application preserves a hard link to the original, flushes it, and atomically overwrites the target. Config rollback atomically overwrites too; config.env must remain present because systemd reads it before starting the diff --git a/apps/server/api/docs/ws-attach.md b/apps/server/api/docs/ws-attach.md index 4e0f03d0c..99ea8f3f2 100644 --- a/apps/server/api/docs/ws-attach.md +++ b/apps/server/api/docs/ws-attach.md @@ -64,8 +64,13 @@ attach, both under `journalctl --user -u subshell-server.service | grep "ws atta server bug; static requests are not logged, so this is the only signal. `build=MISSING` is a bundle older than the field; `build=dev` is a dev server. -- `painted repainted= nudged= replay=B dump=`: what - the pane did before the capture. +- `painted repainted= nudged= cursor=,|none replay=B dump=`: what + the pane did before the capture. `cursor=none` means the replay shipped + WITHOUT its absolute cursor restore (the read answered nothing): the + client's base cursor is then wherever the capture left it, and every later + relative-positioned frame (a TUI repainting its pinned spinner block) lands + a constant offset off. On a "fixed elements overlap the transcript" report, + read this field before blaming the stream. `repainted=false nudged=true` means the geometry CHANGED and the pane refused to repaint even for a forced SIGWINCH, so a bad replay is the pane's own state; `repainted=true` means a freshly painted frame was diff --git a/apps/server/api/package.json b/apps/server/api/package.json index 6d4b1e67e..011b937b3 100644 --- a/apps/server/api/package.json +++ b/apps/server/api/package.json @@ -53,6 +53,7 @@ "dotenv": "17.4.2", "elysia": "1.4.29", "env-var": "7.5.0", + "es-module-lexer": "3.0.4", "expo-server-sdk": "7.2.0", "file-type": "22.0.2", "jose": "6.2.13", @@ -60,6 +61,7 @@ "kysely-bun-sqlite-dialect": "1.0.0", "loglayer": "9.4.0", "nanoid": "6.0.1", + "postcss-value-parser": "4.2.0", "sanitize-filename": "1.6.4", "serialize-error": "13.0.1", "tar-stream": "3.2.1", diff --git a/apps/server/api/src/api/__tests__/downloads-route.test.ts b/apps/server/api/src/api/__tests__/downloads-route.test.ts index 331583565..29488fce9 100644 --- a/apps/server/api/src/api/__tests__/downloads-route.test.ts +++ b/apps/server/api/src/api/__tests__/downloads-route.test.ts @@ -347,7 +347,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { const body = await res.text(); expect(body).toContain("set -euo pipefail"); expect(body).toContain("exit 2"); - expect(body).toContain("usage"); + expect(body).toContain("copy the generated setup command"); expect(body).not.toContain("api/downloads"); // no download pipeline is rendered }); @@ -404,7 +404,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { // The whole sequence is ONE verb now (spec 2026-09-15 §4.5). `enroll` // alone left the operator with no running agent and nothing naming // `service install`; `setup` asks about the service and installs it. - expect(body).toContain('"$DEST" setup --server "$SERVER" --key "$KEY"'); + expect(body).toContain('SUBSHELL_SETUP_KEY="$KEY" "$DEST" setup --server "$SERVER"'); expect(body).not.toContain('"$DEST" enroll --server "$SERVER"'); // The foreground dead end is GONE: `subshell run` dies with the SSH // session, so the script must not end by recommending it. @@ -432,7 +432,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { expect(body).toContain('elif [ -n "$REREG_NAME" ]; then'); expect(body).toContain('SETUP_NAME_ARGS=(--name "$REREG_NAME")'); // It is still the SAME pipeline otherwise (one verb, same guards). - expect(body).toContain('"$DEST" setup --server "$SERVER" --key "$KEY"'); + expect(body).toContain('SUBSHELL_SETUP_KEY="$KEY" "$DEST" setup --server "$SERVER"'); expect(body).not.toContain("usage: curl"); }); @@ -697,7 +697,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { // bare 64-hex before ANY comparison: a body shaped like HTML or an error // line is a miss, and an empty-vs-empty match must never skip. expect(body).toContain('SKIP_DOWNLOAD=""'); - expect(body).toContain('$TARGET.sha256?setup_key=$KEY" 2>/dev/null'); + expect(body).toContain('$TARGET.sha256?setup_key=$KEY" --fail --silent --location 2>/dev/null'); expect(body).toContain('|| EXPECTED_SHA=""'); expect(body).toContain("*[!0-9a-fA-F]*"); // The tool is UNQUOTED as the command word: quoted, "shasum -a 256" is @@ -895,6 +895,8 @@ describe("/api/downloads + /install.sh (assembled app)", () => { join(bin, "curl"), [ "#!/usr/bin/env bash", + `printf '%s\\n' "$@" >> '${join(work, "curl-args.log")}'`, + `IFS= read -r config; printf '%s\\n' "$config" >> '${join(work, "curl-input.log")}'`, 'out=""', 'prev=""', 'for a in "$@"; do', @@ -902,7 +904,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { ' prev="$a"', "done", 'if [ -n "$out" ]; then', - ' printf \'%s\\n\' \'#!/usr/bin/env bash\' \'for a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\' > "$out"', + ' printf \'%s\\n\' \'#!/usr/bin/env bash\' \'if [ "$1" = "--help" ]; then [ "$PRIVATE_KEY_UNSUPPORTED" = 1 ] || echo SUBSHELL_SETUP_KEY >&2; exit 0; fi\' \'for a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\' \'printf "%s" "$SUBSHELL_SETUP_KEY" > "$ENROLL_LOG.key"\' > "$out"', " printf 200", // the binary leg reads the HTTP code off stdout "else", " printf '%s\\n' \"$(printf '0%.0s' $(seq 1 64))\"", // 64 zeros; the verifier is stubbed too @@ -1003,8 +1005,11 @@ describe("/api/downloads + /install.sh (assembled app)", () => { const def = runBranch(cwd1, {}); expect(def.exitCode).toBe(0); // stderr may carry the (expected) loopback WARNING expect(def.args[0]).toBe("setup"); // NOT enroll: the sequence is one verb - expect(def.args).toContain("--key"); - expect(def.args).toContain(key); + expect(def.args).not.toContain("--key"); + expect(def.args).not.toContain(key); + expect(readFileSync(join(work, "enroll-cwd-default.log.key"), "utf8")).toBe(key); + expect(readFileSync(join(work, "curl-args.log"), "utf8")).not.toContain(key); + expect(readFileSync(join(work, "curl-input.log"), "utf8")).toContain(key); expect(def.args).not.toContain("--data-dir"); // the whole point: the agent keeps its own default data dir expect(def.args).not.toContain("--no-service"); // unset knob forwards nothing expect(def.args).not.toContain("--name"); // unset NAME knob forwards nothing either — setup asks @@ -1012,6 +1017,14 @@ describe("/api/downloads + /install.sh (assembled app)", () => { expect(existsSync(join(cwd1, "subshell"))).toBe(false); expect(existsSync(join(def.home, ".local", "bin", "subshell.sha256"))).toBe(false); // sidecar cleaned up + // An older downloaded binary must fail before enrollment; no argv fallback. + const old = runBranch(join(work, "cwd-old-node"), { PRIVATE_KEY_UNSUPPORTED: "1" }); + expect(old.exitCode).toBe(1); + expect(old.stdout + old.stderr).toContain("can't accept a setup key privately"); + expect(old.stdout + old.stderr).not.toContain(key); + expect(old.args).toEqual([]); + expect(existsSync(join(work, "enroll-cwd-old-node.log.key"))).toBe(false); + // ── opt-in (env set): relocated dest, state follows the binary ── const cwd2 = join(work, "cwd-relocated"); const dest = join(cwd2, "deep", "subshell-data"); // missing parents also pin `mkdir -p` @@ -1058,7 +1071,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { const planted = join(skipDestDir, "subshell"); writeFileSync( planted, - '#!/usr/bin/env bash\n# PREPLANTED\nfor a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\n', + '#!/usr/bin/env bash\n# PREPLANTED\nif [ "$1" = "--help" ]; then echo SUBSHELL_SETUP_KEY >&2; exit 0; fi\nfor a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\n', ); chmodSync(planted, 0o755); const skip = runBranch(cwdSkip, {}); @@ -1106,7 +1119,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { mkdirSync(p.replace(/\/subshell$/, ""), { recursive: true }); writeFileSync( p, - '#!/usr/bin/env bash\n# PREPLANTED\nfor a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\n', + '#!/usr/bin/env bash\n# PREPLANTED\nif [ "$1" = "--help" ]; then echo SUBSHELL_SETUP_KEY >&2; exit 0; fi\nfor a in "$@"; do printf "%s\\n" "$a" >> "$ENROLL_LOG"; done\n', ); chmodSync(p, 0o755); } @@ -1204,6 +1217,8 @@ describe("/api/downloads + /install.sh (assembled app)", () => { join(bin, "curl"), [ "#!/usr/bin/env bash", + `printf '%s\\n' "$@" >> '${join(work, "curl-args.log")}'`, + `IFS= read -r config; printf '%s\\n' "$config" >> '${join(work, "curl-input.log")}'`, 'out=""', 'prev=""', 'for a in "$@"; do', @@ -1215,7 +1230,7 @@ describe("/api/downloads + /install.sh (assembled app)", () => { // `#!/usr/bin/env bash` header could not resolve bash. ENOEXEC // makes the calling bash run it as a shell script, which is all // this case needs from it (the setup line merely has to run). - " printf 'echo SETUP-RAN\\n' > \"$out\"", + ' printf \'if [ "$1" = --help ]; then echo SUBSHELL_SETUP_KEY >&2; exit 0; fi\\necho SETUP-RAN\\n\' > "$out"', " printf 200", "else", " printf '%s\\n' \"$(printf '0%.0s' $(seq 1 64))\"", diff --git a/apps/server/api/src/api/install-script.ts b/apps/server/api/src/api/install-script.ts index 4e744e294..5565a073c 100644 --- a/apps/server/api/src/api/install-script.ts +++ b/apps/server/api/src/api/install-script.ts @@ -19,8 +19,7 @@ function usageScript(): string { # subshell installer: a valid one-time setup key is required (spec 2026-08-31 §5.1/§8). set -euo pipefail -echo "usage: curl -fsSL \\"${APP_BASE_URL}/install.sh?setup_key=SETUP_KEY\\" | bash" >&2 -echo " mint a key first: the Nodes page → Add node, in the subshell web UI." >&2 +echo "Open Nodes in the dashboard and copy the generated setup command for this node." >&2 exit 2 `; } @@ -134,6 +133,16 @@ set -euo pipefail SERVER="${server}" KEY="${key}" + +# Use curl's stdin config so setup keys never appear in curl's process argv. +# Escaping is for curl's config syntax, independently of shell quoting. +fetch_private() { + local url="$1" + shift + url="\${url//\\\\/\\\\\\\\}" + url="\${url//\\"/\\\\\\"}" + printf 'url = "%s"\\n' "$url" | curl --config - "$@" +} ${ reRegisterName === null ? "" @@ -232,8 +241,7 @@ fi SKIP_DOWNLOAD="" EXPECTED_SHA="" if [ -f "$DEST" ] && [ -n "$SHA_TOOL" ]; then - EXPECTED_SHA="$(curl --fail --silent --location \\ - "$SERVER/api/downloads/node/$TARGET.sha256?setup_key=$KEY" 2>/dev/null | tr -d '[:space:]')" || EXPECTED_SHA="" + EXPECTED_SHA="$(fetch_private "$SERVER/api/downloads/node/$TARGET.sha256?setup_key=$KEY" --fail --silent --location 2>/dev/null | tr -d '[:space:]')" || EXPECTED_SHA="" case "$EXPECTED_SHA" in '' | *[!0-9a-fA-F]*) EXPECTED_SHA="" ;; *) [ "\${#EXPECTED_SHA}" = "64" ] || EXPECTED_SHA="" ;; @@ -332,8 +340,7 @@ else # every network failure too). The progress bar is on the wire because this # fetch is ~100 MB and --silent made minutes of healthy transfer indistinguishable from a hang. TMP="$DEST.part" - if ! HTTP="$(curl --show-error --location --progress-bar \\ - "$SERVER/api/downloads/node/$TARGET?setup_key=$KEY" \\ + if ! HTTP="$(fetch_private "$SERVER/api/downloads/node/$TARGET?setup_key=$KEY" --show-error --location --progress-bar \\ --output "$TMP" --write-out '%{http_code}')"; then rm -f "$TMP" 2>/dev/null || true echo "subshell: could not reach $SERVER; nothing was installed ($DEST untouched)." >&2 @@ -378,8 +385,7 @@ else # shasum -a 256 -c both take the " " spelling. A mismatch is a # corrupt download or a server serving a stale sidecar — either way the old # $DEST survives. - if ! EXPECTED="$(curl --fail --silent --show-error --location \\ - "$SERVER/api/downloads/node/$TARGET.sha256?setup_key=$KEY" | tr -d '[:space:]')"; then + if ! EXPECTED="$(fetch_private "$SERVER/api/downloads/node/$TARGET.sha256?setup_key=$KEY" --fail --silent --show-error --location | tr -d '[:space:]')"; then rm -f "$TMP" 2>/dev/null || true echo "subshell: could not fetch the checksum; nothing was installed ($DEST untouched)." >&2 exit 1 @@ -491,8 +497,17 @@ echo "==> re-registering the existing node \\"$REREG_NAME\\": this machine takes # expansions a single source; the ONLY difference is who answers setup's # questions. set -e still aborts on a failed setup: it sits in an if-BRANCH, # not in a condition (measured posture the old \`exec\` comment pinned). +# Older downloaded or reused binaries must fail closed, not fall back to a +# setup key in argv. The help text advertises this input without exposing it. +case "$("$DEST" --help 2>&1)" in + *SUBSHELL_SETUP_KEY*) ;; + *) + echo "subshell: this node binary can't accept a setup key privately. Update the server's node download and run setup again." >&2 + exit 1 + ;; +esac run_setup() { - "$DEST" setup --server "$SERVER" --key "$KEY" \${SETUP_DATA_DIR_ARGS[@]+"\${SETUP_DATA_DIR_ARGS[@]}"} \${SETUP_SERVICE_ARGS[@]+"\${SETUP_SERVICE_ARGS[@]}"} \${SETUP_NAME_ARGS[@]+"\${SETUP_NAME_ARGS[@]}"} + SUBSHELL_SETUP_KEY="$KEY" "$DEST" setup --server "$SERVER" \${SETUP_DATA_DIR_ARGS[@]+"\${SETUP_DATA_DIR_ARGS[@]}"} \${SETUP_SERVICE_ARGS[@]+"\${SETUP_SERVICE_ARGS[@]}"} \${SETUP_NAME_ARGS[@]+"\${SETUP_NAME_ARGS[@]}"} } if [ -n "$CAN_PROMPT" ]; then run_setup < /dev/tty diff --git a/apps/server/api/src/api/previews/http.ts b/apps/server/api/src/api/previews/http.ts index 2e117962a..0ae30bfed 100644 --- a/apps/server/api/src/api/previews/http.ts +++ b/apps/server/api/src/api/previews/http.ts @@ -3,6 +3,9 @@ import { Elysia } from "elysia"; import { LaneDuplex } from "@/modules/previews/lane-duplex.js"; import { LaneClosedError, LaneProtocolError, sendLaneRequest } from "@/modules/previews/lane-http.js"; import { getPreviewBroker, PreviewRefusal } from "@/modules/previews/preview-broker.js"; +import { previewUrl } from "@/modules/previews/preview-url.js"; +import { DEFAULT_PREVIEW_PROXY_SETTINGS } from "@/modules/previews/proxy-settings.js"; +import { transformPreviewResponse } from "@/modules/previews/response-transform.js"; import { HttpError } from "@/shared/errors.js"; /** @@ -56,6 +59,11 @@ const INBOUND_DROP = new Set([ "host", "content-length", "authorization", + "referer", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "sec-fetch-user", "x-api-key", "forwarded", "remote-user", @@ -248,6 +256,23 @@ async function proxyRequest(request: Request, sessionRef: string, rest: string): const jarCookie = jarCookieHeader(session.cookieJar); if (jarCookie) headers.push(["cookie", jarCookie]); + const settings = session.proxySettings ?? DEFAULT_PREVIEW_PROXY_SETTINGS; + const targetOrigin = `${session.service?.protocol ?? "http"}://${serviceAuthority(session.service?.host ?? "127.0.0.1", session.port)}`; + const proxyBase = `${url.origin}/pv/${sessionRef}/`; + if (settings.passHost) headers[0] = ["host", url.host]; + // These are capability-authorized server-to-server requests. The sandbox's + // opaque origin describes the browser→proxy hop, not the backend request. + // Leave unrelated Origin values intact; never translate them into trusted ones. + for (let i = headers.length - 1; i >= 0; i--) { + const [name, value] = headers[i]; + if (name === "origin" && (value === "null" || value === url.origin)) headers.splice(i, 1); + } + if (settings.rewriteUrls) { + for (let i = headers.length - 1; i >= 0; i--) + if (["accept-encoding", "range", "if-range", "if-none-match", "if-modified-since"].includes(headers[i][0])) + headers.splice(i, 1); + headers.push(["accept-encoding", "identity"]); + } const isHead = request.method === "HEAD"; try { const res = await sendLaneRequest( @@ -268,15 +293,25 @@ async function proxyRequest(request: Request, sessionRef: string, rest: string): }, ); const headersOut = outHeadersToWeb(res.headers, session.cookieJar, sessionRef); + const location = res.headers.find(([name]) => name === "location")?.[1]; + if (location) { + const mapped = settings.rewriteRedirects + ? previewUrl(location, `${targetOrigin}/${rest}${url.search}`, targetOrigin, proxyBase) + : rewriteLocation(location, sessionRef); + headersOut.set("location", mapped.startsWith(proxyBase) ? mapped.slice(url.origin.length) : mapped); + } if (res.body === null) { duplex.destroy(); releaseLane(); } - return new Response(res.body, { + const response = new Response(res.body, { status: res.status, statusText: res.statusText, headers: headersOut, }); + return settings.rewriteUrls && !isHead + ? await transformPreviewResponse(response, targetOrigin, proxyBase, `/${rest}${url.search}`) + : response; } catch (err) { duplex.destroy(); releaseLane(); diff --git a/apps/server/api/src/api/previews/ws.ts b/apps/server/api/src/api/previews/ws.ts index 18be077db..b69968726 100644 --- a/apps/server/api/src/api/previews/ws.ts +++ b/apps/server/api/src/api/previews/ws.ts @@ -61,6 +61,7 @@ interface PvTunnel { laneRef: string; mode: "http" | "raw"; port: number; + hostHeader?: string; /** Suffix + query the browser dialed (http-mode redial path; raw ignores it). */ rest: string; search: string; @@ -162,7 +163,8 @@ export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { // own negotiation already completed with Bun. const forward: [string, string][] = []; const originHeader = request.headers.get("origin"); - if (originHeader) forward.push(["origin", originHeader]); + if (originHeader && originHeader !== "null" && originHeader !== url.origin) + 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, { @@ -171,6 +173,7 @@ export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { laneRef, mode: session.mode, port: session.port, + hostHeader: session.proxySettings?.passHost ? url.host : undefined, rest: params["*"] ?? "", search: url.search, forward, @@ -242,6 +245,7 @@ export function previewWsRoute(options: { preAckMaxBytes?: number } = {}) { const jarCookie = jarCookieHeader(t.session.cookieJar); const redial = openLaneWebSocket(duplex, { host: t.session.service?.host, + hostHeader: t.hostHeader, port: t.port, path: `/${t.rest}${t.search}`, headers: jarCookie ? [...t.forward, ["cookie", jarCookie]] : t.forward, diff --git a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts index ffac0325d..95d73db1d 100644 --- a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts @@ -109,6 +109,12 @@ const jsonBody = async (res: Response) => (await res.json()) as Record `/${id}/views/settings`, + body: { target: { port: 3000 }, action: "read" }, + }, { 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 } }, @@ -308,6 +314,139 @@ describe("preview REST surface (Task 9)", () => { expect(((await saved.json()) as { host: string }).host).toBe("localhost"); }); + it("saves personal target settings across subshells, snapshots opens, and resets", async () => { + attachLive(); + const pane = await directRow(); + const other = await directRow(); + await grantPane(pane, editorId, "edit"); + const settings = { rewriteUrls: false, passHost: true, rewriteRedirects: false }; + const settingsCall = (id: string, action: string, cookie = ownerCookie, target: unknown = { port: 3000 }) => + call("POST", `/${id}/views/settings`, { cookie, body: { target, action, settings } }); + expect((await settingsCall(pane, "save")).status).toBe(200); + expect(await (await settingsCall(other, "read")).json()).toEqual(settings); + expect(await (await settingsCall(pane, "read", editorCookie)).json()).toEqual({ + rewriteUrls: true, + passHost: false, + rewriteRedirects: true, + }); + expect(await (await settingsCall(other, "read", ownerCookie, { port: 3001 })).json()).toEqual({ + rewriteUrls: true, + passHost: false, + rewriteRedirects: true, + }); + const opened = await call("POST", `/${other}/views/open`, { cookie: ownerCookie, body: { port: 3000 } }); + const { proxyBase } = (await opened.json()) as { proxyBase: string }; + expect(getPreviewBroker().sessionFor(proxyBase.split("/")[2] ?? "")?.proxySettings).toEqual(settings); + expect((await settingsCall(other, "reset")).status).toBe(200); + expect(await (await settingsCall(pane, "read")).json()).toEqual({ + rewriteUrls: true, + passHost: false, + rewriteRedirects: true, + }); + expect(getPreviewBroker().sessionFor(proxyBase.split("/")[2] ?? "")?.proxySettings).toEqual(settings); + }); + + it("uses saved service targets without granting access or crossing protocols", async () => { + attachLive(); + const pane = await directRow(); + const saveService = async (protocol: "http" | "https", host: string) => { + const response = await call("POST", `/${pane}/services`, { + cookie: ownerCookie, + body: { name: protocol, port: 4443, host, protocol }, + }); + expect(response.status).toBe(200); + return (await response.json()) as { id: string }; + }; + const first = await saveService("http", "LOCALHOST"); + const second = await saveService("https", "localhost"); + const settings = { rewriteUrls: false, passHost: true, rewriteRedirects: false }; + const save = await call("POST", `/${pane}/views/settings`, { + cookie: ownerCookie, + body: { target: { serviceId: first.id }, action: "save", settings }, + }); + expect(save.status).toBe(200); + const read = await call("POST", `/${pane}/views/settings`, { + cookie: ownerCookie, + body: { target: { serviceId: second.id }, action: "read" }, + }); + expect(await read.json()).toEqual({ rewriteUrls: true, passHost: false, rewriteRedirects: true }); + expect( + ( + await call("POST", `/${pane}/views/settings`, { + cookie: foreignCookie, + body: { target: { serviceId: first.id }, action: "read" }, + }) + ).status, + ).toBe(404); + await call("POST", `/${pane}/views/settings`, { + cookie: ownerCookie, + body: { target: { serviceId: first.id }, action: "reset" }, + }); + }); + + it("caps saved targets per user while allowing updates and resets", async () => { + attachLive(); + const pane = await directRow(); + await db.deleteFrom("previewProxySettings").where("userId", "=", ownerId).execute(); + await db + .insertInto("previewProxySettings") + .values( + Array.from({ length: 256 }, (_, i) => ({ + userId: ownerId, + nodeId, + host: "localhost", + protocol: "http", + port: 10000 + i, + rewriteUrls: 1, + passHost: 0, + rewriteRedirects: 1, + })), + ) + .execute(); + const settings = { rewriteUrls: false, passHost: true, rewriteRedirects: false }; + const change = (port: number, action: string) => + call("POST", `/${pane}/views/settings`, { cookie: ownerCookie, body: { target: { port }, action, settings } }); + expect((await change(3000, "save")).status).toBe(409); + expect((await change(10000, "save")).status).toBe(200); + expect((await change(10000, "reset")).status).toBe(200); + expect((await change(3000, "save")).status).toBe(200); + await db.deleteFrom("previewProxySettings").where("userId", "=", ownerId).execute(); + }); + + it("settings cannot bypass edit access or the node preview switch", async () => { + attachLive(); + const pane = await directRow(); + await grantPane(pane, viewerId, "view"); + for (const action of ["read", "save", "reset"]) { + const body = { + target: { port: 3000 }, + action, + settings: { rewriteUrls: true, passHost: false, rewriteRedirects: true }, + }; + expect((await call("POST", `/${pane}/views/settings`, { cookie: viewerCookie, body })).status).toBe(403); + expect((await call("POST", `/${pane}/views/settings`, { cookie: foreignCookie, body })).status).toBe(404); + } + await nodes.setPreviewEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + // Display-only nodes still own preview preferences for their screen port. + expect( + ( + await call("POST", `/${pane}/views/settings`, { + cookie: ownerCookie, + body: { target: { port: 5900 }, action: "read" }, + }) + ).status, + ).toBe(200); + await nodes.setPreviewEnabled(nodeId, { feature: "display", on: false, changedAt: new Date().toISOString() }); + expect( + ( + await call("POST", `/${pane}/views/settings`, { + cookie: ownerCookie, + body: { target: { port: 3000 }, action: "read" }, + }) + ).status, + ).toBe(409); + }); + it("the owner reaches all four doors", async () => { attachLive(); const pane = await directRow(); @@ -343,7 +482,7 @@ describe("preview REST surface (Task 9)", () => { ...("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); + expect(res.status).toBe(door.label === "open" || door.label === "settings" ? 403 : 200); } }); @@ -907,6 +1046,11 @@ describe("preview REST surface (Task 9)", () => { expect(res.status).toBe(400); const body = await errorBody(res); expect(body.message).toMatch(/Enable Allow virtual displays/i); + // The marker is the wire contract, not decoration: the dashboard matches on + // `preview-node-off` to decide that this refusal belongs beside the Virtual + // display checkbox rather than under Save as preset. A rewording that drops + // it re-creates the original bug with this suite still green. + expect(body.message).toContain("(preview-node-off)"); expect(sim.countOf("launch")).toBe(0); expect(await paneCount(ownerId)).toBe(before); }); @@ -1069,15 +1213,14 @@ describe("preview REST surface (Task 9)", () => { // 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. + // switch flipped mid-race). Every row echoes the node's own detail, bare + // form included: the word is what an operator greps in the daemon log, and + // what `isDisplayLaunchRefusal` (apps/server/web) matches to render the + // refusal beside the Virtual display control that asked for the screen. const SCREEN_REFUSALS: ReadonlyArray = [ - ["preview-display-unsupported", /provider is not supported/, false], + ["preview-display-unsupported", /provider is not supported/, true], ["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], + ["preview-node-off: turn on Preview for this node", /Turn on Preview for this node/i, true], ]; it.each(SCREEN_REFUSALS)( diff --git a/apps/server/api/src/api/subshells/preview-gate.ts b/apps/server/api/src/api/subshells/preview-gate.ts index b62eed9bd..029a1f8b1 100644 --- a/apps/server/api/src/api/subshells/preview-gate.ts +++ b/apps/server/api/src/api/subshells/preview-gate.ts @@ -87,7 +87,7 @@ export async function recheckPreviewOpen( nodeId: string, min: "view" | "edit", actor: GuardActor, - feature: "web-previews" | "display" = "web-previews", + feature: "web-previews" | "display" | "any" = "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); diff --git a/apps/server/api/src/api/subshells/views-open.route.ts b/apps/server/api/src/api/subshells/views-open.route.ts index 65fe70813..8cb248f52 100644 --- a/apps/server/api/src/api/subshells/views-open.route.ts +++ b/apps/server/api/src/api/subshells/views-open.route.ts @@ -5,6 +5,7 @@ import { apiModels } from "@/api/schemas/index.js"; import { gatePreview, previewNodeState, recheckPreviewOpen } from "@/api/subshells/preview-gate.js"; import { NodeRpcError } from "@/modules/nodes/node-rpc.js"; import { getPreviewBroker, PreviewRefusal } from "@/modules/previews/preview-broker.js"; +import { readPreviewProxySettings, savePreviewProxySettings } from "@/modules/previews/proxy-settings.js"; import { detailIsNodeOff, PREVIEW_PANE_GONE } from "@/modules/previews/refusals.js"; import { getService } from "@/modules/subshells/saved-services.js"; import { HttpError } from "@/shared/errors.js"; @@ -17,16 +18,22 @@ import { HttpError } from "@/shared/errors.js"; * 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)", +const PreviewOpenBodySchema = t.Union( + [ + t.Object({ + port: t.Integer({ + minimum: 1, + maximum: 65_535, + description: + "Loopback port on the subshell's node to forward. Saved serviceId targets use their stored host instead.", + }), }), - }), - t.Object({ serviceId: t.String({ format: "uuid" }) }), -]); + t.Object({ + serviceId: t.String({ format: "uuid", description: "Saved service whose host and protocol the node will use" }), + }), + ], + { description: "Target to open or configure: a loopback port or a saved service" }, +); const PreviewOpenResponseSchema = t.Object({ proxyBase: t.String({ @@ -34,6 +41,22 @@ const PreviewOpenResponseSchema = t.Object({ }), }); +const PreviewSettingsSchema = t.Object( + { + rewriteUrls: t.Boolean({ description: "Rewrite app resource URLs through the preview" }), + passHost: t.Boolean({ description: "Forward the preview host header to the app" }), + rewriteRedirects: t.Boolean({ description: "Rewrite app redirects through the preview" }), + }, + { description: "Personal proxy preferences to apply to this destination" }, +); +const PreviewSettingsBodySchema = t.Object({ + target: PreviewOpenBodySchema, + action: t.Union([t.Literal("read"), t.Literal("save"), t.Literal("reset")], { + description: "Read, save, or reset your target preferences", + }), + settings: t.Optional(PreviewSettingsSchema), +}); + export const viewsOpenRoute = new Elysia() .use(contextPlugin) .use(authGuard) @@ -75,6 +98,13 @@ export const viewsOpenRoute = new Elysia() port, service: service ? { host: service.host, protocol: service.protocol } : undefined, serviceId: service?.id, + proxySettings: await readPreviewProxySettings({ + userId: user.id, + nodeId: row.nodeId, + host: service?.host ?? "localhost", + protocol: service?.protocol ?? "http", + port, + }), authorize: async () => { if (service) await getService(params.id, service.id); await recheckPreviewOpen(user.id, params.id, row.nodeId, "edit", actor, feature); @@ -127,6 +157,39 @@ export const viewsOpenRoute = new Elysia() "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.", }, }, + ) + .post( + "/:id/views/settings", + async ({ params, body, user, actor, apiKeyPermissions }) => { + requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + const { row } = await gatePreview(user.id, params.id, "edit", actor); + await recheckPreviewOpen(user.id, params.id, row.nodeId, "edit", actor, "any"); + const service = "serviceId" in body.target ? await getService(params.id, body.target.serviceId) : undefined; + const target = { + userId: user.id, + nodeId: row.nodeId, + host: service?.host ?? "localhost", + protocol: service?.protocol ?? "http", + port: service?.port ?? ("port" in body.target ? body.target.port : 0), + }; + if (body.action === "read") return readPreviewProxySettings(target); + if (body.action === "reset") return savePreviewProxySettings(target, null); + if (!body.settings) throw new HttpError(400, "Choose the preview settings to save."); + return savePreviewProxySettings(target, body.settings); + }, + { + body: PreviewSettingsBodySchema, + response: { + 200: PreviewSettingsSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + 409: "ApiErrorResponse", + }, + systemPermission: "subshells:execute", + detail: { operationId: "previewProxySettings", tags: ["subshells"] }, + }, ); /** The one 409 every "cannot talk to that node" branch renders. */ diff --git a/apps/server/api/src/api/ws/__tests__/attach-forensics.test.ts b/apps/server/api/src/api/ws/__tests__/attach-forensics.test.ts index 6cedb66f0..1acdb256b 100644 --- a/apps/server/api/src/api/ws/__tests__/attach-forensics.test.ts +++ b/apps/server/api/src/api/ws/__tests__/attach-forensics.test.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { forensicsEnabled, recordAttachPaint, setForensicsEnabledForTests } from "@/api/ws/attach-forensics.js"; +import { logger } from "@/shared/logger.js"; /** * Attach forensics (2026-09-01) — the per-attach evidence dump that makes the @@ -25,7 +26,14 @@ describe("attach forensics", () => { // Screen contents can hold secrets, and every attach writing to /tmp // would churn the disk — the dump is opt-in per instance. expect(forensicsEnabled()).toBe(false); - recordAttachPaint({ subshellId: SID, preResize: "BEFORE", replay: "AFTER", repainted: true, nudged: false }); + recordAttachPaint({ + subshellId: SID, + preResize: "BEFORE", + replay: "AFTER", + repainted: true, + nudged: false, + cursor: null, + }); expect(existsSync(`${ROOT}/${SID}`)).toBe(false); }); @@ -37,6 +45,7 @@ describe("attach forensics", () => { replay: "CLEAN-AFTER-REPAINT", repainted: true, nudged: true, + cursor: { x: 3, y: 2 }, }); // One timestamped directory per attach, so successive attaches on one @@ -52,7 +61,14 @@ describe("attach forensics", () => { // A stale client sends no geometry ⇒ no resize ⇒ nothing to capture // "before" it. The replay half is still the evidence that matters. setForensicsEnabledForTests(true); - recordAttachPaint({ subshellId: SID, preResize: null, replay: "REPLAY", repainted: false, nudged: false }); + recordAttachPaint({ + subshellId: SID, + preResize: null, + replay: "REPLAY", + repainted: false, + nudged: false, + cursor: { x: 0, y: 0 }, + }); const files = [...new Bun.Glob("*/*.txt").scanSync(`${ROOT}/${SID}`)]; const dir = `${ROOT}/${SID}/${files[0].split("/")[0]}`; @@ -72,7 +88,41 @@ describe("attach forensics", () => { replay: "R", repainted: false, nudged: false, + cursor: { x: 7, y: 1 }, }), ).not.toThrow(); }); + + it("names the shipped cursor on the journal line, because a missing restore IS a diagnosis", () => { + // The `painted` line is what a "still garbled" report gets read from; + // `cursor=none` there means the replay shipped without its absolute + // restore, the known degrade that puts every later relative-positioned + // frame (a TUI's pinned-block repaints) on the wrong rows. + const seen: string[] = []; + const spy = spyOn(logger, "info").mockImplementation((msg: string) => { + seen.push(msg); + }); + try { + recordAttachPaint({ + subshellId: SID, + preResize: null, + replay: "R", + repainted: false, + nudged: false, + cursor: { x: 3, y: 2 }, + }); + recordAttachPaint({ + subshellId: SID, + preResize: null, + replay: "R", + repainted: false, + nudged: false, + cursor: null, + }); + } finally { + spy.mockRestore(); + } + expect(seen.some((l) => l.includes("cursor=3,2"))).toBe(true); + expect(seen.some((l) => l.includes("cursor=none"))).toBe(true); + }); }); diff --git a/apps/server/api/src/api/ws/__tests__/input-budget.test.ts b/apps/server/api/src/api/ws/__tests__/input-budget.test.ts new file mode 100644 index 000000000..6ae080848 --- /dev/null +++ b/apps/server/api/src/api/ws/__tests__/input-budget.test.ts @@ -0,0 +1,363 @@ +import { afterEach, expect, it } from "bun:test"; +import { + INPUT_FRAME_MAX_BYTES, + INPUT_GLOBAL_MAX_BYTES, + INPUT_GLOBAL_MAX_FRAMES, + INPUT_NODE_MAX_BYTES, + INPUT_NODE_MAX_FRAMES, + INPUT_SOCKET_MAX_BYTES, + INPUT_SOCKET_MAX_FRAMES, + takeInputBudget, +} from "@/api/ws/input-budget.js"; +import { + hasHeldInput, + holdFailedInput, + refireInputHoldsForNode, + resetInputHoldsForTests, +} from "@/api/ws/input-hold.js"; +import { cleanupSubshellWs, handleSubshellMessage } from "@/api/ws/subshell-ws.js"; +import { closeViewersForSubshell, registerViewer, type WsSocket } from "@/api/ws/viewers.js"; + +function socket(sendInput: () => Promise = async () => {}) { + const closed: number[] = []; + const ws = { + data: { + launcher: { sendInput }, + socket: "sock", + subshellId: crypto.randomUUID(), + nodeId: crypto.randomUUID(), + viewerId: crypto.randomUUID(), + canInput: true, + }, + send: () => 1, + close: (code: number) => { + closed.push(code); + }, + } as unknown as WsSocket; + return { ws, closed }; +} +const settle = async () => { + for (let i = 0; i < 12; i++) await Promise.resolve(); +}; +afterEach(() => resetInputHoldsForTests()); + +it("bounds unacknowledged dispatches, including input without retry ids", async () => { + const done = Promise.withResolvers(); + let dispatched = 0; + const { ws, closed } = socket(() => { + dispatched++; + return done.promise; + }); + try { + for (let i = 0; i < INPUT_SOCKET_MAX_FRAMES + 2; i++) { + handleSubshellMessage(ws, { type: "input", data: "x" }); + } + expect(dispatched).toBe(INPUT_SOCKET_MAX_FRAMES); + expect(closed).toEqual([1013]); + } finally { + cleanupSubshellWs(ws); + done.resolve(); + await settle(); + } +}); + +it("counts UTF-8 bytes per frame and per socket rather than trusting character counts", () => { + const oversized = socket(); + handleSubshellMessage(oversized.ws, { type: "input", data: "😀".repeat(INPUT_FRAME_MAX_BYTES / 4 + 1) }); + expect(oversized.closed).toEqual([1009]); + const peer = socket(); + const releases: (() => void)[] = []; + try { + const payload = "x".repeat(INPUT_FRAME_MAX_BYTES); + for (let i = 0; i < INPUT_SOCKET_MAX_BYTES / INPUT_FRAME_MAX_BYTES; i++) { + const release = takeInputBudget(peer.ws, payload); + if (!release) throw new Error("input refused before its byte limit"); + releases.push(release); + } + expect(takeInputBudget(peer.ws, "x")).toBeNull(); + expect(peer.closed).toEqual([1013]); + } finally { + for (const release of releases) { + release(); + release(); + } + } +}); + +it("enforces process-wide frame and byte budgets across connections and releases settled work", () => { + for (const bytes of [1, INPUT_FRAME_MAX_BYTES]) { + const payload = "x".repeat(bytes); + const perSocket = Math.min(INPUT_SOCKET_MAX_FRAMES, INPUT_SOCKET_MAX_BYTES / bytes); + const count = Math.min(INPUT_GLOBAL_MAX_FRAMES, INPUT_GLOBAL_MAX_BYTES / bytes); + const releases: (() => void)[] = []; + let current = socket(); + try { + for (let i = 0; i < count; i++) { + if (i % perSocket === 0) current = socket(); + const release = takeInputBudget(current.ws, payload); + if (!release) throw new Error("input refused before its global limit"); + releases.push(release); + } + const excess = socket(); + expect(takeInputBudget(excess.ws, payload)).toBeNull(); + expect(excess.closed).toEqual([1013]); + } finally { + for (const release of releases) release(); + } + const next = takeInputBudget(socket().ws, payload); + expect(next).not.toBeNull(); + next?.(); + } +}); + +it("caps retained retries and discards them on overflow without waiting for a close event", async () => { + const failed = Promise.withResolvers(); + let dispatched = 0; + const { ws, closed } = socket(() => { + dispatched++; + return failed.promise; + }); + const { nodeId, subshellId, viewerId: sessionId } = ws.data; + try { + for (let id = 0; id <= INPUT_SOCKET_MAX_FRAMES; id++) { + holdFailedInput({ nodeId, subshellId, sessionId, id, payload: "x", ws, sendAck: () => {} }); + } + expect(closed).toEqual([1013]); + expect(hasHeldInput(nodeId, subshellId, sessionId)).toBe(false); + refireInputHoldsForNode(nodeId); + expect(dispatched).toBe(1); + } finally { + failed.reject(new Error("node disconnected")); + await settle(); + } +}); + +it("a write failing after disconnect cannot resurrect input or replay into the pane", async () => { + const failed = Promise.withResolvers(); + let dispatched = 0; + const { ws } = socket(() => { + dispatched++; + return failed.promise; + }); + handleSubshellMessage(ws, { type: "input", data: "late", id: 1 }); + cleanupSubshellWs(ws); + failed.reject(new Error("late node failure")); + await settle(); + expect(hasHeldInput(ws.data.nodeId, ws.data.subshellId, ws.data.viewerId)).toBe(false); + refireInputHoldsForNode(ws.data.nodeId); + handleSubshellMessage(ws, { type: "input", data: "closed", id: 2 }); + expect(dispatched).toBe(1); +}); + +it("disconnecting a retrying socket doesn't release work still running on the node", async () => { + const releases: (() => void)[] = []; + const done = Promise.withResolvers(); + const retry = socket(() => done.promise); + let filler = socket(); + try { + for (let i = 0; i < INPUT_GLOBAL_MAX_FRAMES - 1; i++) { + if (i % INPUT_SOCKET_MAX_FRAMES === 0) filler = socket(); + const release = takeInputBudget(filler.ws, "x"); + if (!release) throw new Error("input refused before global limit"); + releases.push(release); + } + holdFailedInput({ + nodeId: retry.ws.data.nodeId, + subshellId: retry.ws.data.subshellId, + sessionId: retry.ws.data.viewerId, + id: 1, + payload: "x", + ws: retry.ws, + sendAck: () => {}, + }); + cleanupSubshellWs(retry.ws); + const excess = socket(); + expect(takeInputBudget(excess.ws, "x")).toBeNull(); + expect(excess.closed).toEqual([1013]); + done.resolve(); + await settle(); + const recovered = takeInputBudget(socket().ws, "x"); + expect(recovered).not.toBeNull(); + recovered?.(); + } finally { + done.resolve(); + await settle(); + for (const release of releases) release(); + } +}); + +it("one offline node cannot consume the fleet's input budget across sockets", async () => { + for (const bytes of [1, INPUT_FRAME_MAX_BYTES]) { + const releases: (() => void)[] = []; + const payload = "x".repeat(bytes); + const count = Math.min(INPUT_NODE_MAX_FRAMES, INPUT_NODE_MAX_BYTES / bytes); + const perSocket = Math.min(INPUT_SOCKET_MAX_FRAMES, INPUT_SOCKET_MAX_BYTES / bytes); + const nodeId = crypto.randomUUID(); + let current = socket(); + try { + for (let i = 0; i < count; i++) { + if (i % perSocket === 0) current = socket(); + current.ws.data.nodeId = nodeId; + const release = takeInputBudget(current.ws, payload); + if (!release) throw new Error("node budget refused input early"); + releases.push(release); + } + const excess = socket(); + excess.ws.data.nodeId = nodeId; + expect(takeInputBudget(excess.ws, payload)).toBeNull(); + expect(excess.closed).toEqual([1013]); + const healthy = socket(); + const release = takeInputBudget(healthy.ws, payload); + expect(release).not.toBeNull(); + expect(healthy.closed).toEqual([]); + release?.(); + } finally { + for (const release of releases) release(); + } + const recovered = socket(); + recovered.ws.data.nodeId = nodeId; + const release = takeInputBudget(recovered.ws, payload); + expect(release).not.toBeNull(); + release?.(); + } +}); + +it("forced disconnect drops held input synchronously when close never fires", async () => { + let dispatched = 0; + let acknowledged = 0; + const { ws, closed } = socket(async () => { + dispatched++; + throw new Error("node offline"); + }); + // Exercise the negotiated session id rather than the viewer-id fallback. + Object.assign(ws.data, { query: { sid: "forced-session" } }); + registerViewer(ws, ws.data.subshellId); + const held = { nodeId: ws.data.nodeId, subshellId: ws.data.subshellId, sessionId: "forced-session" }; + holdFailedInput({ ...held, id: 1, payload: "held", ws, sendAck: () => acknowledged++ }); + await settle(); + expect(hasHeldInput(held.nodeId, held.subshellId, held.sessionId)).toBe(true); + expect(closeViewersForSubshell(ws.data.subshellId, 1012, "stream recovery")).toBe(1); + expect(ws.data.inputClosed).toBe(true); + expect(hasHeldInput(held.nodeId, held.subshellId, held.sessionId)).toBe(false); + refireInputHoldsForNode(held.nodeId); + handleSubshellMessage(ws, { type: "input", data: "late", id: 2 }); + await settle(); + expect(dispatched).toBe(1); + expect(acknowledged).toBe(0); + expect(closed).toEqual([1012]); +}); + +/** Fill the fleet queue from sockets that each stay inside their own caps. */ +function saturateFleet(): (() => void)[] { + const charges: (() => void)[] = []; + for (let s = 0; s < INPUT_GLOBAL_MAX_FRAMES / INPUT_SOCKET_MAX_FRAMES; s++) { + const filler = socket(); + for (let f = 0; f < INPUT_SOCKET_MAX_FRAMES; f++) { + const release = takeInputBudget(filler.ws, "x"); + if (!release) throw new Error("the fleet queue filled earlier than expected"); + charges.push(release); + } + } + return charges; +} + +it("reclaims a stuck retry instead of closing a viewer whose own pane is alive", () => { + const charges = saturateFleet(); + try { + // With no capacity to take back, an arrival still pays for a queue it did + // not fill. That is the honest last resort, not the first answer. + const refused = socket(); + expect(takeInputBudget(refused.ws, "x")).toBeNull(); + expect(refused.closed).toEqual([1013]); + + // With one stuck retry to give up, the next viewer is admitted instead. + let reclaimed = 0; + const viewer = socket(); + const release = takeInputBudget(viewer.ws, "x", () => { + charges.pop()?.(); + reclaimed++; + return true; + }); + expect(release).not.toBeNull(); + expect(viewer.closed).toEqual([]); + expect(reclaimed).toBe(1); + release?.(); + } finally { + for (const charge of charges) charge(); + } +}); + +it("still ends the arrival when the shared queue cannot be relieved", () => { + const charges = saturateFleet(); + try { + let asked = 0; + const viewer = socket(); + expect( + takeInputBudget(viewer.ws, "x", () => { + asked++; + return false; + }), + ).toBeNull(); + expect(viewer.closed).toEqual([1013]); + expect(asked).toBe(1); + } finally { + for (const charge of charges) charge(); + } +}); + +it("caps a node by bytes across its sockets, not only by frames", () => { + // The node and socket byte ceilings are the same number, so one socket + // filling the node would trip both and prove neither. Spread the load so the + // only bound left standing is the node's own. + const framesToFillNode = INPUT_NODE_MAX_BYTES / INPUT_FRAME_MAX_BYTES; + expect(framesToFillNode).toBe(256); + const socketsNeeded = 4; + const perSocket = framesToFillNode / socketsNeeded; + expect(perSocket).toBeLessThan(INPUT_SOCKET_MAX_FRAMES); + const payload = "x".repeat(INPUT_FRAME_MAX_BYTES); + const nodeId = crypto.randomUUID(); + const charges: (() => void)[] = []; + try { + for (let s = 0; s < socketsNeeded; s++) { + const filler = socket(); + filler.ws.data.nodeId = nodeId; + for (let f = 0; f < perSocket; f++) { + const release = takeInputBudget(filler.ws, payload); + if (!release) throw new Error("a per-socket bound tripped before the node's did"); + charges.push(release); + } + } + const excess = socket(); + excess.ws.data.nodeId = nodeId; + expect(takeInputBudget(excess.ws, payload)).toBeNull(); + expect(excess.closed).toEqual([1013]); + } finally { + for (const charge of charges) charge(); + } +}); + +it("forced disconnect drops held input for a shared viewer with no negotiated session id", async () => { + let dispatched = 0; + const { ws } = socket(async () => { + dispatched++; + throw new Error("node offline"); + }); + // Deliberately no `query`: the arrival keys its hold by the viewer-id + // fallback, so recovery has to find the backlog there rather than only in the + // negotiated-sid case the other test covers. + registerViewer(ws, ws.data.subshellId); + const held = { nodeId: ws.data.nodeId, subshellId: ws.data.subshellId, sessionId: ws.data.viewerId }; + holdFailedInput({ ...held, id: 1, payload: "held", ws, sendAck: () => {} }); + await settle(); + expect(hasHeldInput(held.nodeId, held.subshellId, held.sessionId)).toBe(true); + expect(closeViewersForSubshell(ws.data.subshellId, 1012, "stream recovery")).toBe(1); + expect(hasHeldInput(held.nodeId, held.subshellId, held.sessionId)).toBe(false); + // Holding already kicked a drain, which is what put the first attempt on the + // wire, so the count to compare against is taken here rather than from zero. + // What this pins is that recovery cleared the backlog: a node coming back + // now has nothing left to re-fire into a socket that is gone. + const attempts = dispatched; + refireInputHoldsForNode(held.nodeId); + await settle(); + expect(dispatched).toBe(attempts); +}); diff --git a/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts b/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts index 3a9971391..41a6bb10f 100644 --- a/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts @@ -20,6 +20,7 @@ import { resetInputHoldsForTests } from "@/api/ws/input-hold.js"; import { resetInputWindowsForTests } from "@/api/ws/input-window.js"; import { handleSubshellMessage } from "@/api/ws/subshell-ws.js"; import { subscribeLive } from "@/app/live-bus.js"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { type NodeReadyReport, NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -443,6 +444,7 @@ describe("forced-eviction projection", () => { // migrations run here, and rows are created and dropped per case. beforeAll(async () => { await runMigrations(); + await runAuthMigrations(); }); beforeEach(() => { resetNodeRegistryForTests(); diff --git a/apps/server/api/src/api/ws/__tests__/pane-geometry.test.ts b/apps/server/api/src/api/ws/__tests__/pane-geometry.test.ts index 4cdf64e59..c3055db87 100644 --- a/apps/server/api/src/api/ws/__tests__/pane-geometry.test.ts +++ b/apps/server/api/src/api/ws/__tests__/pane-geometry.test.ts @@ -229,6 +229,28 @@ describe("createGeometryQueue — serialize + coalesce per subshell", () => { expect(next.applied).toEqual([{ cols: 80, rows: 24 }]); }); + it("drains sustained resize traffic and discards the pending size when the viewer leaves", async () => { + let applied = 0; + let announced = 0; + const queue = createGeometryQueue({ onGeometry: () => announced++ }); + const sizer: PaneSizer = { + apply: async (cols) => { + applied++; + queue.request("storm", cols === 80 ? 81 : 80, 24, sizer); + if (applied === 5_000) queue.release("storm"); + }, + read: async () => ({ cols: 80, rows: 24 }), + }; + queue.request("storm", 80, 24, sizer); + await settle(); + expect(applied).toBe(5_000); + expect(announced).toBe(4_999); + const next = recorder(); + queue.request("storm", 80, 24, next.sizer); + await settle(); + expect(next.applied).toEqual([{ cols: 80, rows: 24 }]); + }); + it("seed records an out-of-band resize, so the same size is not re-applied", async () => { const rec = recorder(); const queue = createGeometryQueue({ onGeometry: () => {} }); diff --git a/apps/server/api/src/api/ws/__tests__/pane-sources.test.ts b/apps/server/api/src/api/ws/__tests__/pane-sources.test.ts index 4a8a3e366..3ed9b9885 100644 --- a/apps/server/api/src/api/ws/__tests__/pane-sources.test.ts +++ b/apps/server/api/src/api/ws/__tests__/pane-sources.test.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "bun:test"; import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createLogTailSource, createPanePollSource } from "@/api/ws/pane-sources.js"; +import { + createLogTailSource, + createPanePollSource, + createRemoteTailSource, + tailCloseReason, +} from "@/api/ws/pane-sources.js"; import type { NodeLauncher } from "@/modules/nodes/node-launcher.js"; +import type { RemoteLauncher } from "@/modules/nodes/remote-launcher.js"; const scratch = mkdtempSync(join(tmpdir(), "pane-sources-")); let seq = 0; @@ -237,3 +243,330 @@ describe("createPanePollSource", () => { expect(fake.captures).toBe(atStop); }); }); + +/** + * The remote tail is the shared pump for every viewer of a node pane, so a + * source that goes silent without saying so freezes the pane for all of them + * until the server restarts (the 2026-10-27 stuck-pane incident). These cases + * pin the two ways it refuses to go silent: a refused `tail_start`, and a + * watchdog that reads a growing log against a dead-silent relay. + */ +describe("createRemoteTailSource", () => { + interface RemoteTailFake { + launcher: RemoteLauncher; + /** The agent's `tail_start` callback, so the test can deliver (or not). */ + deliveries: ((bytes: Uint8Array, next: number) => void)[]; + /** How many relay disposers ran. */ + relayDisposed: number; + sizeCalls: number; + /** When true, a probe parks until `releaseProbe`. A flag so one case can + * park the FIRST probe and let the rest answer live. */ + hold: boolean; + releaseProbe: () => void; + } + + function remoteTailStub(opts: { + /** Reject the tail_start round-trip (the agent's relay refused). */ + refuse?: string; + /** What the size probe answers (the node's current EOF). */ + size: () => number; + /** When set, probes park until `releaseProbe`. */ + holdProbes?: boolean; + /** The first N size probes reject (transient RPC failure); later ones answer. */ + probeRejects?: number; + }): RemoteTailFake { + const fake: RemoteTailFake = { + deliveries: [], + relayDisposed: 0, + sizeCalls: 0, + hold: opts.holdProbes ?? false, + releaseProbe: () => {}, + launcher: {} as RemoteLauncher, + }; + fake.launcher = { + tailStart: async ( + _id: string, + _subId: string, + _fromByte: number, + onChunk: (bytes: Uint8Array, next: number) => void, + ) => { + if (opts.refuse) throw new Error(opts.refuse); + fake.deliveries.push(onChunk); + let disposedOnce = false; // the REAL launcher disposer is idempotent; mirror it + return () => { + if (disposedOnce) return; + disposedOnce = true; + fake.relayDisposed += 1; + }; + }, + readLogSized: async () => { + fake.sizeCalls += 1; + if (opts.probeRejects && fake.sizeCalls <= opts.probeRejects) { + throw new Error("transient node RPC failure"); + } + // Snapshot at command time, like the node answers; the hold then lets + // a test deliver bytes into the flight and prove the verdict re-check. + const size = opts.size(); + if (fake.hold) { + await new Promise((resolve) => { + fake.releaseProbe = resolve; + }); + } + return { bytes: new Uint8Array(0), next: size, size }; + }, + } as unknown as RemoteLauncher; + return fake; + } + + /** Fast watchdog so a stall verdict does not cost a test its patience. */ + const FAST = { stallCheckMs: 20, stallAfterMs: 60 }; + + it("reports a refused tail_start instead of streaming silence", async () => { + const fake = remoteTailStub({ refuse: "node refused", size: () => 500 }); + const seen: string[] = []; + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start((t) => seen.push(t)); + try { + await waitFor(() => deaths.length > 0, "the refusal to be reported"); + expect(deaths).toHaveLength(1); + expect(deaths[0]).toContain("tail_start refused"); + expect(deaths[0]).toContain("node refused"); + expect(seen).toEqual([]); // nothing painted, and nothing pretending to + } finally { + stop(); + } + }); + + it("reports a stall when the log grows and the relay delivers nothing", async () => { + // The dead-pump shape from the field: the node keeps appending, the + // plane-side relay hands over zero bytes. A quiet pane (next case) must + // NOT read this way, which is why growth is part of the verdict. + let size = 400; + const fake = remoteTailStub({ size: () => size }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + try { + await Bun.sleep(100); // several checks while size still equals the cursor + expect(deaths).toEqual([]); + size = 1200; // the pane starts printing; the dead pump says nothing + await waitFor(() => deaths.length > 0, "the watchdog verdict"); + // Tie the producer string to the mapper predicate in one assertion: a + // reword that keeps the word "stalled" but not its position (e.g. "pump + // stalled: …") must redden this, or the stall family would silently map + // to "tail refused" and send the operator down the node-link path. + expect(deaths[0].startsWith("stalled")).toBe(true); + expect(tailCloseReason(deaths[0])).toBe("tail stalled"); + expect(deaths[0]).toContain("EOF 1200"); + expect(fake.relayDisposed).toBe(1); // the corpse is disposed, not nursed + } finally { + stop(); + } + }); + + it("leaves a quiet pane alone (no bytes, no verdict)", async () => { + const fake = remoteTailStub({ size: () => 400 }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + try { + await Bun.sleep(250); // many check intervals over the stall threshold + expect(deaths).toEqual([]); + expect(fake.sizeCalls).toBeGreaterThan(0); // the watchdog really was probing + } finally { + stop(); + } + }); + + it("keeps a delivering pump healthy", async () => { + let size = 400; + const fake = remoteTailStub({ size: () => size }); + const seen: string[] = []; + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start((t) => seen.push(t)); + try { + await waitFor(() => fake.deliveries.length === 1, "the tail_start handshake"); + const onChunk = fake.deliveries[0]; + // Bytes arrive as the log grows: the cursor and the clock both stay + // fresh, so no probe even runs. + for (let at = 500; at <= 1500; at += 100) { + size = at; + onChunk(Buffer.from(`x${at}`), at); + await Bun.sleep(30); + } + expect(deaths).toEqual([]); + expect(seen.join("")).toContain("x1500"); + // The documented economy: a pump that keeps delivering pays for no + // probe run (the interval's silence gate short-circuits before the + // RPC). One is tolerated for event-loop jitter around the first + // armed check; delete that gate and a 20 ms interval over this loop + // produces dozens, so the alarm still fires instantly. + expect(fake.sizeCalls).toBeLessThanOrEqual(1); + } finally { + stop(); + } + }); + + it("a burst landing mid-probe is alive, not stalled", async () => { + // The probe's EOF is a snapshot; a chunk delivered while it is in flight + // moves the cursor PAST that answer, and `size < cursor` is otherwise + // exactly the rewind shape. The verdict must re-ask the silence question + // at resolution, not at the tick that armed the probe. + let size = 400; + const fake = remoteTailStub({ size: () => size, holdProbes: true }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + try { + await waitFor(() => fake.deliveries.length === 1, "the tail_start handshake"); + const onChunk = fake.deliveries[0]; + await waitFor(() => fake.sizeCalls >= 1, "the first parked probe"); + // The pump wakes while that probe is in flight: the cursor passes the + // answer it will bring back. + size = 800; + onChunk(Buffer.from("burst"), 800); + fake.releaseProbe(); + await Bun.sleep(120); + expect(deaths).toEqual([]); // the stale answer was refused at the gate + // And the watchdog is not defanged: true silence with a moving log + // still earns the verdict. A tick that armed its probe just before the + // hold came off would otherwise sit parked, so pump the release until + // the verdict lands (releasing an answered probe is a no-op). + fake.hold = false; + size = 1200; + for (let i = 0; i < 25 && deaths.length === 0; i++) { + await Bun.sleep(40); + fake.releaseProbe(); + } + expect(deaths.length).toBe(1); + // Tie the producer string to the mapper predicate in one assertion: a + // reword that keeps the word "stalled" but not its position (e.g. "pump + // stalled: …") must redden this, or the stall family would silently map + // to "tail refused" and send the operator down the node-link path. + expect(deaths[0].startsWith("stalled")).toBe(true); + expect(tailCloseReason(deaths[0])).toBe("tail stalled"); + } finally { + stop(); + } + }); + + it("a transient probe failure does not burn the pump or wedge the watchdog", async () => { + // MINOR 4: the .catch that resets probeInFlight on a failed probe is + // load-bearing. If it left probeInFlight stuck true, a real stall later + // would never report — the 2026-10-27 freeze reborn behind green suites. + // The first probes reject (simulating a flaky node RPC); once they + // recover, the genuine stall must STILL be caught and reported. + const fake = remoteTailStub({ size: () => 1200, probeRejects: 2 }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + try { + // Two rejected probes must NOT strand the watchdog: it keeps ticking. + await waitFor(() => deaths.length > 0, "the verdict after recovered probes"); + // Tie the producer string to the mapper predicate in one assertion: a + // reword that keeps the word "stalled" but not its position (e.g. "pump + // stalled: …") must redden this, or the stall family would silently map + // to "tail refused" and send the operator down the node-link path. + expect(deaths[0].startsWith("stalled")).toBe(true); + expect(tailCloseReason(deaths[0])).toBe("tail stalled"); + expect(fake.sizeCalls).toBeGreaterThanOrEqual(3); // it probed past the two failures + } finally { + stop(); + } + }); + + it("treats a rewind (log smaller than the cursor) as a stall too", async () => { + // The retention-sweep-then-relaunch shape: the log under the pump got + // replaced, so `size > cursor` would wait forever for a byte that can + // never arrive at that offset again. + let size = 1000; + const fake = remoteTailStub({ size: () => size }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 0, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + try { + await waitFor(() => fake.deliveries.length === 1, "the tail_start handshake"); + fake.deliveries[0](Buffer.from("hello"), 1000); // cursor now at 1000 + size = 20; // the file was replaced under the pump + await waitFor(() => deaths.length > 0, "the rewind verdict"); + // Tie the producer string to the mapper predicate in one assertion: a + // reword that keeps the word "stalled" but not its position (e.g. "pump + // stalled: …") must redden this, or the stall family would silently map + // to "tail refused" and send the operator down the node-link path. + expect(deaths[0].startsWith("stalled")).toBe(true); + expect(tailCloseReason(deaths[0])).toBe("tail stalled"); + } finally { + stop(); + } + }); + + it("stops probing once the stream is disposed", async () => { + // A quiet pane (size equals the cursor): the watchdog probes and waits + // forever without a verdict. The claim is only that after dispose even + // the probing stops. + const fake = remoteTailStub({ size: () => 400 }); + const deaths: string[] = []; + const stop = createRemoteTailSource({ + launcher: fake.launcher, + subshellId: "s1", + fromByte: 400, + ...FAST, + onDead: (why) => deaths.push(why), + }).start(() => {}); + await waitFor(() => fake.sizeCalls >= 1, "a first probe"); + stop(); + const atStop = fake.sizeCalls; + await Bun.sleep(200); + expect(fake.sizeCalls).toBe(atStop); // no interval left to tick + expect(deaths).toEqual([]); // and nothing left to report to + }); +}); + +describe("tailCloseReason (pure mapper)", () => { + it("maps the stall family to 'tail stalled' and the refusal family to 'tail refused'", () => { + // Both arms pinned: an operator reads this string to pick which path to + // chase (relay silence vs node-link refusal); a collapse to a constant is + // the bug this guards. + expect(tailCloseReason("stalled: EOF 1200, delivered 400, silent 30000ms")).toBe("tail stalled"); + expect(tailCloseReason("tail_start refused: node refused")).toBe("tail refused"); + // Anything not clearly a stall reads as a refusal (conservative default). + expect(tailCloseReason("weird")).toBe("tail refused"); + }); +}); diff --git a/apps/server/api/src/api/ws/__tests__/remote-subshell-ws.test.ts b/apps/server/api/src/api/ws/__tests__/remote-subshell-ws.test.ts index c226bc9f3..6ad195bac 100644 --- a/apps/server/api/src/api/ws/__tests__/remote-subshell-ws.test.ts +++ b/apps/server/api/src/api/ws/__tests__/remote-subshell-ws.test.ts @@ -852,6 +852,89 @@ describe("attachRemoteSubshellWs — several viewers share one node pane", () => await until(() => sim.cmdTypes().includes("tail_stop"), "tail stopped with the last viewer"); expect(sim.cmdTypes().filter((t) => t === "tail_stop")).toHaveLength(1); }); + + it("a tail the agent refuses takes its viewers down with it, and the next attach builds a fresh pump", async () => { + // The stuck-pane regression (2026-10-27): the shared pump is built once + // per subshell, for its first viewer, and a source that died quietly + // stayed every later attach's terminal for hours. A refused tail_start + // is now an event, and recovery must not wait on a socket's close + // handler firing. + const sim = makeNodeSim(); + scriptHappy(sim); + scriptLogRead(sim, { bytes: "ab\ncd\n", size: 7 }, 40); + sim.answer("tail_start", fail("relay refused")); + const first = fakeBrowser(); + await attachRemoteSubshellWs(first.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); + // The replay paints (it needs no pump)... + expect(first.sent.some((f) => f.includes('"type":"replay"'))).toBe(true); + // ...but the refused pump drops its audience with the client's retry + // code instead of leaving them watching silence forever. + await until(() => first.closed.length > 0, "the refused tail to drop its viewer"); + expect(first.closed[0]?.code).toBe(1011); + // The reason is the diagnostic split the reporting exists to create: a + // refusal names itself, so an operator is not sent down the stall path. + expect(first.closed[0]?.reason).toBe("tail refused"); + expect(paneStreams.viewerCount(SID)).toBe(0); // the stream died with them + + // The second attach gets a WORKING pump at the current EOF, and its + // bytes reach the browser: the corpse was not inherited. + sim.answer("probe", [{ subshellId: SID, alive: true, exitCode: null }]); + sim.answer("capture", `${BSU}SCREEN${ESU}`); + const second = fakeBrowser(); + await attachRemoteSubshellWs(second.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); + const cmds = sim.cmdsOf("tail_start") as Extract[]; + expect(cmds).toHaveLength(2); + expect(cmds[1].fromByte).toBe(7); + dispatchOutput(outputFrame(cmds[1].subId, 7, "live\r\n")); + await until(() => second.sent.some((f) => f.includes("live")), "the fresh pump's bytes"); + cleanupSubshellWs(second.ws); + }); + + it("a pump that dies while TWO viewers share it drops both, none waiting on a close event", async () => { + // The invariant the single-viewer case cannot reach: the registry hands + // the first viewer's pump to every later joiner, so `onDead` must clear + // the WHOLE audience — a phone tab that outlives the death is exactly + // what kept the 2026-10-27 corpse inheritable. The refusal lands late + // (the script returns a promise the test rejects), by which time both + // browsers are painted and streaming from the shared pump. + const sim = makeNodeSim(); + for (let i = 0; i < 3; i++) { + sim.answer("probe", [{ subshellId: SID, alive: true, exitCode: null }]); + sim.answer("capture", `${BSU}SCREEN${ESU}`); + } + scriptLogRead(sim, { bytes: "ab\ncd\n", size: 7 }, 60); + let refuseTail!: () => void; + sim.answer( + "tail_start", + () => + new Promise((_resolve, reject) => { + refuseTail = () => reject(new Error("node link died mid-round-trip")); + }), + ); + const laptop = fakeBrowser(); + const phone = fakeBrowser(); + await attachRemoteSubshellWs(laptop.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); + await attachRemoteSubshellWs(phone.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); + // One pump for both (the join-point rule, pinned here as the test's premise)... + expect(sim.cmdsOf("tail_start")).toHaveLength(1); + refuseTail(); // ...and it dies with BOTH watching. + await until(() => laptop.closed.length > 0 && phone.closed.length > 0, "both viewers dropped"); + expect(laptop.closed[0]?.code).toBe(1011); + expect(laptop.closed[0]?.reason).toBe("tail refused"); + expect(phone.closed[0]?.code).toBe(1011); + expect(paneStreams.viewerCount(SID)).toBe(0); + + // A third attach is a third viewer of a GONE stream: fresh pump, live bytes. + sim.answer("probe", [{ subshellId: SID, alive: true, exitCode: null }]); + sim.answer("capture", `${BSU}SCREEN${ESU}`); + const third = fakeBrowser(); + await attachRemoteSubshellWs(third.ws, attachRow(), new RemoteLauncher(NODE_ID), "owner", attachParams(), true); + const cmds = sim.cmdsOf("tail_start") as Extract[]; + expect(cmds).toHaveLength(2); + dispatchOutput(outputFrame(cmds[1].subId, 7, "recovered\r\n")); + await until(() => third.sent.some((f) => f.includes("recovered")), "the fresh pump's bytes"); + cleanupSubshellWs(third.ws); + }); }); describe("readSubshellLogTail routes by node", () => { diff --git a/apps/server/api/src/api/ws/__tests__/terminal-share-revocation.test.ts b/apps/server/api/src/api/ws/__tests__/terminal-share-revocation.test.ts new file mode 100644 index 000000000..c3a68e1b6 --- /dev/null +++ b/apps/server/api/src/api/ws/__tests__/terminal-share-revocation.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from "bun:test"; +import { holdFailedInput, refireInputHoldsForNode } from "@/api/ws/input-hold.js"; +import { handleSubshellMessage, handleSubshellWs } from "@/api/ws/subshell-ws.js"; +import { paneStreams, sendFrame } from "@/api/ws/viewers.js"; +import { issueWsToken } from "@/api/ws/ws-token.js"; +import { getRequestlessContext } from "@/app/context.js"; +import { subshellLogPath } from "@/modules/subshells/paths.js"; +import { + attach, + attached, + defaultLocalLauncher, + fakeBrowser, + seedLocalRow, + stubLauncher, + useLocalAttachHarness, +} from "./helpers/local-attach-harness.js"; + +useLocalAttachHarness(); + +it("a share downgrade cuts old input and output without relying on the socket close event", async () => { + stubLauncher(); + const row = await seedLocalRow(); + await Bun.write(subshellLogPath(row.id), "old\n"); + const { repos, services } = getRequestlessContext(); + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: null, permission: "edit" }], row.userId); + const owner = await attach(row.userId, row.id); + const guest = await attach("guest", row.id); + expect(guest.ws.data.canInput).toBe(true); + let inputCalls = 0; + guest.ws.data.launcher = { + ...guest.ws.data.launcher, + sendInput: async () => { + inputCalls++; + }, + }; + + await services.subshells.setShares(row.userId, row.id, [{ granteeUserId: null, permission: "view" }], "cookie"); + expect(guest.closed).toEqual([{ code: 1012, reason: "subshell sharing changed" }]); + expect(owner.closed).toEqual([]); + const before = guest.sent.length; + handleSubshellMessage(guest.ws, { type: "input", data: "unauthorized\n", id: 1 }); + sendFrame(guest.ws, { type: "output", data: "private output" }); + // A failure from a write issued before revocation must not recreate a hold. + holdFailedInput({ + nodeId: row.nodeId, + subshellId: row.id, + sessionId: "late", + id: 2, + payload: "late", + ws: guest.ws, + sendAck: () => {}, + }); + refireInputHoldsForNode(row.nodeId); + expect(inputCalls).toBe(0); + expect(guest.sent.length).toBe(before); + + const viewer = await attach("guest", row.id); + expect(viewer.closed).toEqual([]); + expect(viewer.ws.data.canInput).toBe(false); + await services.subshells.setShares(row.userId, row.id, [], "cookie"); + expect(viewer.closed.length).toBe(1); + const refused = await attach("guest", row.id); + expect(refused.closed).toEqual([{ code: 4005, reason: "subshell not found" }]); + expect(owner.closed).toEqual([]); +}); + +it("revocation during an awaited attach probe cannot resurrect the old grant", async () => { + stubLauncher(); + const row = await seedLocalRow(); + await Bun.write(subshellLogPath(row.id), "old\n"); + const { repos, services } = getRequestlessContext(); + await repos.subshellShares.replaceForSubshell(row.id, [{ granteeUserId: null, permission: "edit" }], row.userId); + const entered = Promise.withResolvers(); + const resume = Promise.withResolvers(); + defaultLocalLauncher.hasSubshell = () => { + entered.resolve(); + return resume.promise; + }; + const guest = fakeBrowser(); + attached.push(guest); + const attaching = handleSubshellWs( + guest.ws, + new URL(`ws://localhost/ws?subshell=${row.id}&token=${issueWsToken("guest")}`), + ); + await entered.promise; + await services.subshells.setShares(row.userId, row.id, [], "cookie"); + resume.resolve(true); + await attaching; + expect(guest.closed).toEqual([{ code: 1012, reason: "subshell sharing changed" }]); + expect(guest.sent).toEqual([]); + expect(paneStreams.viewerCount(row.id)).toBe(0); + handleSubshellMessage(guest.ws, { type: "input", data: "denied" }); +}); diff --git a/apps/server/api/src/api/ws/attach-forensics.ts b/apps/server/api/src/api/ws/attach-forensics.ts index 7379c4b8a..aac0043be 100644 --- a/apps/server/api/src/api/ws/attach-forensics.ts +++ b/apps/server/api/src/api/ws/attach-forensics.ts @@ -91,6 +91,13 @@ export interface AttachPaintFacts { repainted: boolean; /** Whether the pane had to be nudged (±1 col) to force that repaint. */ nudged: boolean; + /** + * The cursor the replay ENDED on — the read behind the absolute CUP — or + * null when it answered nothing, in which case the replay shipped without + * its restore (the known degrade for a cursor near the top, and the thing + * to look for first on a "pinned elements land on the transcript" report). + */ + cursor: { x: number; y: number } | null; } /** @@ -111,8 +118,9 @@ export interface AttachPaintFacts { */ export function recordAttachPaint(facts: AttachPaintFacts): void { const dir = writeAttachForensics(facts.subshellId, facts.preResize, facts.replay); + const cursor = facts.cursor ? `${facts.cursor.x},${facts.cursor.y}` : "none"; logger.info( `ws attach ${facts.subshellId}: painted repainted=${facts.repainted} nudged=${facts.nudged} ` + - `replay=${facts.replay.length}B dump=${dir ?? "off"}`, + `cursor=${cursor} replay=${facts.replay.length}B dump=${dir ?? "off"}`, ); } diff --git a/apps/server/api/src/api/ws/input-budget.ts b/apps/server/api/src/api/ws/input-budget.ts new file mode 100644 index 000000000..79d50794b --- /dev/null +++ b/apps/server/api/src/api/ws/input-budget.ts @@ -0,0 +1,107 @@ +import type { WsSocket } from "@/api/ws/viewers.js"; + +// The browser sends eight 32 KiB frames at once. These limits leave room for +// retries without trusting a custom client's pacing or queue implementation. +export const INPUT_FRAME_MAX_BYTES = 64 * 1024; +export const INPUT_SOCKET_MAX_FRAMES = 512; +export const INPUT_SOCKET_MAX_BYTES = 16 * 1024 * 1024; +// A disconnected node must leave capacity for the rest of the fleet. +export const INPUT_NODE_MAX_FRAMES = 1024; +export const INPUT_NODE_MAX_BYTES = 16 * 1024 * 1024; +export const INPUT_GLOBAL_MAX_FRAMES = 4096; +export const INPUT_GLOBAL_MAX_BYTES = 64 * 1024 * 1024; + +interface Usage { + frames: number; + bytes: number; +} +const sockets = new WeakMap(); +const nodes = new Map(); +const total: Usage = { frames: 0, bytes: 0 }; + +/** Whether one node's share and the fleet-wide queue still fit this payload. */ +function hasSharedCapacity(node: Usage, bytes: number): boolean { + return ( + node.frames < INPUT_NODE_MAX_FRAMES && + node.bytes + bytes <= INPUT_NODE_MAX_BYTES && + total.frames < INPUT_GLOBAL_MAX_FRAMES && + total.bytes + bytes <= INPUT_GLOBAL_MAX_BYTES + ); +} + +/** + * Ends the connection that brought the limit on itself. + * + * Reserved for a breach of this socket's own per-frame or per-socket bound, + * where the client's reconnect re-send is what tells the person who typed the + * keystroke that it did not land. Turning away a frame only because the shared + * queue is full is a different case, and closing for it punishes whoever + * happens to type next. + */ +function closeForInputLimit(ws: WsSocket, code: number): null { + // Stop admitting work even if the close callback is delayed. Already + // dispatched writes stay charged until they settle, including on reconnect. + ws.data.inputClosed = true; + try { + ws.close(code, "terminal input limit exceeded"); + } catch { + /* already closed */ + } + return null; +} + +/** + * Charge both dispatched writes and retained retries until their payload is released. + * + * A saturated shared queue is filled by retries that have not been able to + * re-fire, so an arrival that fits its own limits is offered one of them + * before anyone is disconnected. `reclaimSharedCapacity` frees the oldest such + * retry and returns whether it managed to. It arrives as a callback rather + * than an import so the hold module stays the only owner of that queue. + */ +export function takeInputBudget( + ws: WsSocket, + payload: string, + reclaimSharedCapacity?: () => boolean, +): (() => void) | null { + if (ws.data.inputClosed || ws.data.accessRevoked) return null; + const bytes = Buffer.byteLength(payload); + const usage = sockets.get(ws.data) ?? { frames: 0, bytes: 0 }; + const nodeId = ws.data.nodeId; + const node = nodes.get(nodeId) ?? { frames: 0, bytes: 0 }; + if ( + bytes > INPUT_FRAME_MAX_BYTES || + usage.frames >= INPUT_SOCKET_MAX_FRAMES || + usage.bytes + bytes > INPUT_SOCKET_MAX_BYTES + ) { + return closeForInputLimit(ws, bytes > INPUT_FRAME_MAX_BYTES ? 1009 : 1013); + } + if (!hasSharedCapacity(node, bytes)) { + // Free a stuck retry and look again. Only a queue that cannot be relieved + // at all is treated as this arrival's problem. + if (reclaimSharedCapacity?.() !== true || !hasSharedCapacity(node, bytes)) { + return closeForInputLimit(ws, 1013); + } + } + sockets.set(ws.data, usage); + nodes.set(nodeId, node); + node.frames++; + node.bytes += bytes; + usage.frames++; + usage.bytes += bytes; + total.frames++; + total.bytes += bytes; + let released = false; + return () => { + if (released) return; + released = true; + node.frames--; + node.bytes -= bytes; + if (node.frames === 0) nodes.delete(nodeId); + usage.frames--; + usage.bytes -= bytes; + total.frames--; + total.bytes -= bytes; + if (usage.frames === 0) sockets.delete(ws.data); + }; +} diff --git a/apps/server/api/src/api/ws/input-hold.ts b/apps/server/api/src/api/ws/input-hold.ts index 1214802c0..9eb5c09fc 100644 --- a/apps/server/api/src/api/ws/input-hold.ts +++ b/apps/server/api/src/api/ws/input-hold.ts @@ -35,12 +35,11 @@ * - **A node that stays down just leaves the frames waiting.** There is no * retry timer: the re-fire triggers are the node's `ready` moment (the * node-ws-handler, which calls {@link refireInputHoldsForNode}) and a - * new-write arrival (which appends and kicks the drain). The memory bound - * is the client's own: a client never holds more than its 512-frame - * pending queue (MAX_PENDING in input-queue.ts — 2026-09-22's window + - * INFLIGHT_MAX cap moved WHEN frames ship, not how many can wait), and a - * re-arriving id never duplicates a hold, so the hold can never exceed - * what the client itself is holding. + * new-write arrival (which appends and kicks the drain). The server bounds + * frames and payload bytes per socket, per node and process-wide in input-budget.ts, + * counting both dispatched writes and retries. A custom client cannot + * bypass those bounds by ignoring the browser's pacing. Duplicate held ids + * don't consume another slot; overflow closes the requesting socket without acknowledging the frame. * - **A re-fire failure re-enters the hold.** The drain stops at the first * rejection, leaving that frame at the head and every later one behind it; * the next trigger re-enters. No clock anywhere in this module. @@ -65,6 +64,7 @@ * papered over. */ +import { takeInputBudget } from "@/api/ws/input-budget.js"; import { inputWindowAdd } from "@/api/ws/input-window.js"; import type { WsSocket } from "@/api/ws/viewers.js"; import { logger } from "@/shared/logger.js"; @@ -77,6 +77,8 @@ interface HoldEntry { payload: string; ws: WsSocket; sendAck: () => void; + release: () => void; + inFlight?: boolean; } /** One session's holds: the FIFO (id order by construction — client ids are @@ -133,6 +135,7 @@ export interface FailedInput { * @param failed - The failed write */ export function holdFailedInput(failed: FailedInput): void { + if (failed.ws.data.accessRevoked || failed.ws.data.inputClosed) return; let subs = holds.get(failed.nodeId); if (!subs) { subs = new Map(); @@ -149,7 +152,12 @@ export function holdFailedInput(failed: FailedInput): void { sessions.set(failed.sessionId, hold); } if (!hold.queue.some((entry) => entry.id === failed.id)) { - hold.queue.push({ id: failed.id, payload: failed.payload, ws: failed.ws, sendAck: failed.sendAck }); + const release = takeInputBudget(failed.ws, failed.payload); + if (!release) { + dropInputHolds(failed.nodeId, failed.subshellId, failed.sessionId); + return; + } + hold.queue.push({ id: failed.id, payload: failed.payload, ws: failed.ws, sendAck: failed.sendAck, release }); } drain(hold, failed.sessionId); } @@ -183,7 +191,21 @@ function drain(hold: SessionHold, sessionId: string): void { // a fresh write would make — never a stale copy of the launcher the // frame originally failed on. const data = entry.ws.data; - await data.launcher.sendInput(data.socket, data.subshellId, entry.payload); + if (data.accessRevoked || data.inputClosed) { + entry.release(); + hold.queue.shift(); + continue; + } + entry.inFlight = true; + try { + await data.launcher.sendInput(data.socket, data.subshellId, entry.payload); + } catch (err) { + if (!hold.queue.includes(entry)) entry.release(); + throw err; + } finally { + entry.inFlight = false; + } + entry.release(); hold.queue.shift(); inputWindowAdd(data.subshellId, sessionId, entry.id); entry.sendAck(); @@ -230,6 +252,7 @@ export function dropInputHolds(nodeId: string, subshellId: string, sessionId: st const sessions = subs?.get(subshellId); const hold = sessions?.get(sessionId); if (!hold || !subs || !sessions) return; + for (const entry of hold.queue) if (!entry.inFlight) entry.release(); hold.queue = []; sessions.delete(sessionId); if (sessions.size === 0) { @@ -238,11 +261,54 @@ export function dropInputHolds(nodeId: string, subshellId: string, sessionId: st } } +/** + * Frees the budget one stuck retry is holding, for the benefit of a keystroke + * that arrived on a healthy pane. Called only when a node's share or the + * fleet-wide queue is full, so the cost of capacity lands on a backlog that is + * already waiting to re-fire instead of on whoever types next. The waiting + * pane is usually on a node that has not come back, but it can equally be a + * slow write, and that is the cost being traded here. + * + * Sessions are visited in the order they were created, and the oldest entry + * that is not mid-write goes. An in-flight entry is left alone: its bytes did + * reach the pane, and its charge settles on its own. A dropped entry never + * gets its ack, which is the same overflow outcome a session's holds already + * meet when it disconnects mid-backlog, and the client's own retry machinery is + * what would have carried it. + * + * @returns whether anything was released, so the caller can retry its charge + */ +export function reclaimOneHeldInput(): boolean { + for (const [nodeId, subs] of holds) { + for (const [subshellId, sessions] of subs) { + for (const [sessionId, hold] of sessions) { + const index = hold.queue.findIndex((entry) => !entry.inFlight); + if (index < 0) continue; + const [entry] = hold.queue.splice(index, 1); + entry?.release(); + if (hold.queue.length === 0) { + sessions.delete(sessionId); + if (sessions.size === 0) subs.delete(subshellId); + if (subs.size === 0) holds.delete(nodeId); + } + return true; + } + } + } + return false; +} + /** * Drops every hold. Only for tests, which reuse subshell ids and sessions; * a surviving hold would re-fire the previous case's keystroke into the next * one's pane. @internal */ export function resetInputHoldsForTests(): void { + for (const subs of holds.values()) + for (const sessions of subs.values()) + for (const hold of sessions.values()) { + for (const entry of hold.queue) if (!entry.inFlight) entry.release(); + hold.queue = []; + } holds.clear(); } diff --git a/apps/server/api/src/api/ws/pane-geometry.ts b/apps/server/api/src/api/ws/pane-geometry.ts index 498ba779b..5c689dbe7 100644 --- a/apps/server/api/src/api/ws/pane-geometry.ts +++ b/apps/server/api/src/api/ws/pane-geometry.ts @@ -118,44 +118,36 @@ export function createGeometryQueue(options: GeometryQueueOptions): GeometryQueu return entry; }; - /** - * Applies one size, reports the readback, then drains whatever coalesced - * behind it. Recurses through `pending` rather than looping so a burst that - * keeps arriving mid-apply is always answered by exactly one more round - * trip carrying the newest size. - */ - const run = async (key: string, size: PaneGeometry, sizer: PaneSizer, existing?: Entry): Promise => { - // The recursive tail reuses the entry it already holds: calling - // `entryFor` again would resurrect one that `release` deleted mid-apply. - const entry = existing ?? entryFor(key); + /** Drain one latest-size slot without retaining a promise per resize. */ + const run = async (key: string, initialSize: PaneGeometry, initialSizer: PaneSizer): Promise => { + const entry = entryFor(key); + let size = initialSize; + let sizer = initialSizer; entry.busy = true; try { - await sizer.apply(size.cols, size.rows); - entry.applied = size; - const real = await sizer.read(); - // Nothing to say once the entry is released: the last viewer left while - // this round trip was in flight, and the frame is fanned out BY - // SUBSHELL ID rather than to the socket that asked — so a late - // announcement reaches whoever attaches next and tells them to lay out - // a size decided for viewers who have gone. A null read means the pane - // died, which is equally nothing to announce. - if (real && !entry.released) options.onGeometry(key, real); - } catch (err) { - options.onError?.(err, key); + for (;;) { + try { + await sizer.apply(size.cols, size.rows); + entry.applied = size; + const real = await sizer.read(); + // A released viewer must not announce its stale size to a new one. + if (real && !entry.released) options.onGeometry(key, real); + } catch (err) { + options.onError?.(err, key); + } + if (entry.released) return; + const next = entry.pending; + const nextSizer = entry.pendingSizer; + entry.pending = null; + entry.pendingSizer = null; + if (!next || !nextSizer) return; + if (entry.applied && entry.applied.cols === next.cols && entry.applied.rows === next.rows) return; + size = next; + sizer = nextSizer; + } } finally { entry.busy = false; } - // Released while this apply was in flight: the viewer is gone, so drop - // whatever coalesced behind it rather than resizing a pane nobody sees. - if (entry.released) return; - const next = entry.pending; - const nextSizer = entry.pendingSizer; - entry.pending = null; - entry.pendingSizer = null; - if (!next || !nextSizer) return; - // The burst may have settled on the size the pane already holds. - if (entry.applied && entry.applied.cols === next.cols && entry.applied.rows === next.rows) return; - await run(key, next, nextSizer, entry); }; return { diff --git a/apps/server/api/src/api/ws/pane-sources.ts b/apps/server/api/src/api/ws/pane-sources.ts index cf9b94058..77884aa14 100644 --- a/apps/server/api/src/api/ws/pane-sources.ts +++ b/apps/server/api/src/api/ws/pane-sources.ts @@ -4,6 +4,7 @@ import type { PaneSource } from "@/api/ws/pane-stream.js"; import { SyncStreamStripper } from "@/api/ws/sync-stripper.js"; import type { NodeLauncher } from "@/modules/nodes/node-launcher.js"; import type { RemoteLauncher } from "@/modules/nodes/remote-launcher.js"; +import { logger } from "@/shared/logger.js"; /** * How often the pane log is re-read for new bytes. @@ -192,6 +193,29 @@ export function createPanePollSource(options: PanePollSourceOptions): PaneSource }; } +/** How often the remote tail's watchdog asks the node for the log's size. */ +export const TAIL_STALL_CHECK_MS = 10_000; + +/** + * How long a pump may stay silent while the log it tails has moved before + * the watchdog calls it dead. Comfortably above every honest delay on the + * path (the agent polls every 50 ms, and `tail_start` itself times out at + * 10 s), so it fires on a broken relay, never on a slow one. + */ +export const TAIL_STALL_AFTER_MS = 25_000; + +/** + * The two families a dead remote pump reports into, as the client's close + * reason. Pure and exported so both arms are pinned by test: a collapse to + * any constant (or each other) sends an operator down the wrong path — + * "refused" reads as a node-link problem, "stalled" as a relay going quiet. + * @param reason - the reason {@link RemoteTailSourceOptions.onDead} reported + * @returns the close reason the dropped viewers carry + */ +export function tailCloseReason(reason: string): string { + return reason.startsWith("stalled") ? "tail stalled" : "tail refused"; +} + /** What a source needs to tail a subshell running on a remote node. */ export interface RemoteTailSourceOptions { /** The node's launcher — the relay to the agent that owns the pane. */ @@ -207,6 +231,21 @@ export interface RemoteTailSourceOptions { fromByte: number; /** Called once per emitted chunk, for the lastOutputAt heartbeat. */ onOutput?: () => void; + /** + * Fires exactly once when this pump can no longer deliver: the agent + * refused `tail_start`, or the watchdog caught the pane's log growing + * while the pump sat silent. The attach path needs this because the + * registry builds ONE pump per subshell, for its first viewer: a source + * that dies quietly becomes the permanent view of that pane, handed to + * every later re-attach as long as any viewer lingers (a phone tab does + * for hours). The honest recovery is to drop the whole stream so the next + * attach builds a fresh pump, not to let a corpse stream paint replays. + */ + onDead?: (reason: string) => void; + /** Watchdog period; a test seam over {@link TAIL_STALL_CHECK_MS}. */ + stallCheckMs?: number; + /** Silence that reads as a stall; a test seam over {@link TAIL_STALL_AFTER_MS}. */ + stallAfterMs?: number; } /** @@ -229,7 +268,16 @@ export interface RemoteTailSourceOptions { * twin — a multi-byte character or a DEC 2026 marker split across two reads * must be held until its other half arrives. * - * @param options - Launcher, subshell, start offset, and the output heartbeat + * Because the pump outlives the attach that built it, it carries its own + * liveness: a refused `tail_start` and a watchdog verdict (the log's size + * moves while zero bytes arrive for {@link TAIL_STALL_AFTER_MS}) both report + * the pump dead through {@link RemoteTailSourceOptions.onDead} rather than + * going silent. That silence was the 2026-10-27 stuck-pane incident: one + * dead source stayed every viewer's terminal for two hours because nothing + * on the plane could notice. + * + * @param options - Launcher, subshell, start offset, output heartbeat, and + * the dead-pump report * @returns A source for {@link import("./pane-stream.js").PaneStreamRegistry} */ export function createRemoteTailSource(options: RemoteTailSourceOptions): PaneSource { @@ -237,12 +285,69 @@ export function createRemoteTailSource(options: RemoteTailSourceOptions): PaneSo start(emit) { const decoder = new TextDecoder(); const stripper = new SyncStreamStripper(); + const checkMs = options.stallCheckMs ?? TAIL_STALL_CHECK_MS; + const afterMs = options.stallAfterMs ?? TAIL_STALL_AFTER_MS; let stopped = false; let dispose: (() => void) | undefined; + let reported = false; + // What the launcher's relay has handed this source, and when. Both are + // the watchdog's evidence: the cursor is what the pane has not yet + // shown us past, and the timestamp is how long the silence has run. + let cursor = options.fromByte; + let lastDeliveryAt = Date.now(); + let probeInFlight = false; + + const stop = (): void => { + if (stopped) return; + stopped = true; + clearInterval(watchdog); + dispose?.(); + }; + + const reportDead = (reason: string): void => { + if (reported || stopped) return; + reported = true; + logger.warn(`remote tail dead for subshell ${options.subshellId}: ${reason}; dropping its viewers`); + stop(); // a source that cannot produce must not stay the pane's stream + options.onDead?.(reason); + }; + + const watchdog = setInterval(() => { + if (stopped || reported || probeInFlight) return; + // A pump that delivered recently needs no witness; a streaming pane + // pays nothing beyond the bookkeeping inside onChunk. + if (Date.now() - lastDeliveryAt < afterMs) return; + probeInFlight = true; + void options.launcher + .readLogSized(options.subshellId, 0, 1) + .then(({ size }) => { + probeInFlight = false; + if (stopped || reported) return; + // A chunk can land while the probe is in flight, and then two + // things are true at once: the pump just proved alive, and this + // answer's EOF is already stale below the cursor. Re-ask the + // silence question against NOW, not against when the tick armed + // the probe; a burst mid-probe reads (size < cursor), which is + // otherwise indistinguishable from the rewind this also catches. + if (Date.now() - lastDeliveryAt < afterMs) return; + if (size === cursor) return; // a quiet pane is not a dead pump + reportDead(`stalled: EOF ${size}, delivered ${cursor}, silent ${Date.now() - lastDeliveryAt}ms`); + }) + .catch(() => { + // A failing probe is link trouble, not a stalled tail: a lost + // node socket is closed out-of-band, its viewers with it, so the + // watchdog must never burn a healthy pump over a transient RPC + // failure on the way back. + probeInFlight = false; + }); + }, checkMs); + watchdog.unref?.(); // a tail must never hold the process open on its own void options.launcher - .tailStart(options.subshellId, crypto.randomUUID(), options.fromByte, (bytes) => { + .tailStart(options.subshellId, crypto.randomUUID(), options.fromByte, (bytes, next) => { if (stopped) return; + cursor = next; + lastDeliveryAt = Date.now(); const text = stripper.push(decoder.decode(bytes, { stream: true })); if (!text) return; // nothing paintable yet — the whole read is held emit(text); @@ -252,14 +357,19 @@ export function createRemoteTailSource(options: RemoteTailSourceOptions): PaneSo dispose = disposer; if (stopped) disposer(); // torn down during the tail_start round-trip }) - .catch(() => { - // The relay refused. The attach's own error path closes the socket; - // a source that never produces is the honest outcome here. + .catch((err: unknown) => { + // The relay refused. This used to read as "a source that never + // produces is the honest outcome"; with one pump per subshell, + // inherited by every re-attach, that honesty was a pane frozen + // until the server restarted. Report it, and let the viewers + // rebuild the stream from a fresh join point. + reportDead(`tail_start refused: ${err instanceof Error ? err.message : String(err)}`); }); return () => { stopped = true; stripper.flush(); // held partial-sequence bytes belong to the next stream + clearInterval(watchdog); dispose?.(); }; }, diff --git a/apps/server/api/src/api/ws/remote-subshell-ws.ts b/apps/server/api/src/api/ws/remote-subshell-ws.ts index cb9b2854f..b8071cb70 100644 --- a/apps/server/api/src/api/ws/remote-subshell-ws.ts +++ b/apps/server/api/src/api/ws/remote-subshell-ws.ts @@ -2,12 +2,13 @@ import { forensicsEnabled, recordAttachPaint } from "@/api/ws/attach-forensics.j import type { AttachParams } from "@/api/ws/attach-params.js"; import { captureToReplayText } from "@/api/ws/capture-text.js"; import { captureStable, fitPaneAndRepaint, paneReadsAsBooting, RESIZE_SETTLE_MS } from "@/api/ws/pane-repaint.js"; -import { createRemoteTailSource } from "@/api/ws/pane-sources.js"; +import { createRemoteTailSource, tailCloseReason } from "@/api/ws/pane-sources.js"; import type { Subscription } from "@/api/ws/pane-stream.js"; import { applySharedGeometry, broadcastToViewers, broadcastViewers, + closeViewersForSubshell, paneStreams, persistOutputFor, readPaneCursor, @@ -236,6 +237,13 @@ export async function attachRemoteSubshellWs( subshellId: row.id, fromByte: logStart, onOutput: () => persistOutputFor(row.id), + // The pump is the pane's ONLY byte source, and the registry hands + // it to every later viewer of this row. A dead one therefore has + // to take its audience down with it: these sockets are closed with + // a sub-4000 code (the client's retry signal), and the re-attach + // that follows builds a fresh `tail_start` against the current EOF + // instead of inheriting a corpse that only ever paints replays. + onDead: (reason) => closeViewersForSubshell(row.id, 1011, tailCloseReason(reason)), }), (text) => { // Spec §3.4: a browser that cannot keep up is cut, not queued into — @@ -338,7 +346,7 @@ export async function attachRemoteSubshellWs( const painted = captureToReplayText(replay, cursor ?? undefined); sendFrame(ws, { type: "replay", data: painted }); - recordAttachPaint({ subshellId: row.id, preResize, replay: painted, repainted, nudged }); + recordAttachPaint({ subshellId: row.id, preResize, replay: painted, repainted, nudged, cursor }); if (detached) return; // Deliver: flush what the pump held while the replay was being taken, diff --git a/apps/server/api/src/api/ws/subshell-ws.ts b/apps/server/api/src/api/ws/subshell-ws.ts index eef65fa1d..b05b92d8d 100644 --- a/apps/server/api/src/api/ws/subshell-ws.ts +++ b/apps/server/api/src/api/ws/subshell-ws.ts @@ -2,7 +2,8 @@ import { parseClientFrame } from "@internal/subshell-protocol"; import { forensicsEnabled, recordAttachPaint } from "@/api/ws/attach-forensics.js"; import { resolveAttach } from "@/api/ws/attach-resolve.js"; import { captureToReplayText } from "@/api/ws/capture-text.js"; -import { dropInputHolds, hasHeldInput, holdFailedInput } from "@/api/ws/input-hold.js"; +import { takeInputBudget } from "@/api/ws/input-budget.js"; +import { dropInputHolds, hasHeldInput, holdFailedInput, reclaimOneHeldInput } from "@/api/ws/input-hold.js"; import { inputWindowAdd, inputWindowHas, sanitizeInputSession } from "@/api/ws/input-window.js"; import type { PaneGeometry } from "@/api/ws/pane-geometry.js"; import { captureStable, fitPaneAndRepaint, paneReadsAsBooting, RESIZE_SETTLE_MS } from "@/api/ws/pane-repaint.js"; @@ -23,6 +24,8 @@ import { sendFrame, setSizingPolicy, sharedGridFor, + trackTerminalSocket, + untrackTerminalSocket, type WsData, type WsSocket, } from "@/api/ws/viewers.js"; @@ -76,6 +79,16 @@ const LOG_FILE_WAIT_MS = 1_500; * local attach handler for shared machinery. */ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { + const paneId = url.searchParams.get("subshell"); + if (paneId) trackTerminalSocket(ws, paneId, () => cleanupSubshellWs(ws)); + try { + await attachSubshellWs(ws, url); + } finally { + if (!ws.data.viewerId || ws.data.detachedEarly) untrackTerminalSocket(ws); + } +} + +async function attachSubshellWs(ws: WsSocket, url: URL): Promise { // Who is asking, may they, and about which subshell — one decision, made // without touching the socket (see `attach-resolve.ts`). Refusals come back // rather than closing there, so every close code is owned here. @@ -91,6 +104,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { ws.close(resolved.code, resolved.reason); return; } + if (ws.data.accessRevoked || ws.data.detachedEarly) return; const { row, access, canInput, params, attendsPush } = resolved; // Stash WHO authenticated onto the socket before either path continues — // the local `Object.assign` and the remote relay's both build their own @@ -368,7 +382,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { const replay = text != null ? captureToReplayText(text, cursor ?? undefined) : null; if (replay != null) { sendFrame(ws, { type: "replay", data: replay }); - recordAttachPaint({ subshellId: row.id, preResize, replay, repainted, nudged }); + recordAttachPaint({ subshellId: row.id, preResize, replay, repainted, nudged, cursor }); } // The replay is out; release everything the pane produced while it was @@ -410,7 +424,7 @@ export async function handleSubshellWs(ws: WsSocket, url: URL): Promise { */ export function handleSubshellMessage(ws: WsSocket, message: string | object): void { const data = ws.data; - if (!data?.launcher) return; + if (!data?.launcher || data.accessRevoked || data.inputClosed) return; const frame = parseClientFrame(message); if (!frame) { logger.warn("ws: dropped unrecognized client frame"); @@ -509,6 +523,8 @@ export function handleSubshellMessage(ws: WsSocket, message: string | object): v }); return; } + const release = takeInputBudget(ws, frame.data, reclaimOneHeldInput); + if (!release) return; void data.launcher .sendInput(data.socket, data.subshellId, frame.data) .then(() => { @@ -526,6 +542,7 @@ export function handleSubshellMessage(ws: WsSocket, message: string | object): v sendToSocket(ws, { type: "ack", id }); }) .catch((err) => { + release(); // Transfer the charge to the hold before retaining a retry. // Wave D: a failed plane→node write is HELD, not dropped — the // plane-side hold re-fires it when the node's connection is live // again (ws/input-hold.ts), because the browser socket lives on and @@ -545,7 +562,8 @@ export function handleSubshellMessage(ws: WsSocket, message: string | object): v return; } logFailure(err); - }); + }) + .finally(release); return; } // No id: nothing to hold, and this dispatches PAST any held ids of the @@ -554,7 +572,9 @@ export function handleSubshellMessage(ws: WsSocket, message: string | object): v // disengage drops its own backlog first — so the only bare frame that // can land behind a hold is a client that downgraded mid-session, whose // own retry machinery has already given up. Today's drop-and-log. - void data.launcher.sendInput(data.socket, data.subshellId, frame.data).catch(logFailure); + const release = takeInputBudget(ws, frame.data, reclaimOneHeldInput); + if (!release) return; + void data.launcher.sendInput(data.socket, data.subshellId, frame.data).catch(logFailure).finally(release); } } @@ -587,6 +607,8 @@ function sendToSocket(ws: WsSocket, frame: object): void { /** Stops streaming when the client disconnects. */ export function cleanupSubshellWs(ws: WsSocket): void { + if (ws.data) ws.data.inputClosed = true; + untrackTerminalSocket(ws); // The attach is fired unawaited from the plugin's `open` and reaches // `Object.assign(ws.data, data)` only after an access lookup and a tmux // probe. A close inside that window finds NOTHING to undo — no viewerId to diff --git a/apps/server/api/src/api/ws/viewers.ts b/apps/server/api/src/api/ws/viewers.ts index 2d5757f79..b72f69590 100644 --- a/apps/server/api/src/api/ws/viewers.ts +++ b/apps/server/api/src/api/ws/viewers.ts @@ -15,13 +15,14 @@ import { DEFAULT_SIZING, resolveSharedGrid, type SizingPolicy, type ViewerPresence } from "@internal/subshell-protocol"; import { encodeFrame, type WireMode } from "@internal/subshell-protocol/wire"; -import { resetInputHoldsForTests } from "@/api/ws/input-hold.js"; -import { resetInputWindowsForTests } from "@/api/ws/input-window.js"; +import { dropInputHolds, resetInputHoldsForTests } from "@/api/ws/input-hold.js"; +import { resetInputWindowsForTests, sanitizeInputSession } from "@/api/ws/input-window.js"; import { createGeometryQueue, type PaneGeometry } from "@/api/ws/pane-geometry.js"; import { createPaneStreamRegistry } from "@/api/ws/pane-stream.js"; import { getRequestlessContext } from "@/app/context.js"; import { publishLive } from "@/app/live-bus.js"; import type { NodeLauncher } from "@/modules/nodes/node-launcher.js"; +import { resetTerminalAccessForTests, trackTerminalAccess } from "@/modules/subshells/terminal-access.js"; import { logger } from "@/shared/logger.js"; /** @@ -58,6 +59,12 @@ export interface WsData { /** True when the caller may send terminal input (`edit`/`owner`); a `view` grantee is read-only. */ canInput: boolean; + /** Revoked sockets cannot send or receive, even before their close callback runs. */ + accessRevoked?: boolean; + /** Disconnect or input overflow: late failures must not recreate retry queues. */ + inputClosed?: boolean; + /** Releases the access lease even when attach has not registered a viewer yet. */ + releaseTerminalAccess?: () => void; /** * True when this socket belongs to the human the row's pushes go to (a * cookie identity on their own row) — stashed on `ws.data` by the open @@ -161,6 +168,7 @@ function wsBinaryPayload(bytes: Uint8Array): Buffer { * @param frame - The server frame */ export function sendFrame(ws: WsSocket, frame: object): void { + if (ws.data?.accessRevoked) return; try { const payload = encodeForSocket(ws, frame); ws.send(typeof payload === "string" ? payload : wsBinaryPayload(payload)); @@ -240,6 +248,37 @@ export function persistOutputFor(subshellId: string): void { */ const liveViewers = new Map>(); +/** Track pending and established sockets without coupling the share service to WebSockets. */ +export function trackTerminalSocket(ws: WsSocket, paneId: string, cleanup: () => void): void { + ws.data.releaseTerminalAccess = trackTerminalAccess( + paneId, + () => ws.data.attachUserId, + () => { + // Mark first: a late probe can assign canInput again, and a close event + // need not arrive before another input or output callback. + ws.data.accessRevoked = true; + ws.data.detachedEarly = true; + ws.data.canInput = false; + untrackTerminalSocket(ws); + try { + cleanup(); + } catch { + // The guards still deny traffic if a transport disposer fails. + } + try { + ws.close(1012, "subshell sharing changed"); + } catch { + // Already closed; access is revoked independently of the transport. + } + }, + ); +} + +export function untrackTerminalSocket(ws: WsSocket): void { + ws.data?.releaseTerminalAccess?.(); + if (ws.data) delete ws.data.releaseTerminalAccess; +} + /** * Drops every scrap of per-subshell state this module holds, WITHOUT closing * any socket. Only for tests. @@ -261,6 +300,7 @@ const liveViewers = new Map>(); */ export function resetLiveViewersForTests(): void { liveViewers.clear(); + resetTerminalAccessForTests(); sizingPolicies.clear(); lastOutputWrites.clear(); paneStreams.resetForTests(); @@ -614,6 +654,58 @@ export function dropTerminalSocketsFor(userId: string, reason = "account disable return closed; } +/** + * Close every socket watching ONE subshell, with one code. + * + * The attach path's recovery from a dead output pump: the pane-stream + * registry builds one pump per subshell, for its first viewer, so a source + * that quietly stopped producing would otherwise be handed to every later + * re-attach as long as any viewer lingered. Closing them all is the fix, and + * it runs the socket's own teardown first rather than waiting on the close + * event: a viewer whose `close` never fires is exactly the leak that keeps a + * dead stream alive, so recovery must not depend on it. Sub-4000 codes are + * the client's retry signal (see `use-subshell-ws.ts`), so every device + * re-attaches and the first of those builds a fresh pump against the current + * EOF. + * + * @param subshellId - the pane whose viewers to drop + * @param code - the close code every socket receives + * @param reason - the close reason, for the client's diagnostics + * @returns how many sockets were asked to close + */ +export function closeViewersForSubshell(subshellId: string, code: number, reason: string): number { + const viewers = liveViewers.get(subshellId); + if (!viewers) return 0; + let closed = 0; + // Snapshot: detachViewer empties (and deletes) the map we are standing in. + for (const ws of [...viewers.values()]) { + ws.data.inputClosed = true; + untrackTerminalSocket(ws); + const query = (ws.data as WsData & { query?: Record }).query; + dropInputHolds(ws.data.nodeId, ws.data.subshellId, sanitizeInputSession(query?.sid) ?? ws.data.viewerId); + // Each step its own try: a disposer that throws must not skip the detach, + // or the viewer stays registered, the stream never reaches zero, and the + // next attach inherits the corpse this function exists to bury. + try { + ws.data?.cleanup?.(); // the attach's own disposer: closes the subscription + } catch { + /* bookkeeping continues without it */ + } + try { + detachViewer(ws); + } catch { + /* ditto */ + } + try { + ws.close(code, reason); + closed++; + } catch { + // A socket already gone is the outcome this wanted anyway. + } + } + return closed; +} + /** * Removes a viewer and re-settles everything that depended on it. * diff --git a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts index 008de7432..5ea2164df 100644 --- a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts @@ -392,22 +392,26 @@ 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..0069 together: these migrations are the tail of the + const previewSettingsSchema = db + .query("SELECT sql FROM sqlite_master WHERE type='table' AND name='preview_proxy_settings'") + .get() as { sql: string }; + // WHY 0047..0070 together: these migrations are the tail of the // registered ledger, so the prefix runs contiguously through 0046 and boot // 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','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key') 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','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "ALTER TABLE presets DROP COLUMN enabled; 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','0068-preset-enabled','0069-auth-account-provider-key'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE preview_proxy_settings; ALTER TABLE presets DROP COLUMN enabled; 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','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings'); 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 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); ALTER TABLE presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; ALTER TABLE ssh_presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; UPDATE migration_restore_probe SET value='newer destination';", ); + db.exec(previewSettingsSchema.sql); for (const row of migrationRows) db.query("INSERT INTO kysely_migration(name,timestamp) VALUES (?,?)").run(row.name, row.timestamp); db.close(); @@ -420,7 +424,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','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key')", + "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','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings')", ) .all(), ).toEqual([]); @@ -430,7 +434,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','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key') 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','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings') ORDER BY name", ) .all(), ).toEqual([ @@ -457,6 +461,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0067-display-provider-choices" }, { name: "0068-preset-enabled" }, { name: "0069-auth-account-provider-key" }, + { name: "0070-preview-proxy-settings" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index 098341f30..61351d5a1 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -63,6 +63,7 @@ import * as nodePreviewEnabledMigration from "@/db/migrations/0062-node-preview- import * as subshellServicesMigration from "@/db/migrations/0063-subshell-services.js"; import * as presetEnabledMigration from "@/db/migrations/0068-preset-enabled.js"; import * as authAccountProviderKeyMigration from "@/db/migrations/0069-auth-account-provider-key.js"; +import * as previewProxySettingsMigration from "@/db/migrations/0070-preview-proxy-settings.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"; @@ -193,6 +194,7 @@ export async function runMigrations(): Promise { "0067-display-provider-choices": displayProviderChoicesMigration, "0068-preset-enabled": presetEnabledMigration, "0069-auth-account-provider-key": authAccountProviderKeyMigration, + "0070-preview-proxy-settings": previewProxySettingsMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0070-preview-proxy-settings.ts b/apps/server/api/src/db/migrations/0070-preview-proxy-settings.ts new file mode 100644 index 000000000..a62e01374 --- /dev/null +++ b/apps/server/api/src/db/migrations/0070-preview-proxy-settings.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; +export async function up(db: Kysely): Promise { + await db.schema + .createTable("preview_proxy_settings") + .addColumn("user_id", "text", (c) => c.notNull().references("user.id").onDelete("cascade")) + .addColumn("node_id", "text", (c) => c.notNull().references("nodes.id").onDelete("cascade")) + .addColumn("host", "text", (c) => c.notNull()) + .addColumn("protocol", "text", (c) => c.notNull()) + .addColumn("port", "integer", (c) => c.notNull()) + .addColumn("rewrite_urls", "integer", (c) => c.notNull()) + .addColumn("pass_host", "integer", (c) => c.notNull()) + .addColumn("rewrite_redirects", "integer", (c) => c.notNull()) + .addPrimaryKeyConstraint("preview_proxy_settings_target", ["user_id", "node_id", "host", "protocol", "port"]) + .execute(); +} +export async function down(db: Kysely): Promise { + await db.schema.dropTable("preview_proxy_settings").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0036-node-encrypt-public-key.test.ts b/apps/server/api/src/db/migrations/__tests__/0036-node-encrypt-public-key.test.ts index 0b6e2f411..beadc06a4 100644 --- a/apps/server/api/src/db/migrations/__tests__/0036-node-encrypt-public-key.test.ts +++ b/apps/server/api/src/db/migrations/__tests__/0036-node-encrypt-public-key.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { CamelCasePlugin, Kysely, sql } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; // no-op when already applied import * as initMigration from "@/db/migrations/0001-init.js"; @@ -75,6 +76,7 @@ describe("migration 0036-node-encrypt-public-key", () => { // disk but missing from the map never applies to a shared/production DB, // so ask the migrated database directly what it actually ran. await runMigrations(); + await runAuthMigrations(); const applied = (await sql<{ name: string }>`select name from kysely_migration`.execute(db)).rows; expect(applied.map((r) => r.name)).toContain("0036-node-encrypt-public-key"); diff --git a/apps/server/api/src/db/repositories/__tests__/node-allowed-dirs.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/node-allowed-dirs.repository.test.ts index bd732262b..602b0374b 100644 --- a/apps/server/api/src/db/repositories/__tests__/node-allowed-dirs.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/node-allowed-dirs.repository.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { NodeAllowedDirsRepository } from "@/db/repositories/node-allowed-dirs.repository.js"; @@ -25,6 +26,7 @@ async function seedNode(id: string): Promise { describe("NodeAllowedDirsRepository", () => { beforeAll(async () => { await runMigrations(); + await runAuthMigrations(); await seedNode(NODE); await seedNode(OTHER); }); diff --git a/apps/server/api/src/db/repositories/__tests__/node-setup-keys.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/node-setup-keys.repository.test.ts index 0e5f18035..3a1d0b95e 100644 --- a/apps/server/api/src/db/repositories/__tests__/node-setup-keys.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/node-setup-keys.repository.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, it } from "bun:test"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; // no-op when already applied import { NodeSetupKeysRepository } from "@/db/repositories/node-setup-keys.repository.js"; @@ -13,6 +14,7 @@ const unique = (p: string) => `${p}-${process.pid}-${salt}-${seq++}`; beforeAll(async () => { await runMigrations(); + await runAuthMigrations(); }); describe("NodeSetupKeysRepository", () => { diff --git a/apps/server/api/src/db/repositories/__tests__/nodes.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/nodes.repository.test.ts index 52a4a5e62..9e8179852 100644 --- a/apps/server/api/src/db/repositories/__tests__/nodes.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/nodes.repository.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; // no-op when already applied import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -25,6 +26,7 @@ async function mkNode(repo: NodesRepository, ownerUserId: string, name = unique( beforeAll(async () => { await runMigrations(); + await runAuthMigrations(); }); // This file re-owns `local` to a fixture user and revokes its Everyone diff --git a/apps/server/api/src/db/types/index.ts b/apps/server/api/src/db/types/index.ts index 77bd869aa..4847fee9c 100644 --- a/apps/server/api/src/db/types/index.ts +++ b/apps/server/api/src/db/types/index.ts @@ -26,6 +26,7 @@ import type { SubshellTable } from "@/db/types/subshells.db-types.js"; import type { UserMetaTable } from "@/db/types/user-meta.db-types.js"; import type { WorkspacePaneTable } from "@/db/types/workspace-panes.db-types.js"; import type { WorkspaceTable } from "@/db/types/workspaces.db-types.js"; +import type { PreviewProxySettingsTable } from "./preview-proxy-settings.db-types.js"; /** * Typed database schema for Kysely. Each table mirrors a migration. @@ -33,6 +34,7 @@ import type { WorkspaceTable } from "@/db/types/workspaces.db-types.js"; * them separately.) */ export interface Database { + previewProxySettings: PreviewProxySettingsTable; subshellServices: import("./subshell-services.db-types.js").SubshellServiceTable; presetShares: PresetShareTable; sshPresetShares: PresetShareTable; diff --git a/apps/server/api/src/db/types/preview-proxy-settings.db-types.ts b/apps/server/api/src/db/types/preview-proxy-settings.db-types.ts new file mode 100644 index 000000000..60f353261 --- /dev/null +++ b/apps/server/api/src/db/types/preview-proxy-settings.db-types.ts @@ -0,0 +1,19 @@ +/** One user's preferences for a destination, independent of live capabilities. */ +export interface PreviewProxySettingsTable { + /** Authenticated preference owner. */ + userId: string; + /** Node that dials this destination. */ + nodeId: string; + /** Normalized service host. */ + host: string; + /** HTTP transport chosen for the service. */ + protocol: "http" | "https"; + /** Destination port. */ + port: number; + /** SQLite boolean: rewrite app resource URLs. */ + rewriteUrls: number; + /** SQLite boolean: forward the preview host. */ + passHost: number; + /** SQLite boolean: rewrite app redirects. */ + rewriteRedirects: number; +} diff --git a/apps/server/api/src/modules/backups/database.ts b/apps/server/api/src/modules/backups/database.ts index 8fb410cc9..6867c624f 100644 --- a/apps/server/api/src/modules/backups/database.ts +++ b/apps/server/api/src/modules/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 = "0069-auth-account-provider-key"; +export const LATEST_BACKUP_MIGRATION = "0070-preview-proxy-settings"; /** 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/modules/nodes/__tests__/display-selection.test.ts b/apps/server/api/src/modules/nodes/__tests__/display-selection.test.ts new file mode 100644 index 000000000..fade85d1f --- /dev/null +++ b/apps/server/api/src/modules/nodes/__tests__/display-selection.test.ts @@ -0,0 +1,204 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import type { VirtualDisplayRequest } from "@internal/subshell-protocol"; +import { hashPassword } from "better-auth/crypto"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { resolveNodeDisplayRequest } from "@/modules/nodes/display-selection.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/testing/api/auth-tables.js"; + +/** + * The plane's own display gate, which runs before a row, a token, or an RPC + * exists. Two things are pinned here, and both have been wrong in the field. + * + * The first is WHAT it refuses. A provider the catalog says drives another + * operating system used to pass this door and die at the agent, after the + * subshell row and its MCP token had been written and rolled back: the node is + * the authority on what it can run, but a fact this side already holds (the + * stored `os`, and the catalog's platform for each bundled provider) should cost + * a 400 rather than a launch. + * + * The second is HOW it says so. Every refusal here ends with the node's own word + * in parentheses, because that ending is what decides where the dashboard puts + * the sentence: `isDisplayLaunchRefusal` matches it and keeps the message beside + * the **Virtual display** checkbox whose setting caused the failure. A plane + * refusal without the word lands at the bottom of the form instead, so the same + * problem read differently depending on which side noticed it. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +const SCREEN = { width: 1280, height: 800 }; + +async function mkUser(): Promise { + const email = `display-gate-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("display-gate-pass-1"), + role: "user", + }); +} + +interface NodeDisplayRow { + /** What the agent reported about its platform: "linux", "darwin", "unknown", or never. */ + os: string | null; + enabled: 0 | 1; + provider?: string; + /** The owner's enabled list; absent means the column was never written. */ + providers?: string[]; +} + +async function mkNode(row: NodeDisplayRow): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `gate-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + await db + .updateTable("nodes") + .set({ + os: row.os, + virtualDisplayEnabled: row.enabled, + ...(row.provider === undefined ? {} : { virtualDisplayProvider: row.provider }), + ...(row.providers === undefined ? {} : { virtualDisplayProviders: JSON.stringify(row.providers) }), + }) + .where("id", "=", id) + .execute(); + return id; +} + +/** The refusal's text and status, or a failure that says the launch was accepted. */ +async function refused( + nodeId: string, + provider?: string, + status = 400, + request: VirtualDisplayRequest = SCREEN, +): Promise<{ message: string; status: unknown }> { + try { + const resolved = await resolveNodeDisplayRequest(nodeId, { ...request, provider }, status); + throw new Error(`expected a refusal, got provider ${resolved.provider}`); + } catch (error) { + const http = error as { message: string; status?: unknown }; + if (http.status === undefined) throw error; + return { message: http.message, status: http.status }; + } +} + +/** The routing contract the dashboard depends on: the word is the last thing in the line. */ +function expectTail(message: string, word: string): void { + expect(message.endsWith(` (${word})`)).toBe(true); +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +describe("resolveNodeDisplayRequest", () => { + it("accepts what this machine runs and this node has enabled", async () => { + const id = await mkNode({ os: "darwin", enabled: 1, provider: "betterdisplay", providers: ["betterdisplay"] }); + expect(await resolveNodeDisplayRequest(id, SCREEN)).toEqual({ ...SCREEN, provider: "betterdisplay" }); + // No provider named: the node's own default answers, and it is returned + // resolved so the caller stores one shape. + expect(await resolveNodeDisplayRequest(id, { ...SCREEN, provider: undefined })).toEqual({ + ...SCREEN, + provider: "betterdisplay", + }); + }); + + it("refuses a provider the catalog says this machine cannot run, before the launch", async () => { + // Xvfb is Linux's display server. Asking the agent costs a row, a minted + // token, a signed command, and a rollback; the plane holds both facts + // already (the stored os, and the provider's platform). + const id = await mkNode({ os: "darwin", enabled: 1, providers: ["xvfb-x11vnc", "betterdisplay"] }); + const { message, status } = await refused(id, "xvfb-x11vnc"); + expect(status).toBe(400); + expect(message).toContain("does not run on this node's operating system"); + expectTail(message, "preview-display-unsupported"); + // The same provider on the machine it drives is fine, so this is the pair + // being refused, not the provider. + const linux = await mkNode({ os: "linux", enabled: 1, providers: ["xvfb-x11vnc"] }); + expect((await resolveNodeDisplayRequest(linux, SCREEN)).provider).toBe("xvfb-x11vnc"); + }); + + for (const unanswered of [null, "unknown"]) { + it(`refuses nothing on an os that has not answered (${String(unanswered)})`, async () => { + // "unknown" is a node saying it could not classify itself, which is not a + // claim about what runs there, so the plane does not refuse on it. The node + // still answers for its own machine, and refuses Xvfb on any non-Linux + // platform by name (`xvfbDisplayReadiness`), so this is not a promise that + // the launch will succeed: it is a refusal to guess. Reading it as a verdict + // would empty the provider card of a node that never came ready. + const id = await mkNode({ os: unanswered, enabled: 1, providers: ["xvfb-x11vnc"] }); + expect((await resolveNodeDisplayRequest(id, SCREEN)).provider).toBe("xvfb-x11vnc"); + }); + } + + it("refuses a provider this node's owner has not enabled, naming the door that fixes it", async () => { + const id = await mkNode({ os: "darwin", enabled: 1, providers: ["xvfb-x11vnc"] }); + const { message } = await refused(id, "betterdisplay"); + expect(message).toContain("disabled on the node"); + expect(message).toContain("node settings"); + expectTail(message, "preview-display-provider-disabled"); + }); + + it("refuses a provider no catalog row carries", async () => { + // Not on the wire vocabulary (a stored list may hold a node-owned id, but a + // launch cannot ask for one), and the node would answer `unsupported` for + // it, so the plane says the same word rather than inventing a second one. + const id = await mkNode({ os: "linux", enabled: 1, providers: ["xvfb-x11vnc", "acme-screen"] }); + const { message } = await refused(id, "acme-screen"); + expect(message).toContain("unavailable"); + expectTail(message, "preview-display-unsupported"); + }); + + it("refuses a display on a node whose switch is off", async () => { + // At 409, the restart door, which is where this sentence is normally heard. + // Create checks the same flag earlier (`subshells.service.ts`'s preflight, at + // 400, with its own words), so asserting that pair here would mostly pin the + // preflight's text; the resolver's own line does remain reachable at 400 if + // the owner flips the switch between the two reads. + const id = await mkNode({ os: "linux", enabled: 0, providers: ["xvfb-x11vnc"] }); + const { message } = await refused(id, "xvfb-x11vnc", 409); + expect(message).toContain("Allow virtual displays"); + expectTail(message, "preview-node-off"); + }); + + it("keeps the node's word on the restart path's 409", async () => { + // A restart re-resolves the pinned provider (the row already exists), so the + // same refusal must arrive with the status the restart UI maps and the same + // tail the checkbox matcher reads. + const id = await mkNode({ os: "darwin", enabled: 1, providers: ["xvfb-x11vnc"] }); + const { message, status } = await refused(id, "betterdisplay", 409); + expect(status).toBe(409); + expectTail(message, "preview-display-provider-disabled"); + }); + + it("refuses a malformed request before reading the node", async () => { + // The size bounds are the node's wire rule, applied on this side so a + // hand-made body never reaches a machine. The shape check also answers first + // for an id that exists nowhere, which is what keeps a bad body from being + // reported as a missing node. + const tooBig = await refused("no-such-node", undefined, 400, { ...SCREEN, width: 9999 }); + expect(tooBig.message).toContain("invalid"); + expect(tooBig.status).toBe(400); + }); + + it("refuses a node that is not there, rather than resolving against nothing", async () => { + // An absent row must not fall through to the catalog's Linux default: the + // switch check reads `row?.virtualDisplayEnabled`, so the missing node is + // refused as the switched-off machine it is. + // Same door as the switch case: an absent row is the restart's problem, + // because create answers for a missing node before this resolver runs. + const gone = await refused("no-such-node", "xvfb-x11vnc", 409); + expectTail(gone.message, "preview-node-off"); + }); +}); diff --git a/apps/server/api/src/modules/nodes/__tests__/inventory-detect.test.ts b/apps/server/api/src/modules/nodes/__tests__/inventory-detect.test.ts index fcb48e75d..828bb1b9d 100644 --- a/apps/server/api/src/modules/nodes/__tests__/inventory-detect.test.ts +++ b/apps/server/api/src/modules/nodes/__tests__/inventory-detect.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, setSystemTime } from "bun:test"; import { allHarnesses, getHarness, type HarnessPlugin } from "@internal/pane-runtime"; import type { DetectSpecWire, NodeCommandBody } from "@internal/subshell-protocol"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -119,7 +120,8 @@ describe("detectEnvNames: the plane names what it asks for (spec §5 as amended) }); it("the driver sends the union on the wire (seam catalog; claude-code's real declaration pinned below)", async () => { - await runMigrations(); // this describe sits outside the detectOnNode one (suite idiom) + await runMigrations(); + await runAuthMigrations(); // this describe sits outside the detectOnNode one (suite idiom) const node = await mkNode(); const sent: Sent[] = []; await detectOnNode(node.id, { @@ -154,7 +156,8 @@ describe("detectEnvNames: the plane names what it asks for (spec §5 as amended) describe("detectOnNode", () => { beforeAll(async () => { - await runMigrations(); // no-op when already applied (inventory suite idiom) + await runMigrations(); + await runAuthMigrations(); // no-op when already applied (inventory suite idiom) }); afterAll(async () => { diff --git a/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts index 9c8dfe48d..a3c2a892e 100644 --- a/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts @@ -12,6 +12,7 @@ import { type NodeEvent, parseNodeCommandBody, } from "@internal/subshell-protocol"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -1180,7 +1181,8 @@ describe("paneCursor — the pane's confirmed cursor, or nothing", () => { describe("launch-driven detection kick — default wiring (spec §4/§6.2)", () => { beforeAll(async () => { - await runMigrations(); // no-op when already applied (server suite idiom) + await runMigrations(); + await runAuthMigrations(); // no-op when already applied (server suite idiom) }); it("a `binary missing` launch failure sends the node a detect command and caches the answer", async () => { diff --git a/apps/server/api/src/modules/nodes/display-selection.ts b/apps/server/api/src/modules/nodes/display-selection.ts index 7e9946bab..0482b2459 100644 --- a/apps/server/api/src/modules/nodes/display-selection.ts +++ b/apps/server/api/src/modules/nodes/display-selection.ts @@ -1,5 +1,6 @@ import { DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + displayProviderRunsOnOs, parseVirtualDisplayRequest, storedDisplayProviderIds, VIRTUAL_DISPLAY_PROVIDERS, @@ -8,6 +9,26 @@ import { import { getRequestlessContext } from "@/app/context.js"; import { HttpError } from "@/shared/errors.js"; +/** The node's own refusal word, carried in the tail of a plane refusal. */ +const NODE_OFF = "preview-node-off"; +const PROVIDER_UNSUPPORTED = "preview-display-unsupported"; +const PROVIDER_DISABLED = "preview-display-provider-disabled"; + +/** + * One refusal, in the shape the dashboard routes on. + * + * The word rides at the end because that ending is what decides WHERE the + * sentence appears: `isDisplayLaunchRefusal` matches a `(preview-display-…)` or + * `(preview-node-off)` tail and keeps the refusal beside the **Virtual display** + * checkbox whose setting caused it. A plane refusal without the word lands at the + * bottom of the form instead, so the same failure read differently depending on + * which side noticed it. The words are the node's, never invented here: the + * operator greps one vocabulary. + */ +function refused(status: number, sentence: string, word: string): never { + throw new HttpError(status, `${sentence} (${word})`); +} + /** Resolve once before saving a subshell; restart supplies its pinned provider. */ export async function resolveNodeDisplayRequest( nodeId: string, @@ -18,20 +39,34 @@ export async function resolveNodeDisplayRequest( 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( + refused( status, "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings before starting this subshell.", + NODE_OFF, ); const provider = request.provider ?? row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; if (!VIRTUAL_DISPLAY_PROVIDERS.some((p) => p.id === provider)) - throw new HttpError( + refused( status, "This virtual display provider is unavailable. Choose an available provider for a new subshell.", + PROVIDER_UNSUPPORTED, + ); + // The catalog knows which machine each provider drives and the row knows which + // machine this is, so asking the agent costs a row, a minted token, a signed + // command, and a rollback for a screen that cannot exist. An `os` that has not + // answered is no verdict (`displayProviderRunsOnOs`): a node that could not + // classify itself keeps the provider its own readiness check will accept. + if (!displayProviderRunsOnOs(provider, row.os)) + refused( + status, + "This virtual display provider does not run on this node's operating system. Choose a provider this machine supports, or launch without a virtual display.", + PROVIDER_UNSUPPORTED, ); if (!storedDisplayProviderIds(row.virtualDisplayProviders).includes(provider)) - throw new HttpError( + refused( status, "This virtual display provider is disabled on the node. Enable it in node settings or choose another provider for a new subshell.", + PROVIDER_DISABLED, ); return { ...request, provider }; } diff --git a/apps/server/api/src/modules/nodes/ssh-rpc.ts b/apps/server/api/src/modules/nodes/ssh-rpc.ts index 1a2319c81..a66f6edfd 100644 --- a/apps/server/api/src/modules/nodes/ssh-rpc.ts +++ b/apps/server/api/src/modules/nodes/ssh-rpc.ts @@ -212,7 +212,7 @@ export async function sshAgentIdentities(nodeId: string): Promise { ].map((name) => req.headers.get(name)), body: req.method === "GET" || req.method === "HEAD" ? "" : await req.text(), }; + if (url.pathname === "/headers") return Response.json(Object.fromEntries(req.headers)); + if (url.pathname === "/page") + return new Response('

App

', { + headers: { "content-type": "text/html" }, + }); + if (url.pathname === "/absolute-redirect") + return new Response(null, { status: 302, headers: { location: `http://127.0.0.1:${targetPort}/page` } }); if (url.pathname === "/echo") { return Response.json(lastSeen); } @@ -378,6 +385,43 @@ async function probeUpgrade(port: number, path: string, origin?: string): Promis /* ------------------------------------------------------------------ */ describe("pv-http: the same-origin rewrite", () => { + it("applies snapshotted host/rewrite settings to the real lane", async () => { + const broker = getPreviewBroker(); + const { ref } = await broker.openForward({ + viewerUserId: USER, + paneId: PANE, + nodeId: NODE, + port: targetPort, + proxySettings: { rewriteUrls: false, passHost: true, rewriteRedirects: false }, + }); + const headers = (await ( + await proxyFetch(`/pv/${ref}/headers`, { + headers: { + origin: "null", + referer: `http://localhost:3080/pv/${ref}/`, + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "no-cors", + }, + }) + ).json()) as Record; + expect(headers.host).toBe("localhost:3080"); + expect(headers.origin).toBeUndefined(); + expect(headers.referer).toBeUndefined(); + expect(headers["sec-fetch-site"]).toBeUndefined(); + expect(await (await proxyFetch(`/pv/${ref}/page`)).text()).toContain('href="/style.css"'); + expect((await proxyFetch(`/pv/${ref}/redirect`)).headers.get("location")).toBe(`/pv/${ref}/foo?x=1`); + const redirect = await proxyFetch(`/pv/${ref}/absolute-redirect`); + expect(redirect.headers.get("location")).toBe(`http://127.0.0.1:${targetPort}/page`); + const defaultRef = await openSession(targetPort); + expect(await (await proxyFetch(`/pv/${defaultRef}/page`)).text()).toContain(`/pv/${defaultRef}/style.css`); + expect((await proxyFetch(`/pv/${defaultRef}/absolute-redirect`)).headers.get("location")).toBe( + `/pv/${defaultRef}/page`, + ); + const otherOrigin = (await ( + await proxyFetch(`/pv/${defaultRef}/headers`, { headers: { origin: "https://elsewhere.test" } }) + ).json()) as Record; + expect(otherOrigin.origin).toBe("https://elsewhere.test"); + }); 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`); diff --git a/apps/server/api/src/modules/previews/__tests__/response-transform.test.ts b/apps/server/api/src/modules/previews/__tests__/response-transform.test.ts new file mode 100644 index 000000000..cee8b078d --- /dev/null +++ b/apps/server/api/src/modules/previews/__tests__/response-transform.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "bun:test"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { previewSrcset, previewUrl } from "../preview-url.js"; +import { rewriteCss, rewriteModule, transformPreviewResponse } from "../response-transform.js"; + +const target = "http://127.0.0.1:3401"; +const proxy = "https://plane.test/pv/secret/"; +const map = (value: string) => previewUrl(value, `${target}/nested/page`, target, proxy); + +describe("preview resource compatibility", () => { + it("maps only this app and preserves external URLs, query and fragments", () => { + expect(map("/style.css?v=1#x")).toBe(`${proxy}style.css?v=1#x`); + expect(map("../a")).toBe(`${proxy}a`); + expect(map(`${target}/a`)).toBe(`${proxy}a`); + expect(map("https://cdn.test/a")).toBe("https://cdn.test/a"); + expect(map("data:image/png;base64,aa")).toBe("data:image/png;base64,aa"); + expect(map("#x")).toBe("#x"); + expect(map(`${proxy}a`)).toBe(`${proxy}a`); + expect(map("ws://127.0.0.1:3401/hmr")).toBe("wss://plane.test/pv/secret/hmr"); + expect(map("/../../api/auth")).toBe(`${proxy}api/auth`); + expect(map("https://foreign.test/pv/secret/a")).toBe("https://foreign.test/pv/secret/a"); + expect(previewUrl("child", `${proxy}nested/page`, target, proxy)).toBe(`${proxy}nested/child`); + }); + it("preserves srcset data URLs and rewrites candidates", () => { + expect(previewSrcset("data:image/png;base64,aa 1x, /large.png 2x", map)).toBe( + `data:image/png;base64,aa 1x, ${proxy}large.png 2x`, + ); + }); + it("parses CSS url and import syntax without touching comments or ordinary strings", () => { + const result = rewriteCss( + '/* url(/private) */ @import "/a.css"; p { background:url( /image.png ); content:"url(/text)";src:url(https://cdn.test/font)}', + map, + ); + expect(result).toContain(`@import "${proxy}a.css"`); + expect(result).toContain(`url( "${proxy}image.png" )`); + expect(result).toContain("/* url(/private) */"); + expect(result).toContain('content:"url(/text)"'); + expect(result).toContain("url(https://cdn.test/font)"); + }); + it("rewrites static and literal dynamic imports, retaining computed imports and unrelated strings", async () => { + const result = await rewriteModule( + `import x from '/x.js'; export * from '/e.js'; import('/lazy.js'); import(\`/parts/\${name}.js\`); const text="/x.js";`, + map, + ); + expect(result).toContain(`from "${proxy}x.js"`); + expect(result).toContain(`from "${proxy}e.js"`); + expect(result).toContain(`import("${proxy}lazy.js")`); + expect(result).toContain(`import(\`/parts/\${name}.js\`)`); + expect(result).toContain('const text="/x.js"'); + }); + it("keeps computed imports single-evaluation and maps import-map addresses", async () => { + const module = await rewriteModule('import(getName(), {with:{type:"json"}})', map, `${target}/main.js`); + expect(module.match(/getName\(\)/g)).toHaveLength(1); + expect(module).toContain("__subshellPreviewMap"); + expect(module).toContain('{with:{type:"json"}}'); + const response = await transformPreviewResponse( + new Response( + '', + { headers: { "content-type": "text/html" } }, + ), + target, + proxy, + "/", + ); + const text = await response.text(); + expect(text).toContain(`"react":"${proxy}react.js"`); + expect(text).toContain(`"${proxy}nested/":{"lib":"${proxy}lib.js"}`); + }); + it("rewrites HTML attributes/styles/module scripts and injects before app scripts", async () => { + const response = await transformPreviewResponse( + new Response( + '
', + { headers: { "content-type": "text/html", etag: "old" } }, + ), + target, + proxy, + "/", + ); + const text = await response.text(); + expect(text).toContain(`'); + expect(output).not.toContain(""); + expect(output).toContain("\\u003c/script\\u003e"); +}); +it("lets a cold-start burst wait on small upstream bodies without refusing imports", async () => { + const streams = Array.from({ length: 20 }, () => { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const result = transformPreviewResponse( + new Response(body, { headers: { "content-type": "text/javascript" } }), + target, + proxy, + "/app.js", + ); + return { controller, result }; + }); + for (const { controller } of streams) { + controller.enqueue(new TextEncoder().encode('import "/app.js";')); + controller.close(); + } + const responses = await Promise.all(streams.map(({ result }) => result)); + for (const response of responses) expect(await response.text()).toContain(proxy); +}); + +it("bounds aggregate decoded input and releases its budget after cancellation", async () => { + const pending: Array> = []; + const controllers: ReadableStreamDefaultController[] = []; + for (let i = 0; i < 8; i++) { + const body = new ReadableStream({ + start(controller) { + controllers.push(controller); + }, + }); + pending.push( + transformPreviewResponse( + new Response(body, { headers: { "content-type": "text/javascript" } }), + target, + proxy, + "/app.js", + ), + ); + } + // Keep each stream just below the per-resource cap; none has finished, so + // their decoded bytes remain charged while a ninth resource arrives. + const chunk = new Uint8Array(16 * 1024 * 1024).fill(32); + for (const controller of controllers) controller.enqueue(chunk); + await new Promise((resolve) => setTimeout(resolve, 0)); + const excess = transformPreviewResponse( + new Response("x", { headers: { "content-type": "text/javascript" } }), + target, + proxy, + "/app.js", + ); + await expect(excess).rejects.toThrow("Too much app content"); + for (const controller of controllers) controller.error(new Error("cancel fixture")); + await Promise.allSettled(pending); + const recovered = await transformPreviewResponse( + new Response('import "/app.js";', { headers: { "content-type": "text/javascript" } }), + target, + proxy, + "/app.js", + ); + expect(await recovered.text()).toContain(proxy); +}); diff --git a/apps/server/api/src/modules/previews/browser-compat.ts b/apps/server/api/src/modules/previews/browser-compat.ts new file mode 100644 index 000000000..955ee8dc6 --- /dev/null +++ b/apps/server/api/src/modules/previews/browser-compat.ts @@ -0,0 +1,92 @@ +import { previewSrcset, previewUrl } from "./preview-url.js"; + +// Executed only inside the sandbox; kept as source to avoid importing browser globals into server types. +const browserCompatSource = `function installPreviewCompat(target, proxy, mapUrl, mapSrcset) { + const map = (value) => mapUrl(value, document.baseURI, target, proxy); + Object.defineProperty(globalThis, "__subshellPreviewMap", { value: (value, base) => { + const text = "" + value; + return text.startsWith("/") || text.startsWith("./") || text.startsWith("../") || text.startsWith("http://") || text.startsWith("https://") + ? mapUrl(text, base, target, proxy) : text; + } }); + const attributes = new Set(["src", "href", "action", "formaction", "poster", "data"]); + const attribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + const key = name.toLowerCase(); + return attribute.call(this, name, key === "srcset" ? mapSrcset(String(value), map) : attributes.has(key) ? map(String(value)) : value); + }; + for (const [constructor, properties] of [ + [HTMLScriptElement, ["src"]], + [HTMLImageElement, ["src", "srcset"]], + [HTMLLinkElement, ["href"]], + [HTMLAnchorElement, ["href"]], + [HTMLFormElement, ["action"]], + [HTMLInputElement, ["src", "formAction"]], + [HTMLButtonElement, ["formAction"]], + [HTMLSourceElement, ["src", "srcset"]], + [HTMLMediaElement, ["src"]], + [HTMLVideoElement, ["poster"]], + [HTMLIFrameElement, ["src"]] + ]) { + for (const property of properties) { + const descriptor = Object.getOwnPropertyDescriptor(constructor.prototype, property); + if (!descriptor?.set) + continue; + Object.defineProperty(constructor.prototype, property, { ...descriptor, set(value) { + descriptor.set.call(this, property === "srcset" ? mapSrcset(String(value), map) : map(String(value))); + } }); + } + } + const nativeFetch = window.fetch; + window.fetch = function(input, init) { + if (input instanceof Request) { + const url = map(input.url); + if (url !== input.url) + input = new Request(url, input); + } else + input = map(String(input)); + return nativeFetch.call(this, input, init); + }; + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(...args) { + args[1] = map(String(args[1])); + return Reflect.apply(open, this, args); + }; + for (const name of ["WebSocket", "EventSource"]) { + const Original = window[name]; + if (!Original) + continue; + Object.defineProperty(window, name, { configurable: true, writable: true, value: new Proxy(Original, { + construct(ctor, args, newTarget) { + args[0] = map(String(args[0])); + return Reflect.construct(ctor, args, newTarget); + } + }) }); + } + new MutationObserver((changes) => { + const fix = (element) => { + for (const name of [...attributes, "srcset"]) { + const old = element.getAttribute(name); + if (old === null) + continue; + const next = name === "srcset" ? mapSrcset(old, map) : map(old); + if (old !== next) + attribute.call(element, name, next); + } + }; + for (const change of changes) + for (const node of change.addedNodes) { + if (!(node instanceof Element)) + continue; + fix(node); + for (const child of node.querySelectorAll("[src],[href],[action],[formaction],[poster],[srcset]")) + fix(child); + } + }).observe(document, { childList: true, subtree: true }); +}`; + +export function previewCompatScript(target: string, proxy: string): string { + return `(${browserCompatSource})(${JSON.stringify(target)},${JSON.stringify(proxy)},${previewUrl.toString()},${previewSrcset.toString()});`.replace( + /<\/script/gi, + "<\\/script", + ); +} diff --git a/apps/server/api/src/modules/previews/lane-ws-client.ts b/apps/server/api/src/modules/previews/lane-ws-client.ts index cb89fa940..13d4b9ec5 100644 --- a/apps/server/api/src/modules/previews/lane-ws-client.ts +++ b/apps/server/api/src/modules/previews/lane-ws-client.ts @@ -83,6 +83,8 @@ export interface LaneWsHandle { } export interface LaneWsInit { + /** Browser-facing authority only when explicitly selected in preview settings. */ + hostHeader?: string; /** The port the node dials; it rides the `Host` header only. */ port: number; host?: string; @@ -130,7 +132,7 @@ export async function openLaneWebSocket(duplex: Duplex, init: LaneWsInit): Promi const key = randomBytes(16).toString("base64"); const lines = [ `GET ${init.path} HTTP/1.1`, - `host: ${serviceAuthority(init.host ?? "127.0.0.1", init.port)}`, + `host: ${init.hostHeader ?? serviceAuthority(init.host ?? "127.0.0.1", init.port)}`, "connection: upgrade", "upgrade: websocket", ]; diff --git a/apps/server/api/src/modules/previews/preview-broker.ts b/apps/server/api/src/modules/previews/preview-broker.ts index bc142daeb..244a311e0 100644 --- a/apps/server/api/src/modules/previews/preview-broker.ts +++ b/apps/server/api/src/modules/previews/preview-broker.ts @@ -28,6 +28,7 @@ import { type AuditEventInput, audit } from "@/modules/audit.js"; import { NodeRpcError, sendCommand } from "@/modules/nodes/node-rpc.js"; import { sendPreviewFrameOverNodeSocket } from "@/modules/nodes/preview-frames.js"; import { getLocalPreviewRuntime, sweepLocalPreviewDisplays } from "@/modules/previews/local-preview.js"; +import type { PreviewProxySettings } from "@/modules/previews/proxy-settings.js"; import { type Access, accessAtLeast, loadSubshellAccess } from "@/modules/subshells/access.js"; import { getLogger } from "@/shared/logger.js"; @@ -143,6 +144,7 @@ export interface PreviewLane { * the pane's one screencast bridge (`kind: "screencast"`). */ export interface PreviewSession { + proxySettings?: PreviewProxySettings; service?: ServiceTarget; serviceId?: string; /** The 32-hex session ref (for forward, the PROBE ref: minted from the CSPRNG, this is the credential). */ @@ -186,6 +188,7 @@ export interface PreviewSession { /** The verbs the routes, the ws feed, and the sweeps call (Tasks 9-11). */ interface PreviewOpenInput { + proxySettings?: PreviewProxySettings; service?: ServiceTarget; serviceId?: string; viewerUserId: string; @@ -886,6 +889,7 @@ function buildPreviewBroker(deps: PreviewBrokerDeps): PreviewBroker { port, service: input.service, serviceId: input.serviceId, + proxySettings: input.proxySettings ? { ...input.proxySettings } : undefined, createdBy: viewerUserId, createdAt: deps.nowMs(), lanes: new Map(), diff --git a/apps/server/api/src/modules/previews/preview-url.ts b/apps/server/api/src/modules/previews/preview-url.ts new file mode 100644 index 000000000..4634b7124 --- /dev/null +++ b/apps/server/api/src/modules/previews/preview-url.ts @@ -0,0 +1,52 @@ +/** Also serialized into the preview shim: keep this function closure-free. */ +export function previewUrl(value: string, base: string, target: string, proxy: string): string { + if (!value || value.startsWith("#")) return value; + try { + const proxyUrl = new URL(proxy); + const logicalBase = new URL(base); + if (logicalBase.origin === proxyUrl.origin && logicalBase.pathname.startsWith(proxyUrl.pathname)) { + base = target + logicalBase.pathname.slice(proxyUrl.pathname.length - 1) + logicalBase.search; + } + const resolved = new URL(value, base); + if (!["http:", "https:", "ws:", "wss:"].includes(resolved.protocol) || resolved.username || resolved.password) + return value; + const websocket = resolved.protocol === "ws:" || resolved.protocol === "wss:"; + const origin = resolved.origin.replace(/^ws/, "http"); + if (origin === proxyUrl.origin && resolved.pathname.startsWith(proxyUrl.pathname)) return resolved.href; + if (origin !== new URL(target).origin && origin !== proxyUrl.origin) return value; + const output = new URL(proxy); + if (websocket) output.protocol = output.protocol === "https:" ? "wss:" : "ws:"; + return output.href + resolved.pathname.slice(1) + resolved.search + resolved.hash; + } catch { + return value; + } +} + +/** The URL token ends at whitespace, not at a data URL's embedded commas. */ +export function previewSrcset(value: string, map: (url: string) => string): string { + let index = 0; + const candidates: string[] = []; + while (index < value.length) { + while (index < value.length && /[\s,]/.test(value[index])) index++; + const start = index; + while (index < value.length && !/\s/.test(value[index])) index++; + const url = value.slice(start, index); + if (!url) break; + if (url.endsWith(",")) { + candidates.push(map(url.replace(/,+$/, ""))); + continue; + } + const descriptorStart = index; + let depth = 0; + while (index < value.length) { + const char = value[index]; + if (char === "(") depth++; + if (char === ")") depth--; + if (char === "," && depth === 0) break; + index++; + } + candidates.push(map(url) + value.slice(descriptorStart, index)); + index++; + } + return candidates.join(", "); +} diff --git a/apps/server/api/src/modules/previews/proxy-settings.ts b/apps/server/api/src/modules/previews/proxy-settings.ts new file mode 100644 index 000000000..8bb7fbe3d --- /dev/null +++ b/apps/server/api/src/modules/previews/proxy-settings.ts @@ -0,0 +1,80 @@ +import { normalizeServiceHost } from "@internal/subshell-protocol"; +import { db } from "@/db/index.js"; +import { HttpError } from "@/shared/errors.js"; + +export interface PreviewProxySettings { + rewriteUrls: boolean; + passHost: boolean; + rewriteRedirects: boolean; +} +export const DEFAULT_PREVIEW_PROXY_SETTINGS: Readonly = Object.freeze({ + rewriteUrls: true, + passHost: false, + rewriteRedirects: true, +}); +export const PREVIEW_SETTINGS_PER_USER = 256; +export interface PreviewSettingsTarget { + userId: string; + nodeId: string; + host: string; + protocol: "http" | "https"; + port: number; +} +function key(target: PreviewSettingsTarget): PreviewSettingsTarget { + const host = normalizeServiceHost(target.host); + if (!host) throw new HttpError(400, "This preview's host isn't valid."); + return { ...target, host }; +} +export async function readPreviewProxySettings(target: PreviewSettingsTarget): Promise { + const row = await db + .selectFrom("previewProxySettings") + .selectAll() + .where((eb) => eb.and({ ...key(target) })) + .executeTakeFirst(); + return row + ? { rewriteUrls: !!row.rewriteUrls, passHost: !!row.passHost, rewriteRedirects: !!row.rewriteRedirects } + : { ...DEFAULT_PREVIEW_PROXY_SETTINGS }; +} +export async function savePreviewProxySettings( + target: PreviewSettingsTarget, + settings: PreviewProxySettings | null, +): Promise { + const targetKey = key(target); + await db.transaction().execute(async (trx) => { + if (!settings) { + await trx + .deleteFrom("previewProxySettings") + .where((eb) => eb.and({ ...targetKey })) + .execute(); + return; + } + const existing = await trx + .selectFrom("previewProxySettings") + .select("port") + .where((eb) => eb.and({ ...targetKey })) + .executeTakeFirst(); + if (!existing) { + const count = await trx + .selectFrom("previewProxySettings") + .select((eb) => eb.fn.countAll().as("count")) + .where("userId", "=", target.userId) + .executeTakeFirstOrThrow(); + if (count.count >= PREVIEW_SETTINGS_PER_USER) + throw new HttpError( + 409, + "You've saved settings for too many previews. Reset an unused preview to save another.", + ); + } + const values = { + rewriteUrls: Number(settings.rewriteUrls), + passHost: Number(settings.passHost), + rewriteRedirects: Number(settings.rewriteRedirects), + }; + await trx + .insertInto("previewProxySettings") + .values({ ...targetKey, ...values }) + .onConflict((oc) => oc.columns(["userId", "nodeId", "host", "protocol", "port"]).doUpdateSet(values)) + .execute(); + }); + return settings ?? { ...DEFAULT_PREVIEW_PROXY_SETTINGS }; +} diff --git a/apps/server/api/src/modules/previews/refusals.ts b/apps/server/api/src/modules/previews/refusals.ts index cbb1ab84c..b7203471d 100644 --- a/apps/server/api/src/modules/previews/refusals.ts +++ b/apps/server/api/src/modules/previews/refusals.ts @@ -66,13 +66,17 @@ export const SCREEN_LAUNCH_REMEDIES: Readonly> = { /** * 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. + * the word is known, plus the node's own detail verbatim. The word ALWAYS + * rides along, bare form included: the sentence explains, the word is what a + * reader greps and what the dashboard matches to decide the refusal belongs + * beside the Virtual display control rather than at the bottom of the form + * (`isDisplayLaunchRefusal`). A refusal this mirror does not speak is null, + * untouched. */ 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 `${remedy} (${detail})`; } } return null; diff --git a/apps/server/api/src/modules/previews/response-transform.ts b/apps/server/api/src/modules/previews/response-transform.ts new file mode 100644 index 000000000..b4b43b9f2 --- /dev/null +++ b/apps/server/api/src/modules/previews/response-transform.ts @@ -0,0 +1,320 @@ +import { pipeline, Readable } from "node:stream"; +import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; +import { init, parse } from "es-module-lexer"; +import valueParser from "postcss-value-parser"; +import { HttpError } from "@/shared/errors.js"; +import { previewCompatScript } from "./browser-compat.js"; +import { previewSrcset, previewUrl } from "./preview-url.js"; + +const MAX_TRANSFORM_BYTES = 16 * 1024 * 1024; +// Charge decoded bytes as they arrive, rather than refusing a cold-start burst +// of tiny modules merely because their upstream streams are still open. +const MAX_BUFFERED_BYTES = 128 * 1024 * 1024; +let bufferedBytes = 0; + +/** Quote a JS literal that can also safely appear inside an HTML script. */ +function scriptString(value: string): string { + return JSON.stringify(value).replace(//g, "\\u003e"); +} +function cssUnescape(value: string): string { + return value.replace(/\\([\da-f]{1,6}\s?|\r\n|[\s\S])/gi, (_, escaped: string) => { + if (/^[\da-f]/i.test(escaped)) { + const point = Number.parseInt(escaped.trim(), 16); + return point === 0 || point > 0x10ffff ? "\ufffd" : String.fromCodePoint(point); + } + return /[\r\n\f]/.test(escaped) ? "" : escaped; + }); +} +export function rewriteCss(source: string, map: (url: string) => string): string { + const parsed = valueParser(source); + const rewrite = (node: valueParser.Node) => { + if (node.type !== "word" && node.type !== "string") return; + const next = map(cssUnescape(node.value)); + if (next === cssUnescape(node.value)) return; + Object.assign(node, { + type: "string", + quote: '"', + value: next.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a "), + }); + }; + parsed.walk((node) => { + if (node.type === "function" && node.value.toLowerCase() === "url") { + for (const child of node.nodes) rewrite(child); + return false; + } + }); + for (let i = 0; i < parsed.nodes.length; i++) { + if (parsed.nodes[i]?.value.toLowerCase() !== "@import") continue; + let next = i + 1; + while (parsed.nodes[next]?.type === "space" || parsed.nodes[next]?.type === "comment") next++; + if (parsed.nodes[next]?.type === "string") rewrite(parsed.nodes[next]); + } + return parsed.toString(); +} +export async function rewriteModule(source: string, map: (url: string) => string, base?: string): Promise { + await init; + let imports: ReturnType[0]; + try { + [imports] = parse(source); + } catch { + // The browser owns syntax errors in the app's code; don't turn them into + // proxy failures, or rewrite only part of an invalid module. + return source; + } + const edits: { start: number; end: number; text: string }[] = []; + let expansion = 0; + const add = (start: number, end: number, text: string) => { + expansion += Math.max(0, text.length - (end - start)); + if (expansion > MAX_TRANSFORM_BYTES) + throw new HttpError( + 502, + "This app module expands too much to rewrite. Turn off Rewrite app URLs in Configure preview.", + ); + edits.push({ start, end, text }); + }; + for (const item of imports) { + if (!("specifier" in item)) continue; + if (item.type === "dynamic" && (!item.specifier || item.glob)) { + if (base) { + // Wrap only the argument, keeping options and nested imports intact. + // Worker realms without the document shim retain their own behavior. + add( + item.start, + item.start, + `((v)=>globalThis.__subshellPreviewMap?globalThis.__subshellPreviewMap(v,${scriptString(base)}):v)(`, + ); + add(item.end, item.end, ")"); + } + continue; + } + if (!item.specifier || !/^(?:\.{0,2}\/|https?:\/\/)/.test(item.specifier)) continue; + const mapped = map(item.specifier); + if (mapped === item.specifier) continue; + add( + item.type === "dynamic" ? item.start : item.start - 1, + item.type === "dynamic" ? item.end : item.end + 1, + scriptString(mapped), + ); + } + // A single assembly keeps attacker-controlled import counts from making + // response rewriting quadratic in the length of the module. + edits.sort((a, b) => a.start - b.start || a.end - b.end); + const parts: string[] = []; + let offset = 0; + for (const edit of edits) { + parts.push(source.slice(offset, edit.start), edit.text); + offset = edit.end; + } + parts.push(source.slice(offset)); + return parts.join(""); +} +async function boundedText(body: ReadableStream, charge: (bytes: number) => void): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let bytes = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + charge(value.length); + bytes += value.length; + if (bytes > MAX_TRANSFORM_BYTES) + throw new HttpError( + 502, + "This app response is too large to rewrite. Turn off Rewrite app URLs in Configure preview.", + ); + chunks.push(decoder.decode(value, { stream: true })); + } + chunks.push(decoder.decode()); + return chunks.join(""); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} +/** Only text resources are buffered. Fetch/SSE/binary bodies retain their stream. */ +export async function transformPreviewResponse( + response: Response, + target: string, + proxy: string, + path: string, +): Promise { + const type = response.headers.get("content-type") ?? ""; + const html = /^text\/html\b/i.test(type); + const css = /^text\/css\b/i.test(type); + const js = /^(?:text|application)\/(?:javascript|ecmascript)\b/i.test(type); + if (!response.body || (!html && !css && !js)) return response; + if (response.status === 206) { + await response.body.cancel(); + throw new HttpError( + 502, + "This app returned a partial resource that cannot be rewritten. Turn off Rewrite app URLs in Configure preview.", + ); + } + let retainedBytes = 0; + const charge = (bytes: number) => { + if (bufferedBytes + bytes > MAX_BUFFERED_BYTES) + throw new HttpError(503, "Too much app content is being rewritten. Refresh the preview shortly."); + bufferedBytes += bytes; + retainedBytes += bytes; + }; + try { + const charset = /charset\s*=\s*["']?([^;"'\s]+)/i.exec(type)?.[1]?.toLowerCase(); + if (charset && charset !== "utf-8" && charset !== "utf8" && charset !== "us-ascii") { + await response.body.cancel(); + throw new HttpError( + 502, + "This app uses an unsupported text encoding. Turn off Rewrite app URLs in Configure preview.", + ); + } + let body = response.body; + const encoding = response.headers.get("content-encoding")?.trim().toLowerCase(); + if (encoding && encoding !== "identity") { + const decoder = + encoding === "gzip" + ? createGunzip() + : encoding === "deflate" + ? createInflate() + : encoding === "br" + ? createBrotliDecompress() + : null; + if (!decoder) { + await body.cancel(); + throw new HttpError( + 502, + "This app uses unsupported compression. Turn off Rewrite app URLs in Configure preview.", + ); + } + const input = Readable.fromWeb(body as never); + pipeline(input, decoder, () => { + /* The web reader observes errors; pipeline tears down both ends. */ + }); + body = Readable.toWeb(decoder) as ReadableStream; + } + const source = await boundedText(body, charge); + const base = new URL(path, target).href; + let effectiveBase = base; + let expansion = 0; + const map = (value: string) => { + const mapped = previewUrl(value, effectiveBase, target, proxy); + expansion += Math.max(0, mapped.length - value.length); + if (expansion > MAX_TRANSFORM_BYTES) + throw new HttpError( + 502, + "This app response expands too much to rewrite. Turn off Rewrite app URLs in Configure preview.", + ); + return mapped; + }; + let output: string; + if (css) output = rewriteCss(source, map); + else if (js) output = await rewriteModule(source, map, base); + else { + const script = ``; + let injected = false; + let baseSeen = false; + await new HTMLRewriter() + .on("base[href]", { + element(el) { + if (!baseSeen) { + try { + effectiveBase = new URL(el.getAttribute("href") ?? "", base).href; + } catch {} + baseSeen = true; + } + }, + }) + .transform(new Response(source)) + .text(); + const rewriter = new HTMLRewriter() + .on("head", { + element(el) { + el.prepend(script, { html: true }); + injected = true; + }, + }) + .on("*", { + element(el) { + if (!injected && el.tagName === "script") { + el.before(script, { html: true }); + injected = true; + } + for (const name of ["src", "href", "action", "formaction", "poster", "data"]) { + const value = el.getAttribute(name); + if (value === null) continue; + const mapped = + el.tagName === "base" && name === "href" ? previewUrl(value, base, target, proxy) : map(value); + el.setAttribute(name, mapped); + if (mapped !== value && (el.tagName === "script" || el.tagName === "link")) + el.removeAttribute("integrity"); + } + const srcset = el.getAttribute("srcset"); + if (srcset !== null) el.setAttribute("srcset", previewSrcset(srcset, map)); + const style = el.getAttribute("style"); + if (style !== null) el.setAttribute("style", rewriteCss(style, map)); + }, + }); + for (const selector of ["style", 'script[type="module"]', 'script[type="importmap"]']) { + let text = ""; + rewriter.on(selector, { + text(chunk) { + text += chunk.text; + chunk.remove(); + }, + element(el) { + text = ""; + el.onEndTag(async (end) => { + let changed: string; + if (selector === 'script[type="importmap"]') { + try { + const importMap = JSON.parse(text); + const mapEntries = (entries: Record) => + Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + /^(?:\.{0,2}\/|https?:\/\/)/.test(key) ? map(key) : key, + typeof value === "string" ? map(value) : value, + ]), + ); + if (importMap.imports) importMap.imports = mapEntries(importMap.imports); + if (importMap.scopes) + importMap.scopes = Object.fromEntries( + Object.entries(importMap.scopes).map(([key, value]) => [ + map(key), + mapEntries(value as Record), + ]), + ); + changed = JSON.stringify(importMap).replace(/ { + const secret = `nsk_${"a".repeat(32)}`; + let received = ""; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + received = new URL(request.url).searchParams.get("setup_key") ?? ""; + return new Response('printf "%s" "$SUBSHELL_NODE_NAME"'); + }, + }); + try { + const command = renderSshSetupCommand( + `http://127.0.0.1:${server.port}/install.sh?setup_key=${secret}`, + "host's name", + ); + const child = Bun.spawn(["bash", "-s"], { + stdin: new TextEncoder().encode(`${command}\n`), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toBe("host's name"); + expect(received).toBe(secret); + } finally { + server.stop(true); + } +}); diff --git a/apps/server/api/src/modules/ssh/__tests__/ssh-setup-here.service.test.ts b/apps/server/api/src/modules/ssh/__tests__/ssh-setup-here.service.test.ts index 3fad428a0..11e5cf3d0 100644 --- a/apps/server/api/src/modules/ssh/__tests__/ssh-setup-here.service.test.ts +++ b/apps/server/api/src/modules/ssh/__tests__/ssh-setup-here.service.test.ts @@ -232,8 +232,8 @@ function attachPair(impl: { }) : null; const b = attachScriptedNode(NODE_B, { - ssh_exec: async (cmd) => { - if (cmd.type !== "ssh_exec") throw new Error("wrong arm"); + ssh_exec_stdin: async (cmd) => { + if (cmd.type !== "ssh_exec_stdin") throw new Error("wrong arm"); seen = cmd; await impl.onKick?.(cmd); return { started: true, execId: cmd.execId }; @@ -452,7 +452,7 @@ describe("setupHere relay mode", () => { const row = await new SubshellsRepository(db).findById(paneId); expect(row?.alive).toBe(1); expect(row?.status).toBe("running"); - expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec_stdin", "ssh_exec_status"]); // The relay was opened once and closed with the named child-exit word. const exec = pair.kick(); if (!exec) throw new Error("the act never kicked an exec"); @@ -753,7 +753,7 @@ describe("setupHere direct mode (B's own keys, no relay)", () => { expect(audits[0]?.metadataJson).toContain('"cause":"egress"'); expect(audits[0]?.metadataJson).not.toContain("install-exit"); expect((await keys.listByUser(owner)).length).toBe(keysBefore); - expect(pair.b.cmdTypes()).toEqual(["ssh_exec", "ssh_exec_status"]); + expect(pair.b.cmdTypes()).toEqual(["ssh_exec_stdin", "ssh_exec_status"]); } finally { await clearUpgradeRows(); pair.b.detach(); diff --git a/apps/server/api/src/modules/ssh/ssh-setup-command.ts b/apps/server/api/src/modules/ssh/ssh-setup-command.ts new file mode 100644 index 000000000..dcda8118e --- /dev/null +++ b/apps/server/api/src/modules/ssh/ssh-setup-command.ts @@ -0,0 +1,7 @@ +import { shellQuote } from "@internal/pane-runtime"; + +/** The executor sends this on stdin to bash; curl receives its URL the same way. */ +export function renderSshSetupCommand(installUrl: string, nodeName: string): string { + const curlUrl = `url = ${JSON.stringify(installUrl)}`; + return `set -o pipefail; printf '%s\\n' ${shellQuote(curlUrl)} | curl -fsSL --config - | SUBSHELL_NODE_NAME=${shellQuote(nodeName)} bash`; +} diff --git a/apps/server/api/src/modules/ssh/ssh-setup-here.service.ts b/apps/server/api/src/modules/ssh/ssh-setup-here.service.ts index a4c48e104..518d3b44f 100644 --- a/apps/server/api/src/modules/ssh/ssh-setup-here.service.ts +++ b/apps/server/api/src/modules/ssh/ssh-setup-here.service.ts @@ -1,5 +1,5 @@ import { BackendErrorCodes } from "@internal/backend-errors"; -import { buildSshKnownHostsPath, shellQuote } from "@internal/pane-runtime"; +import { buildSshKnownHostsPath } from "@internal/pane-runtime"; import { isSshFingerprints, parseSshConnectionSnapshot, @@ -25,6 +25,7 @@ import { import { prepareRelayLeg } from "@/modules/ssh/ssh-relay-launch.service.js"; import type { SshSetupStage } from "@/modules/ssh/ssh-setup-progress.js"; import { logger } from "@/shared/logger.js"; +import { renderSshSetupCommand } from "./ssh-setup-command.js"; /** * The "Set up Subshell here" act (spec 2026-10-08 §7, Task 14): turn the @@ -340,16 +341,11 @@ export async function setupHere(args: { // host-key PROMPT - auth failure or pin mismatch is a fast refusal, an // interactive prompt would sit out the deadline on a terminal no one holds. const presetFlags = ["-o", "BatchMode=yes", ...composed.presetFlags]; - // The installer one-liner, exactly the copy-paste act §7 describes, with - // the scripted name (the pane's destination host) because the exec has no - // terminal to answer `setup`'s naming question. shellQuote every token; - // `bash -c` so pipefail exists and a failed fetch is not hidden by the - // empty-stdin pipe. + // The executor sends this script on stdin to `bash -s`. Keep the URL in + // curl's stdin config too: neither the ssh child, the destination shell, + // nor curl may expose the one-use setup key in process arguments. const installUrl = `${APP_BASE_URL}/install.sh?setup_key=${keyRow.key}`; - const nodeName = snapshot.host; - const remoteCommand = `bash -c ${shellQuote( - `set -o pipefail; curl -fsSL ${shellQuote(installUrl)} | SUBSHELL_NODE_NAME=${shellQuote(nodeName)} bash`, - )}`; + const remoteCommand = renderSshSetupCommand(installUrl, snapshot.host); /** Enrollment is durable even if installation or the connecting link fails afterward. */ const enrolledResult = async (cause: string): Promise | null> => { diff --git a/apps/server/api/src/modules/subshells/subshell-manager.service.ts b/apps/server/api/src/modules/subshells/subshell-manager.service.ts index c400ffbb9..b903f8fc0 100644 --- a/apps/server/api/src/modules/subshells/subshell-manager.service.ts +++ b/apps/server/api/src/modules/subshells/subshell-manager.service.ts @@ -472,10 +472,16 @@ export class SubshellManagerService { 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. + * §6): forwarded verbatim to the launcher, which ships it on the frame. + * BOTH launchers act on it. A remote node's agent allocates through its own + * display registry and refuses by name (`preview-display-unsupported`, + * `preview-display-provider-disabled`, `preview-node-off`), and + * `LocalLauncher` allocates the local display before spawning, taking the + * environment from it (`local-launcher.ts` calls `openFor(plan.id, + * plan.screen)` and then `launchEnv`), so nothing here may treat the field + * as advisory for the control-plane host. 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 }> { diff --git a/apps/server/api/src/modules/subshells/subshells.service.ts b/apps/server/api/src/modules/subshells/subshells.service.ts index ebbd79b17..9feb7e80a 100644 --- a/apps/server/api/src/modules/subshells/subshells.service.ts +++ b/apps/server/api/src/modules/subshells/subshells.service.ts @@ -58,6 +58,7 @@ import { type SshLaunchPlumbing, SubshellManagerService, } from "@/modules/subshells/subshell-manager.service.js"; +import { invalidateSharedTerminalAccess } from "@/modules/subshells/terminal-access.js"; import { extendSubshellToken, subshellTokenTtlSeconds } from "@/modules/subshells/tokens.js"; import { HttpError } from "@/shared/errors.js"; import { logger } from "@/shared/logger.js"; @@ -684,7 +685,7 @@ export class SubshellsService extends BaseService { 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.", + "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings, or launch without a virtual display. (preview-node-off)", 400, ); } @@ -1305,6 +1306,9 @@ export class SubshellsService extends BaseService { if (unknown) throw new HttpError(400, "Cannot share with an unknown user"); } await this.repos.subshellShares.replaceForSubshell(id, entries, viewerId); + // Existing sockets cache their input grant. Cut them, including pending + // attaches, before answering; a client notification isn't authorization. + invalidateSharedTerminalAccess(id, row.userId); // The BEFORE access rides the event: recipients computed after the write // reach everyone EXCEPT whoever just lost the row, so this is the only // thing that can tell a revoked grantee (spec §4.2). Deriving which topics diff --git a/apps/server/api/src/modules/subshells/terminal-access.ts b/apps/server/api/src/modules/subshells/terminal-access.ts new file mode 100644 index 000000000..1b9ea52fc --- /dev/null +++ b/apps/server/api/src/modules/subshells/terminal-access.ts @@ -0,0 +1,30 @@ +/** Live terminal access leases, including connections still resolving their identity. */ +interface TerminalAccess { + paneId: string; + userId: () => string | undefined; + revoke: () => void; +} +const terminals = new Set(); + +/** Register before authentication awaits; release on refusal, failure or disconnect. */ +export function trackTerminalAccess(paneId: string, userId: TerminalAccess["userId"], revoke: () => void): () => void { + const entry = { paneId, userId, revoke }; + terminals.add(entry); + return () => { + terminals.delete(entry); + }; +} + +/** Grantees reconnect through the current share gate; the owner's access is unchanged. */ +export function invalidateSharedTerminalAccess(paneId: string, ownerId: string): void { + for (const entry of [...terminals]) { + if (entry.paneId !== paneId || entry.userId() === ownerId) continue; + terminals.delete(entry); + entry.revoke(); + } +} + +/** @internal Test isolation for transports that leave fake sockets registered. */ +export function resetTerminalAccessForTests(): void { + terminals.clear(); +} diff --git a/apps/server/api/src/testing/api/auth-tables.ts b/apps/server/api/src/testing/api/auth-tables.ts index 6a30920d3..9ecc5088c 100644 --- a/apps/server/api/src/testing/api/auth-tables.ts +++ b/apps/server/api/src/testing/api/auth-tables.ts @@ -55,10 +55,10 @@ import { prepareLocalPlugins } from "@/modules/plugins/store.js"; * still clean up their own fixture *rows*. */ export async function setupAuthTables(): Promise { - // Same order as `apps/server/api/src/index.ts` at boot. There are no - // cross-table foreign keys between the app tables and better-auth's, so the - // order is immaterial today — but a pristine-database bug is exactly what - // this helper exists to prevent, so the suite exercises production's order + // Same order as `apps/server/api/src/index.ts` at boot. App tables can + // reference better-auth tables that are created afterward; finish both + // migration runners before using cross-table cascades. This helper + // exercises production's order // rather than a second one nothing else uses. await runMigrations(); await runAuthMigrations(); diff --git a/apps/server/desktop/AGENTS.md b/apps/server/desktop/AGENTS.md index 18b0cf4ff..0414afa83 100644 --- a/apps/server/desktop/AGENTS.md +++ b/apps/server/desktop/AGENTS.md @@ -78,7 +78,7 @@ machine, and its Reset door opens a modal dialog inert to its own dismissal while the chain runs. First run is zero-touch (the page FIRES the setup chain), and recovery is ONE screen whose title IS the diagnosis. The standing screens (**Update**, **Reset**, **How Your Server Runs**, **What macOS Will -Ask**, **Server Addresses**) never join `screensFor`'s list: they arrive by +Ask**, **Server Addresses**, **Virtual Displays**) never join `screensFor`'s list: they arrive by REQUEST as a member of the closed `reset::Screen` enum, routed off `REQUESTED_SCREENS` via `screenForRequest`; adding a screen means the Rust enum and that list, and there is no third place to forget. ONE `update` screen @@ -641,11 +641,24 @@ apps/server/desktop/docs/native-chrome.md first.** ## macOS display helper -The native application menu's **Virtual Display Helper…** opens a shared -standalone AppKit settings panel. It is not part of first run and has no -Tauri IPC command. Both desktop apps bundle the same signed helper identity; -`desktop-core::display_helper` installs or reuses one per-user copy only on a -menu selection. The helper owns consent, OS permission requests, and login +The assistant's **Virtual Displays** rail section and the native menu's +**Virtual Display Helper…** open the same shared standalone AppKit settings +panel. The section is the door a person actually finds (added 2026-10-26, after +the menu bar proved undiscoverable), and it is the one that carries the +enablement steps: the menu item opened a panel and said nothing about the two +macOS permissions it then asks for. It is the rail's only platform FACT, dropped +wherever there is no helper (`railFor`), and it is not part of first run. The +panel itself stays the helper's: the command this section adds +(`desktop_open_display_helper`, no argument, assistant-only) opens it and reports +nothing back, because enabling and granting happen in another program's windows +and there is no status here to poll; a refusal is Rust's to say, in its native +dialog. The paragraph's guide link rides the `desktop_open_web` command the whole +window already has, naming the closed-set `display-guide` target so the docs URL +stays in Rust rather than the page. `reset::Screen::Display` is in the closed screen enum so the rail holds +one kind of section rather than two, and it grants no reach the command does not +already have. Both desktop apps bundle the same signed helper identity; +`desktop-core::display_helper` installs or reuses one per-user copy only when a +door is taken. The helper owns consent, OS permission requests, and login registration. Read [the helper architecture](../../../docs/macos-display-helper.md) before changing bundling, activation, or local control. User instructions live -in [node management](../../docs/content/docs/nodes/manage.mdx#macos-display-helper). +in [macOS virtual displays](../../docs/content/docs/previews/macos.mdx). diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json index eb53fc6bf..dff2e2f35 100644 --- a/apps/server/desktop/package.json +++ b/apps/server/desktop/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "21.0.4", + "@internal/helper-panel": "workspace:*", "@internal/tsconfig": "workspace:*", "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "2.11.4", diff --git a/apps/server/desktop/src-tauri/capabilities/wizard.json b/apps/server/desktop/src-tauri/capabilities/wizard.json index 70e389dbf..4effb6c5c 100644 --- a/apps/server/desktop/src-tauri/capabilities/wizard.json +++ b/apps/server/desktop/src-tauri/capabilities/wizard.json @@ -32,6 +32,7 @@ "allow-desktop-request-notifications", "allow-desktop-request-photos", "allow-desktop-open-system-settings", + "allow-desktop-open-display-helper", "allow-desktop-check-app-update", "allow-desktop-install-app-update", "dialog:allow-save", diff --git a/apps/server/desktop/src-tauri/permissions/desktop.toml b/apps/server/desktop/src-tauri/permissions/desktop.toml index 90582c299..e4ba4fab1 100644 --- a/apps/server/desktop/src-tauri/permissions/desktop.toml +++ b/apps/server/desktop/src-tauri/permissions/desktop.toml @@ -215,6 +215,11 @@ identifier = "allow-desktop-open-system-settings" description = "Open ONE of three System Settings panes: Notifications, Files and Folders, Photos. A closed enum, never a URL, the same shape desktop_open_web keeps. Assistant only: a page able to pop a system pane on its own is a nuisance an XSS could pull, and every route here already goes through raising the assistant." commands.allow = ["desktop_open_system_settings"] +[[permission]] +identifier = "allow-desktop-open-display-helper" +description = "Open the macOS display helper's own settings panel, which is a separate signed program's window and one of its two consent doors: the panel's Enable/Disable button and the helper's own status-menu toggle write consent, and neither is this app. The command opens a window and nothing else. Grants no permission and changes nothing on this machine: macOS asks for screen recording and accessibility itself, and the answer goes to the helper, not to this app. Takes no argument. Off macOS the command refuses, and the Virtual Displays section never renders there." +commands.allow = ["desktop_open_display_helper"] + [[permission]] identifier = "allow-desktop-backup" description = "Bundled assistant backup and restore operation." diff --git a/apps/server/desktop/src-tauri/src/control.rs b/apps/server/desktop/src-tauri/src/control.rs index b1e75a16a..1cead104e 100644 --- a/apps/server/desktop/src-tauri/src/control.rs +++ b/apps/server/desktop/src-tauri/src/control.rs @@ -2950,6 +2950,10 @@ pub enum WebTarget { /// `web-targets.test.ts` now holds shut. #[serde(rename = "macports")] MacPorts, + /// The guide that walks through enabling the macOS display helper, offered + /// on the Virtual Displays screen (2026-10-26). Kebab-case spells it + /// `display-guide`, which is exactly what the page sends. + DisplayGuide, } /// Where the tmux screen sends someone who has no package manager at all. @@ -2957,9 +2961,15 @@ pub enum WebTarget { /// every other member of this set is one. const HOMEBREW_URL: &str = "https://brew.sh"; const MACPORTS_URL: &str = "https://www.macports.org/install.php"; +/// Where the Virtual Displays screen sends someone who wants the full +/// enablement walk-through, the two macOS permissions and the restart case +/// included. The dashboard preview controls link to the shared prerequisites +/// guide; this page covers setup and use on macOS. +const DISPLAY_GUIDE_URL: &str = "https://docs.subshell.sh/previews/macos"; /// Open one of a fixed set of pages in the system browser: the three About -/// links, and the two package managers the tmux screen names. +/// links, the two package managers the tmux screen names, and the display +/// helper's enablement guide. /// /// The console's CSP makes an ordinary `
` open inside the webview, /// which is the wrong window for a website — the same reason @@ -2972,6 +2982,7 @@ pub fn desktop_open_web(app: AppHandle, target: WebTarget) -> Result<(), String> WebTarget::Company => legal::COMPANY_URL, WebTarget::Homebrew => HOMEBREW_URL, WebTarget::MacPorts => MACPORTS_URL, + WebTarget::DisplayGuide => DISPLAY_GUIDE_URL, }; app.opener() .open_url(url, None::<&str>) @@ -3290,6 +3301,68 @@ pub fn desktop_open_system_settings(app: AppHandle, pane: SettingsPane) -> Resul .map_err(|e| format!("could not open System Settings: {e}")) } +/// Open the macOS display helper's own settings panel, the same panel the +/// application menu's **Virtual Display Helper…** item opens. +/// +/// The command lives here, ungated, while `display_helper` is a macOS-only +/// module: `generate_handler!` takes a plain list, so a command that exists on +/// one platform only has to exist on all of them or the Linux build stops +/// compiling. The refusal is the honest answer there rather than a stub, and +/// the Virtual Displays section never renders off macOS, so nothing sends this. +/// +/// Nothing comes back on success: the panel belongs to a separate signed +/// program, and both enabling the helper and granting macOS its two permissions +/// are answered there rather than here. `open_settings` reports its own +/// failures in a native dialog, so the page has no message left to render. +#[tauri::command(async)] +pub fn desktop_open_display_helper(app: AppHandle) -> Result<(), String> { + if let Some(refusal) = display_helper_refusal() { + let _ = app; + return Err(refusal); + } + #[cfg(target_os = "macos")] + crate::display_helper::open_settings(&app); + Ok(()) +} + +/// Why this machine cannot open the helper, or `None` where it can. +/// +/// The words sit in their own function so the arm that answers off macOS is +/// testable: the command needs an `AppHandle`, and the suite runs with no +/// running app. Nothing on this platform's page sends the command (the Virtual +/// Displays section is dropped off macOS by the rail), so an older page or a +/// hand-written invoke is all that gets here, and a success would report a +/// panel that does not exist. +fn display_helper_refusal() -> Option { + #[cfg(target_os = "macos")] + { + None + } + #[cfg(not(target_os = "macos"))] + { + Some("Virtual displays need the macOS display helper, and this is not macOS.".to_string()) + } +} + +#[cfg(test)] +mod display_helper_tests { + use super::display_helper_refusal; + + #[test] + fn a_machine_with_no_helper_refuses_instead_of_reporting_a_panel_it_opened() { + // Each host tests its own arm; the other one is what CI's macOS lane + // and the Linux builder image cover. What must not drift is the shape: + // a Mac answers None and opens the panel, anywhere else answers words. + #[cfg(target_os = "macos")] + assert!(display_helper_refusal().is_none(), "macOS refused its own helper"); + #[cfg(not(target_os = "macos"))] + { + let reason = display_helper_refusal().expect("a machine with no helper answered nothing"); + assert!(reason.contains("macOS"), "{reason}"); + } + } +} + /// Raise the assistant, optionally at a named screen (`reset` | `update`). /// /// Called from the SPA's pill (no screen: the recovery screen for whatever diff --git a/apps/server/desktop/src-tauri/src/display_helper.rs b/apps/server/desktop/src-tauri/src/display_helper.rs index 3055d7da4..894ab1930 100644 --- a/apps/server/desktop/src-tauri/src/display_helper.rs +++ b/apps/server/desktop/src-tauri/src/display_helper.rs @@ -1,4 +1,13 @@ -//! This entry point is reachable only from the native application menu. +//! Open the bundled display helper's settings panel. +//! +//! Two doors reach it, and they are the same act: the native application menu's +//! **Virtual Display Helper…** item (`menu.rs`) and the assistant window's +//! Virtual Displays screen (`control::desktop_open_display_helper`). The second +//! exists because the first is easy to miss, and the control plane's node +//! settings point here by name. +//! +//! The panel itself belongs to the helper, a separate signed program, so +//! enabling it and granting macOS its permissions both happen outside this app. use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::DialogExt; diff --git a/apps/server/desktop/src-tauri/src/lib.rs b/apps/server/desktop/src-tauri/src/lib.rs index fe8327ec7..172e46c42 100644 --- a/apps/server/desktop/src-tauri/src/lib.rs +++ b/apps/server/desktop/src-tauri/src/lib.rs @@ -222,6 +222,7 @@ pub fn run() { control::desktop_request_notifications, control::desktop_request_photos, control::desktop_open_system_settings, + control::desktop_open_display_helper, control::desktop_check_app_update, control::desktop_install_app_update, control::desktop_app_update, diff --git a/apps/server/desktop/src-tauri/src/reset.rs b/apps/server/desktop/src-tauri/src/reset.rs index ea3915d6a..03d5fa5c3 100644 --- a/apps/server/desktop/src-tauri/src/reset.rs +++ b/apps/server/desktop/src-tauri/src/reset.rs @@ -97,6 +97,15 @@ pub enum Screen { Settings, Backup, Restore, + /// **Virtual Displays**: the macOS display helper's instructions and the one + /// press that opens its panel (2026-10-26). + /// + /// The rail is its only door today, and the word is here so the page's rail + /// can hold one kind of section rather than two. It asks for nothing, so a + /// deep link costs nothing: the screen opens a window in a separate signed + /// program and reports nothing back, which is why `desktop_open_display_helper` + /// stays the assistant's own command and this word grants no new reach. + Display, } impl Screen { @@ -111,11 +120,12 @@ impl Screen { Screen::Settings => "settings", Screen::Backup => "backup", Screen::Restore => "restore", + Screen::Display => "display", } } } -/// Parse the (untrusted, optional) `screen` argument. Six accepted words; +/// Parse the (untrusted, optional) `screen` argument. Nine accepted words; /// anything else — including `home` — is the probe's own answer. pub fn parse_screen(raw: Option) -> Screen { match raw.as_deref() { @@ -127,6 +137,7 @@ pub fn parse_screen(raw: Option) -> Screen { Some("settings") => Screen::Settings, Some("backup") => Screen::Backup, Some("restore") => Screen::Restore, + Some("display") => Screen::Display, _ => Screen::Home, } } @@ -1038,6 +1049,7 @@ mod tests { Screen::Settings, Screen::Backup, Screen::Restore, + Screen::Display, ] { assert_eq!(parse_screen(Some(screen.as_str().to_string())), screen); } diff --git a/apps/server/desktop/ui/src/__tests__/host.test.tsx b/apps/server/desktop/ui/src/__tests__/host.test.tsx index a08a34ae7..a4229f45d 100644 --- a/apps/server/desktop/ui/src/__tests__/host.test.tsx +++ b/apps/server/desktop/ui/src/__tests__/host.test.tsx @@ -670,3 +670,44 @@ describe("the rail", () => { expect(screen.getByRole("button", { name: "Status" }).getAttribute("aria-current")).toBe("true"); }); }); + +/** + * The rail's Virtual Displays section (2026-10-26), which exists because the + * only door was in the macOS menu bar and the control plane's node settings + * point at this subject by name. What the section has to get right is the + * platform: the display helper is BetterDisplay's, so a Linux host must not be + * offered a section that opens nothing. + */ +describe("the rail's Virtual Displays section", () => { + it("opens the helper's instructions on a Mac, and the panel on its press", async () => { + fake = installFakeIpc({ + probe: makeProbe({ onboarded: true, next: "start", platform: "darwin" }), + handlers: { desktop_pending_screen: () => null, desktop_open_display_helper: () => undefined }, + }); + render(); + await waitFor(() => expect(routeOf()).toBe("status")); + fireEvent.click(screen.getByRole("button", { name: "Virtual Displays" })); + expect(routeOf()).toBe("display"); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Virtual Displays"); + // The instructions are the point of the screen: the menu item opened a + // panel and said nothing about the two macOS permissions it then asks for. + expect(screen.getByText(/Grant Screen Recording and Accessibility/)).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Open Display Helper settings" })); + await waitFor(() => expect(fake?.callsTo("desktop_open_display_helper")).toHaveLength(1)); + // The section holds the highlight while it stands, like every other one. + expect(screen.getByRole("button", { name: "Virtual Displays" }).getAttribute("aria-current")).toBe("true"); + }); + + it("is not on the rail where there is no display helper", async () => { + fake = installFakeIpc({ + probe: makeProbe({ onboarded: true, next: "start", platform: "linux" }), + handlers: { desktop_pending_screen: () => null }, + }); + render(); + await waitFor(() => expect(routeOf()).toBe("status")); + // The rest of the rail is the same machine's rail; only this one section + // is platform fact. + expect(screen.getByRole("button", { name: "Addresses" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Virtual Displays" })).toBeNull(); + }); +}); diff --git a/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts b/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts index 187d3f120..724e0e882 100644 --- a/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts +++ b/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts @@ -408,21 +408,26 @@ describe("prereqState", () => { */ describe("screens entered by request", () => { it("names all of them, and none is in the list of a machine that can ask", () => { - // Five. "How Your Server Runs" joined them in 2026-09-12, "What macOS Will - // Ask" on 2026-09-14 and "Server Addresses" on 2026-09-18 — and they are - // different kinds of member, which is the thing this test has to say out - // loud. + // Eight now. "How Your Server Runs" joined them in 2026-09-12, "What macOS + // Will Ask" on 2026-09-14, "Server Addresses" on 2026-09-18 and Virtual + // Displays on 2026-10-26, and they are different kinds of member, which is + // the thing this test has to say out loud. // // There were five once before, between 2026-09-15 and 2026-09-18, for a // different reason: `update` (the server this app wraps) and `app-update` // (the app itself) were two screens. They are ONE act now, under `update`, // because the app ships the server. `settings` is not that member coming // back — it is the way out of a base URL that signed this app's own window - // out (spec 2026-09-18 § 14), and it is the only one of the five no - // dashboard names. The sorted comparison is what makes the retired id - // returning — or any of these five being dropped — loud. + // out (spec 2026-09-18 § 14), and it is the only member no dashboard names. + // The sorted comparison is what makes a retired id returning, or any of the + // eight being dropped, loud rather than quiet. + // `display` is the newest (2026-10-26), and the only member no door outside + // this window names yet: the rail's Virtual Displays section asks for it, and + // Rust's enum carries the word so a rail section is one kind of thing here + // rather than two. expect([...REQUESTED_SCREENS].sort()).toEqual([ "backup", + "display", "permissions", "reset", "restore", diff --git a/apps/server/desktop/ui/src/host.tsx b/apps/server/desktop/ui/src/host.tsx index 5a00b0c02..424dce993 100644 --- a/apps/server/desktop/ui/src/host.tsx +++ b/apps/server/desktop/ui/src/host.tsx @@ -82,6 +82,7 @@ import { } from "./lib/wizard-state"; import { useAssistantRunners } from "./runners"; import { AddressesScreen } from "./screens/addresses-screen"; +import { DisplayScreen } from "./screens/display-screen"; import { HandoffScreen } from "./screens/handoff-screen"; import { PermissionsScreen } from "./screens/permissions-screen"; import { ResetDialog, type ResetLog } from "./screens/reset-dialog"; @@ -986,7 +987,10 @@ export function Host(): React.JSX.Element { * screen — facts, the log tail, one button (operator ruling 2026-09-23: no * more bounce through the handoff for a deliberate select). */ - const railSections = railFor(r, probe?.onboarded ?? false); + // The platform is the probe's, never the build machine's: this window shows + // the machine it runs on today, and the Virtual Displays section is a + // platform fact (`railFor` takes it as a required argument for that reason). + const railSections = railFor(r, probe?.onboarded ?? false, probe?.platform === "darwin"); const rail = railSections === null ? undefined : ( ); break; + case "display": + // The rail's macOS-only section is the only door here, and the screen's + // one press opens a native panel, so it takes no probe and runs nothing. + content = ( + + ); + break; case "permissions": content = ( { expect(route(probe({ next: "unreachable", error: "connection refused" }), "update", IDLE)).toEqual({ kind: "update", }); + // Virtual Displays holds this too on the platform that has the helper. + expect(route(probe({ next: "unreachable", error: "connection refused" }), "display", IDLE)).toEqual({ + kind: "display", + }); + }); + + it("answers a Virtual Displays request with the machine it was asked of", () => { + // The one requested screen that is a platform FACT: its steps name a Mac's + // permissions and its button can only refuse elsewhere. The rail drops the + // section off macOS, and a deep link is held to the same rule — the request + // routes nothing and the host shows its own journey. + expect(route(probe({ next: "ready", platform: "linux" }), "display", IDLE)).toEqual({ kind: "handoff" }); }); it("holds the setup screen while the chain runs, ready probe or not", () => { @@ -195,8 +207,8 @@ describe("nextPollDelay", () => { describe("railFor", () => { it("answers null for every FTE family route, permissions and boot", () => { for (const kind of ["welcome", "tmux", "setup", "handoff", "permissions", "boot"] as const) { - expect(railFor({ kind }, true), kind).toBeNull(); - expect(railFor({ kind }, false), kind).toBeNull(); + expect(railFor({ kind }, true, true), kind).toBeNull(); + expect(railFor({ kind }, false, true), kind).toBeNull(); } }); @@ -212,6 +224,7 @@ describe("railFor", () => { "update", "supervision", "settings", + "display", "backup", "restore", "reset", @@ -222,19 +235,20 @@ describe("railFor", () => { // The old page allowed a requested screen over any machine; wave 2 keeps // the render but takes away the rail — the exclusion stays absolute. for (const kind of ["status", "update", "supervision", "addresses"] as const) { - expect(railFor({ kind }, false), kind).toBeNull(); + expect(railFor({ kind }, false, true), kind).toBeNull(); } }); it("answers the five standing sections for a standing route on an onboarded machine", () => { // The fifth is Reset (operator ruling 2026-09-22): the DOOR in the rail, // marked destructive. - const sections = railFor({ kind: "status" }, true); + const sections = railFor({ kind: "status" }, true, true); expect(sections?.map((s) => s.id)).toEqual([ "status", "update", "supervision", "settings", + "display", "backup", "restore", "reset", @@ -244,17 +258,19 @@ describe("railFor", () => { "Update", "Service", "Addresses", + "Virtual Displays", "Backup", "Restore", "Reset", ]); expect(sections?.find((s) => s.id === "reset")?.danger).toBe(true); - for (const kind of ["update", "supervision", "addresses", "backup", "restore"] as const) { - expect(railFor({ kind }, true)?.map((s) => s.id)).toEqual([ + for (const kind of ["update", "supervision", "addresses", "backup", "restore", "display"] as const) { + expect(railFor({ kind }, true, true)?.map((s) => s.id)).toEqual([ "status", "update", "supervision", "settings", + "display", "backup", "restore", "reset", @@ -262,6 +278,24 @@ describe("railFor", () => { } }); + it("takes Virtual Displays off the rail where there is no display helper, and nothing else", () => { + // The section is the rail's only platform fact, because the helper is + // BetterDisplay's and a Linux host has no panel for the button to open. + // Everything else on the rail is this machine's rail either way, which is + // what the comparison says rather than a length check. + expect(railFor({ kind: "display" }, true, false)?.map((s) => s.id)).toEqual([ + "status", + "update", + "supervision", + "settings", + "backup", + "restore", + "reset", + ]); + // The highlight resolution is platform-free: the route answers for itself. + expect(railActive({ kind: "display" })).toBe("display"); + }); + it("marks the active section by the route, through railActive", () => { expect(railActive({ kind: "status" })).toBe("status"); expect(railActive({ kind: "update" })).toBe("update"); diff --git a/apps/server/desktop/ui/src/lib/ipc.ts b/apps/server/desktop/ui/src/lib/ipc.ts index 1e3b91ff9..59078d6dc 100644 --- a/apps/server/desktop/ui/src/lib/ipc.ts +++ b/apps/server/desktop/ui/src/lib/ipc.ts @@ -268,7 +268,7 @@ export type OpenTarget = "config-env" | "server-dir" | "service-definition" | "l * last two are the package managers the tmux screen names when a Mac has * neither. */ -export type WebTarget = "website" | "license" | "company" | "homebrew" | "macports"; +export type WebTarget = "website" | "license" | "company" | "homebrew" | "macports" | "display-guide"; /** * `About`. Who made this, under what terms, and where to read more. @@ -513,6 +513,19 @@ export const requestPhotos = (): Promise => invoke("desk export const openSystemSettings = (pane: SettingsPane): Promise => invoke("desktop_open_system_settings", { pane }); +/** + * Open the macOS display helper's own settings panel — the same panel the + * application menu's **Virtual Display Helper…** item opens. + * + * Nothing comes back, because nothing about this machine has changed yet. The + * panel is a separate signed program's window; enabling the helper and granting + * macOS screen recording and accessibility both happen there, and their answers + * belong to that program rather than to this app. A rejection means the panel + * could not be opened at all, and Rust puts that in a native dialog, so the + * screen has no message left to render. + */ +export const openDisplayHelper = (): Promise => invoke("desktop_open_display_helper"); + /** `desktop_set_supervision`'s answer: the chain's words, plus where it ended. */ export interface SupervisionResult extends ActionResult { /** The mode this machine is in NOW, re-read after the chain */ diff --git a/apps/server/desktop/ui/src/lib/server-state.ts b/apps/server/desktop/ui/src/lib/server-state.ts index 702b0b2e1..e803e4e6e 100644 --- a/apps/server/desktop/ui/src/lib/server-state.ts +++ b/apps/server/desktop/ui/src/lib/server-state.ts @@ -49,6 +49,7 @@ export type Route = | { kind: "permissions" } | { kind: "backup" } | { kind: "restore" } + | { kind: "display" } | { kind: "boot" }; /** @@ -98,6 +99,12 @@ export function route( if (screen === "supervision") return { kind: "supervision" }; if (screen === "settings") return { kind: "addresses" }; if (screen === "permissions") return { kind: "permissions" }; + // Virtual Displays is the one requested screen that is a platform FACT: the + // helper exists on a Mac and nowhere else, which is why the rail drops the + // section off macOS. A deep link asking for it on a Linux host is answered + // by that host's own journey rather than by Mac-only steps beside a button + // that can only refuse. + if (screen === "display" && probe.platform === "darwin") return { kind: "display" }; } const resolved = resolveJourney(probe, screen); if (resolved === null) { @@ -179,6 +186,13 @@ export const RAIL_SECTIONS: RailSection[] = [ { id: "update", label: "Update" }, { id: "supervision", label: "Service" }, { id: "settings", label: "Addresses" }, + // Virtual Displays opens the macOS display helper's own panel, so it is the + // one section that is platform FACT rather than vocabulary: + // {@link RAIL_SECTIONS_OFF_MAC} is this list without it, and {@link railFor} + // picks by the probe's platform. It sits with the machine's settings and + // ahead of the backup/restore/reset tail, which stays last on every + // platform. + { id: "display", label: "Virtual Displays" }, // The fifth section is the DESTRUCTIVE one (operator ruling 2026-09-22, // live screenshot): the DOOR moves into the rail, styled in the destructive // token. Since the 2026-09-23 dialog ruling it is a DOOR and nothing more: @@ -191,6 +205,12 @@ export const RAIL_SECTIONS: RailSection[] = [ { id: "reset", label: "Reset", danger: true }, ]; +/** + * The rail on a platform with no display helper, computed once so the two + * lists are stable identities rather than a filter run per render. + */ +const RAIL_SECTIONS_OFF_MAC: RailSection[] = RAIL_SECTIONS.filter((s) => s.id !== "display"); + /** * Whether THIS route gets the rail, and which sections it shows. * @@ -208,9 +228,15 @@ export const RAIL_SECTIONS: RailSection[] = [ * The active section is not folded in here — a `RailSection` is `{id, label}` * by design, and the active state is the Rail primitive's prop — so * {@link railActive} answers it, keyed on the same route. + * + * `macos` comes from `probe.platform` and drops the Virtual Displays section + * everywhere else. It is a required argument rather than a default precisely + * because the probe can answer it: a defaulted platform is how a rail ends up + * drawn from the build machine's own OS rather than the machine on screen. */ -export function railFor(r: Route, onboarded: boolean): RailSection[] | null { +export function railFor(r: Route, onboarded: boolean, macos: boolean): RailSection[] | null { if (!onboarded) return null; + const sections = macos ? RAIL_SECTIONS : RAIL_SECTIONS_OFF_MAC; switch (r.kind) { case "status": case "update": @@ -218,7 +244,8 @@ export function railFor(r: Route, onboarded: boolean): RailSection[] | null { case "addresses": case "backup": case "restore": - return RAIL_SECTIONS; + case "display": + return sections; default: return null; } @@ -239,6 +266,8 @@ export function railActive(r: Route): string | null { return "backup"; case "restore": return "restore"; + case "display": + return "display"; default: return null; } diff --git a/apps/server/desktop/ui/src/lib/wizard-state.ts b/apps/server/desktop/ui/src/lib/wizard-state.ts index 5be4eaecd..00dc11719 100644 --- a/apps/server/desktop/ui/src/lib/wizard-state.ts +++ b/apps/server/desktop/ui/src/lib/wizard-state.ts @@ -69,7 +69,8 @@ export type ScreenId = | "supervision" | "settings" | "backup" - | "restore"; + | "restore" + | "display"; /** * The label on the way into the Reset screen, and the Reset screen's own @@ -158,6 +159,12 @@ export function prereqState(probe: Probe): PrereqState { * `desktop_open_assistant` grant it already holds. That would be a decision to * take on its merits rather than a hole; the screen changes nothing a signed-in * admin cannot change on the Networking page. + * + * `display` — **Virtual Displays** — is the rail's door onto the macOS display + * helper (2026-10-26). It is on this list for the same reason `settings` is: the + * rail selects a SCREEN, and a screen is reached through this list or not at + * all. Nothing about the machine implies wanting it, so it can never be earned + * from a probe, and the rail is the only door today. */ export const REQUESTED_SCREENS: readonly ScreenId[] = [ "update", @@ -167,6 +174,7 @@ export const REQUESTED_SCREENS: readonly ScreenId[] = [ "settings", "backup", "restore", + "display", ]; /** diff --git a/apps/server/desktop/ui/src/screens/__tests__/display-screen-parity.test.ts b/apps/server/desktop/ui/src/screens/__tests__/display-screen-parity.test.ts new file mode 100644 index 000000000..1dddad729 --- /dev/null +++ b/apps/server/desktop/ui/src/screens/__tests__/display-screen-parity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The two assistants each ship a Virtual Displays screen, and the server one + * says out loud that the client's steps and notes are "word-for-word this + * screen's" apart from which app is named. A promise held by a comment is a + * promise already drifting: the docs and the panel were rewritten twice on this + * branch while both copies of the steps sat where they were, and the sign-in + * sentence had already split from the code in both. This is the test that + * comment should have been, in the shape `tokens-match-spa.test.ts` uses. + * + * Compared as text, not rendered: the two files import from their own app's + * aliases, so neither can be mounted inside the other's build. + */ +const HERE = import.meta.dir; +const SERVER = join(HERE, "..", "display-screen.tsx"); +// Six levels up from this test dir is `apps/` (→ screens → src → ui → desktop → +// server → apps), then down into the other assistant. +const CLIENT = join(HERE, "../../../../../../client/desktop/ui/src/components/assistant/display-screen.tsx"); + +/** The quoted strings in `const STEPS: readonly string[] = [ … ];`, app name folded. */ +function stepsOf(source: string): string[] { + const block = /const STEPS: readonly string\[\] = \[([\s\S]*?)\n\];/.exec(source)?.[1]; + if (block === undefined) throw new Error("no STEPS array found"); + return [...block.matchAll(/"((?:[^"\\]|\\.)+)"/g)] + .map(([, line]) => line.replace(/Subshell (?:Client|Server)/g, "Subshell App").replace(/\\"/g, '"')) + .filter((line) => line.length > 0); +} + +describe("the two assistants' Virtual Displays screens", () => { + const server = readFileSync(SERVER, "utf8"); + const client = readFileSync(CLIENT, "utf8"); + // A path that stops existing would otherwise pass by comparing nothing. + expect(server.length).toBeGreaterThan(1000); + expect(client.length).toBeGreaterThan(1000); + + test("ask for the same three things in the same order", () => { + expect(stepsOf(client)).toEqual(stepsOf(server)); + // A vacuous comparison would be worse than none: three steps is the contract. + expect(stepsOf(server).length).toBe(3); + }); + + test("each names its own app, and only where the app belongs", () => { + expect(server).toContain("Subshell Server"); + expect(client).toContain("Subshell Client"); + expect(stepsOf(server).join(" ")).toContain("Subshell App"); + }); + + test("both name the helper's own menu bar icon, not the app's", () => { + // The note used to say "the same panel sits under Subshell Server (Client) + // in the macOS menu bar". Two wrongs in one sentence: the panel is opened + // from the helper's own status item, and a page inside Subshell Server + // telling you to look under Subshell Server reads as a different program. + // The sentence is app-independent, so it is compared whole. + const note = /The helper runs as[\s\S]*?BetterDisplay needs\./; + const serverNote = note.exec(server)?.[0]; + const clientNote = note.exec(client)?.[0]; + expect(serverNote, "the server screen carries the helper-menu note").toBeDefined(); + expect(clientNote?.replace(/\s+/g, " ")).toBe(serverNote?.replace(/\s+/g, " ")); + expect(serverNote).toContain("Subshell Display Helper"); + for (const source of [server, client]) { + expect(source).not.toMatch( + /sits under Subshell (?:Client|Server)<\/span> in the macOS menu bar/, + ); + } + }); + + test("both explain the same initial consent and warn about the same control", () => { + for (const pattern of [ + /The helper starts disabled[\s\S]*?macOS permissions\./, + /Requires macOS 14[\s\S]*?people you trust\./, + ]) { + const expected = pattern.exec(server)?.[0]; + expect(expected).toBeDefined(); + expect(pattern.exec(client)?.[0]?.replace(/\s+/g, " ")).toBe(expected?.replace(/\s+/g, " ")); + } + }); + test("the host and client introduce a virtual display with the same sentence", () => { + const host = readFileSync(join(HERE, "../../host.tsx"), "utf8"); + const subtitles = readFileSync( + join(HERE, "../../../../../../client/desktop/ui/src/components/assistant/subtitles.ts"), + "utf8", + ); + const sentence = /A virtual display gives a subshell[^"\n]+/; + expect(sentence.exec(host)?.[0]).toBeDefined(); + expect(sentence.exec(subtitles)?.[0]).toBe(sentence.exec(host)?.[0]); + }); + + test("neither promises a sign-in registration that enabling no longer does", () => { + // The defect this pins is the one the split introduced: a step that told + // people Enable helper also adds the login item, after the button stopped + // doing it. The panel owns that choice now, on its own row. + for (const step of stepsOf(server)) { + expect(step).not.toMatch(/also starts the helper when you sign in/); + expect(step).not.toMatch(/registers? .*login item/i); + } + expect(server).toContain("Launch at sign-in"); + expect(client).toContain("Launch at sign-in"); + }); +}); diff --git a/apps/server/desktop/ui/src/screens/__tests__/display-screen.test.tsx b/apps/server/desktop/ui/src/screens/__tests__/display-screen.test.tsx new file mode 100644 index 000000000..e78922118 --- /dev/null +++ b/apps/server/desktop/ui/src/screens/__tests__/display-screen.test.tsx @@ -0,0 +1,95 @@ +/** + * **Virtual Displays**, as component tests: the leave button's one condition, + * the single press that asks Rust for the helper's panel, and the steps whose + * order and naming a reader follows inside System Settings. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { installFakeIpc, makeProbe } from "../../__tests__/harness"; +import { DisplayScreen } from "../display-screen"; + +afterEach(cleanup); + +const STRINGS = { + title: "Virtual Displays", + subtitle: "A virtual display gives a subshell a screen for graphical apps without needing another physical monitor.", + problem: "", +}; + +function renderDisplay(over: { rail?: Parameters[0]["rail"]; openFails?: boolean } = {}) { + const fake = installFakeIpc({ + probe: makeProbe({ next: "start", platform: "darwin" }), + handlers: { + desktop_open_display_helper: over.openFails + ? () => Promise.reject(new Error("the helper is not installed")) + : () => undefined, + }, + }); + render( + {}} + rail={over.rail} + />, + ); + return fake; +} + +describe("the leave button", () => { + it("renders only where the rail is not", () => { + // Rail-less: the leave is the only way out, same ruling as every standing + // screen — a rail select discards exactly what the leave does. + renderDisplay(); + expect(screen.queryByRole("button", { name: "Back" })).not.toBeNull(); + cleanup(); + renderDisplay({ rail:
}); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + }); +}); + +describe("the press", () => { + it("asks Rust for the helper's panel, once", () => { + const fake = renderDisplay({ rail:
}); + fireEvent.click(screen.getByRole("button", { name: "Open Display Helper settings" })); + // One ask per press: the helper's panel is a window in another program, and + // a second open is that window fighting itself. + expect(fake.callsTo("desktop_open_display_helper")).toHaveLength(1); + fake.restore(); + }); + + it("keeps the instructions on screen when the open is refused", () => { + // The refusal is Rust's to say, in its own dialog. Nothing here may wipe + // the steps, because a reader still has to know what to do next. + const fake = renderDisplay({ rail:
, openFails: true }); + fireEvent.click(screen.getByRole("button", { name: "Open Display Helper settings" })); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + fake.restore(); + }); +}); + +describe("the guide link", () => { + it("names the display guide, so the system browser opens the enablement page", () => { + // `wire-names.test.ts` holds the TS union and Rust's enum equal; only this + // press carries the fact that THIS screen sends that member, and a drifted + // string is a runtime refusal no type check sees. + const fake = renderDisplay({ rail:
}); + fireEvent.click(screen.getByRole("button", { name: "How to enable the display helper" })); + expect(fake.callsTo("desktop_open_web")).toEqual([{ target: "display-guide" }]); + fake.restore(); + }); +}); + +describe("the steps", () => { + it("put Enable first and name the helper the way System Settings lists it", () => { + // A reader follows this list into another program's windows and into + // System Settings' permission table. A step out of order sends them to a + // toggle that is not there yet, and a wrong name sends them to the wrong + // row: the grant goes to the helper, never to Subshell Server. + renderDisplay({ rail:
}); + const steps = screen.getAllByRole("listitem").map((li) => li.textContent ?? ""); + expect(steps[0]).toContain("Enable helper"); + expect(steps[1]).toContain("Subshell Display Helper"); + expect(steps[2]).toContain("restart"); + }); +}); diff --git a/apps/server/desktop/ui/src/screens/display-screen.tsx b/apps/server/desktop/ui/src/screens/display-screen.tsx new file mode 100644 index 000000000..47bbca30d --- /dev/null +++ b/apps/server/desktop/ui/src/screens/display-screen.tsx @@ -0,0 +1,128 @@ +/** + * **Virtual Displays** — what this Mac needs before a subshell can run a + * graphical app, and the one press that opens the panel where that happens. + * + * The screen exists because the door it replaces was only in the macOS menu + * bar. Three places to look for one item is too many: the control plane's node + * settings name this subject, the app menu held the only door, and a person + * reading those settings looks in the window first. The rail puts the door + * where they were already looking, and the screen says what the panel then asks + * for, which the menu item never could. + * + * Subshell Client's `display-screen.tsx` is the same page with its own app + * named in the menu-bar line, and its steps and notes are word-for-word this + * screen's: one subject, two apps that can sit on one machine, and no reason + * for a reader to learn two names for the same permission. + * + * **Drawn only on a Mac.** The rail section is dropped off macOS + * (`server-state.ts`'s `railFor`), because the helper is BetterDisplay's and a + * Linux host has nothing for the panel to be. + * + * **The press opens a window and reports nothing back, on purpose.** Enabling + * the helper and granting macOS its two permissions all happen in another + * program's windows, so there is no status to poll here; a spinner waiting on a + * panel that is already open would misreport what the press is still doing. A + * refusal to open is rare, and `desktop_open_display_helper` answers it in a + * native dialog where the person is looking. + */ +import { type AssistantStrings, Frame } from "@internal/assistant"; +import type { ReactElement } from "react"; +import { Button } from "@/components/ui/button"; +import type { Probe } from "../lib/ipc"; +import * as ipc from "../lib/ipc"; +import { leaveLabel } from "../lib/wizard-state"; + +/** The three things the panel and macOS ask for, in the order they happen. */ +const STEPS: readonly string[] = [ + "Choose Enable helper. It keeps running after you close Subshell Server. Starting it at sign-in is a separate choice under Launch at sign-in and may need macOS approval.", + "Grant Screen Recording and Accessibility to Subshell Display Helper in System Settings. Screen Recording lets it capture the virtual screen, and Accessibility lets it send keyboard and pointer input.", + "If macOS asks you to restart the helper, choose Quit helper from its menu bar icon, then reopen Display Helper settings here. You won't need to enable it again.", +]; + +export function DisplayScreen(props: { + /** The rail node the host computed for this route, or undefined when the route is full-window. */ + rail?: ReactElement; + strings: AssistantStrings; + entranceKey?: number; + probe: Probe; + onClose: () => void; +}): ReactElement { + const { probe } = props; + + return ( + + {leaveLabel(probe, probe.onboarded)} + + ) + } + barRight={ + + } + > +

+ The helper starts disabled when this app installs it. To let anyone view or control a virtual display, enable it + in its settings and grant both macOS permissions. +

+
    + {STEPS.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+

+ Requires macOS 14 or later. Virtual displays use your current Mac login and share its keyboard focus and + pointer. Viewers may see other windows on the display and can control this Mac, so enable the helper only for + people you trust. +

+

+ The helper runs as Subshell Display Helper and has its own display icon in + the macOS menu bar. Use that menu to open its settings or disable the helper at any time. explains + the setup, including what BetterDisplay needs. +

+ + ); +} + +/** + * The one link this screen offers, carrying the word the command knows. An + * ordinary `
` would open inside the webview under the bundle's CSP, so the + * press names a member of Rust's closed set and the SYSTEM browser opens it. + */ +function GuideLink(): ReactElement { + return ( + + ); +} diff --git a/apps/server/desktop/ui/src/styles.css b/apps/server/desktop/ui/src/styles.css index ccdcf8575..8c1d77c95 100644 --- a/apps/server/desktop/ui/src/styles.css +++ b/apps/server/desktop/ui/src/styles.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "../../../../../packages/assistant/tokens.css"; /* The shared Frame (`@internal/assistant`) renders with Tailwind utilities but lives OUTSIDE this app's Vite root, so Tailwind v4's auto-detection never scans it and every utility it names generated nothing — measured in the dev @@ -7,49 +8,15 @@ arrive through its alias. */ @source "../../../../../packages/assistant/src"; -/* - * Dreamframe (Castorice) — the SPA's palette, VALUE for value, under the - * shadcn names every surface uses. `lint:design` pins both: the names across - * all three CSS surfaces, and every colour value against the SPA's. Six of - * these — card, border, primary-foreground, success, warning, destructive — - * were this page's own "near-Dreamframe tuning" until 2026-09-14, which is - * exactly the drift the check now refuses: a shared vocabulary with private - * values is drift wearing its uniform. Tailwind supplies this page's spacing - * and type utilities; it generates NO palette utilities — these tokens are - * plain custom properties, and `.muted-text` is the one colour class. - */ +/* The product palette is imported from packages/assistant/tokens.css. + Local declarations below supply the assistant's layout and Tailwind mappings. */ :root { /* Dreamframe (spec 2026-09-03), under the shadcn names every surface uses (spec 2026-09-14 § 3.2) — so a rule here reads the same as one in the SPA, and `lint:design` can compare the three files by name. Values are - unchanged. */ - --background: oklch(0.224 0.035 296); - --card: oklch(0.255 0.032 296); - --border: oklch(0.33 0.035 296); - --foreground: oklch(0.92 0.03 312); - --muted-foreground: oklch(0.74 0.04 310); - --primary: oklch(0.75 0.17 322); - --primary-foreground: oklch(0.16 0.05 322); - --success: oklch(0.765 0.177 163.223); - --warning: oklch(0.828 0.189 84.429); - --destructive: oklch(0.65 0.2 25); - --destructive-foreground: oklch(0.98 0 0); - --secondary: oklch(0.29 0.034 296); - --secondary-foreground: oklch(0.9 0.025 312); - --muted: oklch(0.29 0.034 296); - --accent: oklch(0.33 0.04 296); - --accent-foreground: oklch(0.95 0.02 315); - --input: oklch(0.33 0.035 296); - --ring: oklch(0.62 0.13 322); - /* The primary button's "sunk plum" gradient (Dreamframe: quiet at the - component, bright at the token). Named here because `lint:design` - refuses colour literals outside a token block, and a gradient IS a - design decision, not a one-off. */ - --button-primary-from: oklch(0.34 0.1 322); - --button-primary-to: oklch(0.4 0.1 340); - --button-primary-hover-from: oklch(0.4 0.11 322); - --button-primary-hover-to: oklch(0.46 0.11 340); - --button-primary-foreground: oklch(0.9 0.05 320); + unchanged. The primary button's sunk plum gradient is the shared + palette's, declared in packages/assistant/tokens.css; the Button + component's default variant consumes it. */ /* The rail's active item gradient (spec 2026-09-22, wave 2) — the SPA sidebar's own tokens, value for value (`apps/server/web/src/styles.css`), which is what makes the assistant's rail read as the same navigation the diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md index 83336d30f..f943b47a0 100644 --- a/apps/server/web/AGENTS.md +++ b/apps/server/web/AGENTS.md @@ -537,7 +537,7 @@ Workspace panes offer Web previews & screens through an Open web preview or scre The header's icon-only control is the ONE way into previews, everywhere including the standalone subshell route (operator ruling 2026-10-26, which removed the band under the terminal along with PreviewSection's inline panel and the phone's new-tab link): clicking it there carries the pane into its workspace and raises the picker, where the preview then opens as a tab. 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. 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. +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; the header badge is the one exception, and it reads `GET /views` and `GET /services` for the focused pane only, with the services poll held until the node says it can serve previews. 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. 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. diff --git a/apps/server/web/docs/terminal-gotchas.md b/apps/server/web/docs/terminal-gotchas.md index 55faa940b..5a16ef3c0 100644 --- a/apps/server/web/docs/terminal-gotchas.md +++ b/apps/server/web/docs/terminal-gotchas.md @@ -137,6 +137,25 @@ Whether the viewer may CHANGE the sizing is read off the presence frame's own `canInput`, never passed in: a workspace pane has no access field to hand, and any caller-side copy can disagree with the server that enforces it. +**When the announced grid settles somewhere other than where the replay was +painted, the terminal re-attaches** (`needsGridResync` in +`lib/use-subshell-ws.ts`). Bytes the pane emitted across a resize addressed a +grid this client never rendered, and a relative-positioned TUI (spinner, +pinned block) keeps repainting from them: the rows mix and never heal, which +is the 2026-10-27 "fixed elements overlap the output" report. Only the attach +can fix an attach-time truth (absolute capture, cursor restore, confirmed +geometry), so a mismatch with output applied since the base replays through +the ordinary attach, once per observed change, debounced for drag settle and +storm-gapped. This is deliberately NOT the reverted `eee3a92` reconciliation +loop: the client never asks the server for a different size, it asks for a +fresh truth, and the same grid is never resynced twice. + +The settle delay is 300 ms and the minimum gap between resync attaches is +1,000 ms. A resize inside that gap schedules its check for the later of the +settle delay and the remaining cooldown. Dropping the check at the cooldown +gate leaves the final grid stale until another event arrives. The hook test +covers a second resize during the gap with no later geometry event. + Two traps on this path, both invisible with a single viewer and both hit for real (2026-09-04): @@ -175,6 +194,24 @@ real (2026-09-04): has no drag: on a finger, dockview drags no better, so the phone keeps the presentation that matches its input. +### Native selection in copy mode + +Copy mode needs all three pieces: the capture-phase gesture shield in +`terminal-touch-scroll.ts`, selectable text, and hit testing on `.xterm-rows`. +xterm's DOM renderer sets `pointer-events: none` on the rows for its own +selection service; override it only inside `.copy-mode` so native selection +can start on text. `user-select: text` on the screen alone isn't enough. + +`SubshellTerminal` also disables the hidden textarea while copy mode is on, +both when creating a terminal and when toggling the mode. `disableStdin` alone +still allows focus. Blurring that focused input at the start of a native drag +redraws xterm's cursor row and removes the selection being started. Restore +the textarea when copy mode ends; keep read-only input enforcement separate. + +`e2e/tests/09-mobile-copy-url.spec.ts` checks disabled input, URL tap handling, +a trusted native selection drag, and re-enabling input. Chromium coverage +doesn't prove the iOS long-press callout; that still needs a real-device check. + ### Remote terminal copying `lib/terminal-clipboard.ts` handles OSC 52 writes in xterm’s streaming parser, diff --git a/apps/server/web/scripts/probe-desync.ts b/apps/server/web/scripts/probe-desync.ts index b7a515ba3..1515e713f 100644 --- a/apps/server/web/scripts/probe-desync.ts +++ b/apps/server/web/scripts/probe-desync.ts @@ -4,7 +4,7 @@ * * bunx turbo build * cd apps/server/web - * bun --preload ./src/test-setup.ts scripts/probe-desync.ts + * bun --preload ./scripts/probe-preload.ts scripts/probe-desync.ts * * Hypothesis under test: the client resizes its OWN xterm (fit.fit() on any * ResizeObserver / visualViewport tick) while the tmux pane keeps its size, so @@ -13,7 +13,7 @@ */ import { createHash } from "node:crypto"; import { Terminal } from "@xterm/xterm"; -import { captureToReplayText } from "../../server/dist/ws/capture-text.js"; +import { captureToReplayText } from "../../api/dist/api/ws/capture-text.js"; const id = process.argv[2]; if (!id) throw new Error("usage: probe-desync.ts "); diff --git a/apps/server/web/scripts/probe-diff.ts b/apps/server/web/scripts/probe-diff.ts index a0549fe85..77f8a0848 100644 --- a/apps/server/web/scripts/probe-diff.ts +++ b/apps/server/web/scripts/probe-diff.ts @@ -3,7 +3,7 @@ * * bunx turbo build * cd apps/server/web - * bun --preload ./src/test-setup.ts scripts/probe-diff.ts [keys] + * bun --preload ./scripts/probe-preload.ts scripts/probe-diff.ts [keys] * * Takes a quiet join off a LIVE pane, drives it with real keystrokes, then * feeds the identical `replay + streamed bytes` to @@ -17,7 +17,7 @@ */ import { createHash } from "node:crypto"; import { Terminal } from "@xterm/xterm"; -import { captureToReplayText } from "../../server/dist/ws/capture-text.js"; +import { captureToReplayText } from "../../api/dist/api/ws/capture-text.js"; const id = process.argv[2]; if (!id) throw new Error("usage: probe-diff.ts [keys]"); diff --git a/apps/server/web/scripts/probe-join.ts b/apps/server/web/scripts/probe-join.ts new file mode 100644 index 000000000..92f84e5c3 --- /dev/null +++ b/apps/server/web/scripts/probe-join.ts @@ -0,0 +1,135 @@ +/** + * Production-faithful join probe: does the OVERLAP join (sample the log + * offset, then capture, then stream from the sample) leave a viewer of a + * busy-redrawing pane with a drifted cursor? + * + * bunx turbo build --filter=@internal/server + * cd apps/server/web + * bun --preload ./scripts/probe-preload.ts scripts/probe-join.ts [runs] + * + * probe-stream waits for the pane to go quiet before joining, which is the + * 2026-09-04 rolled-back strategy; production joins mid-redraw. For a TUI + * that repaints with relative moves (claude-code's Ink), the overlap re- + * applies frames the capture already painted, and if that drifts the client + * cursor the pinned region lands on the wrong rows — the reported garble. + * This probe joins the way production does, streams like production does + * (streaming decoder + sync stripper), and diffs the client buffer against + * the pane at the next still moment. + */ +import { createHash } from "node:crypto"; +import { Terminal } from "@xterm/xterm"; +import { captureToReplayText } from "../../api/dist/api/ws/capture-text.js"; +import { SyncStreamStripper } from "../../api/dist/api/ws/sync-stripper.js"; + +const id = process.argv[2]; +if (!id) throw new Error("usage: probe-join.ts [runs]"); +const runs = Number(process.argv[3] ?? 6); +const socket = `subshell-${createHash("sha1").update(id).digest("hex").slice(0, 12)}`; +const logPath = `${process.env.HOME}/.config/subshell-server/subshells/${id}.log`; + +const tmux = (args: string[]): string => { + const p = Bun.spawnSync(["tmux", "-L", socket, ...args]); + if (p.exitCode !== 0) throw new Error(`tmux ${args.join(" ")}: ${p.stderr.toString()}`); + return p.stdout.toString(); +}; +const paneGeo = () => { + const [c, r] = tmux(["display-message", "-t", id, "-p", "#{pane_width}:#{pane_height}"]).trim().split(":"); + return { cols: Number(c), rows: Number(r) }; +}; +/** + * Reads EXACTLY like the agent's `paneCursor`, including the digit guard + * `tmux-runner.ts` added: tmux can print `":"` for a pane caught mid-redraw and + * `Number("")` is 0, so a lenient parse anchors the replay at the top-left cell + * and reports a drift production no longer has. The probe's whole product is + * fidelity to production, so the two reads move together or the probe lies. + */ +const paneCursor = () => { + const out = tmux(["display-message", "-t", id, "-p", "#{cursor_x}:#{cursor_y}"]).trim(); + if (!/^\d+:\d+$/.test(out)) return null; + const [rawX, rawY] = out.split(":"); + const x = Number(rawX); + const y = Number(rawY); + if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0) return null; + return { x, y }; +}; +const logSize = () => Bun.file(logPath).size ?? 0; +const flush = (t: Terminal) => new Promise((r) => t.write("", () => r())); +const stripAnsi = (s: string): string => + // biome-ignore lint/suspicious/noControlCharactersInRegex: probing raw terminal bytes + s.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ""); + +async function oneJoin(geo: { cols: number; rows: number }): Promise<{ ok: boolean; note: string }> { + const term = new Terminal({ cols: geo.cols, rows: geo.rows, allowProposedApi: true, scrollback: 1000 }); + // The production order: sample offset, capture mid-redraw (NO quiet wait), + // read the cursor right after the capture, ship replay, then stream from + // the sample (the overlap), then keep streaming live bytes. + const logStart = logSize(); + const text = tmux(["capture-pane", "-p", "-e", "-J", "-t", id, "-S", "-100"]); + const cursor = paneCursor(); + const replay = captureToReplayText(text, cursor ?? undefined); + term.write(replay); + await flush(term); + const decoder = new TextDecoder(); + const stripper = new SyncStreamStripper(); + let last = logStart; + const until = Date.now() + 4000; // stream like the tail does (50 ms polls) + while (Date.now() < until) { + const size = logSize(); + if (size > last) { + const buf = await Bun.file(logPath).slice(last, size).bytes(); + last = size; + const chunk = stripper.push(decoder.decode(buf, { stream: true })); + if (chunk) { + term.write(chunk); + await flush(term); + } + } + await Bun.sleep(50); + } + // Compare at a still moment. + for (let i = 0; i < 200; i++) { + const before = logSize(); + Bun.sleepSync(30); + if (logSize() === before) break; + } + const tail = logSize(); + if (tail > last) { + const buf = await Bun.file(logPath).slice(last, tail).bytes(); + const chunk = stripper.push(decoder.decode(buf, { stream: true })); + if (chunk) { + term.write(chunk); + await flush(term); + } + } + const afterText = tmux(["capture-pane", "-p", "-e", "-J", "-t", id, "-S", "-100"]); + const paneGrid = stripAnsi(afterText) + .replace(/\n$/, "") + .split("\n") + .slice(-geo.rows) + .map((l) => l.replace(/\s+$/, "")); + const buf = term.buffer.active; + const clientGrid = Array.from({ length: term.rows }, (_, i) => + (buf.getLine(buf.baseY + i)?.translateToString(true) ?? "").replace(/\s+$/, ""), + ); + for (let i = 0; i < Math.min(paneGrid.length, clientGrid.length); i++) { + if (paneGrid[i] !== clientGrid[i]) { + const sample = `row ${i}: pane ${JSON.stringify(paneGrid[i]?.slice(0, 40))} vs client ${JSON.stringify(clientGrid[i]?.slice(0, 40))}`; + return { ok: false, note: sample }; + } + } + const cur = paneCursor(); + const cursorNote = cur + ? `cursor pane (${cur.x},${cur.y}) client (${buf.cursorX},${buf.cursorY})` + : "cursor read: none"; + return { ok: buf.cursorY === (cur?.y ?? buf.cursorY), note: cursorNote }; +} + +const geo = paneGeo(); +let diverged = 0; +for (let i = 0; i < runs; i++) { + const { ok, note } = await oneJoin(geo); + if (!ok) diverged++; + console.log(`join ${i + 1}/${runs}: ${ok ? "ok" : "DIVERGED"} — ${note}`); + await Bun.sleep(200); +} +console.log(`\n${diverged}/${runs} production-style joins diverged`); diff --git a/apps/server/web/scripts/probe-preload.ts b/apps/server/web/scripts/probe-preload.ts new file mode 100644 index 000000000..f2b62a536 --- /dev/null +++ b/apps/server/web/scripts/probe-preload.ts @@ -0,0 +1,11 @@ +/** + * Minimal happy-dom registration for the manual probe scripts. + * + * The suite preload (`src/test-setup.ts`) also installs a fetch delegator and + * a `bun:test` afterEach, which make it unusable for a plain `bun

Vite preview

', + ); + writeFileSync( + `${viteRoot}/main.js`, + 'import "/style.css"; document.querySelector("h1").dataset.ready="yes"; const name="fixture"; import(/* @vite-ignore */ name).then(m=>document.querySelector("h1").dataset.mapped=m.value);', + ); + writeFileSync(`${viteRoot}/style.css`, "h1{color:rgb(1, 2, 3)}"); + writeFileSync(`${viteRoot}/mapped.js`, 'export const value="mapped";'); + const vite = spawn( + "bun", + [ + `${process.cwd()}/apps/server/web/node_modules/vite/bin/vite.js`, + viteRoot, + "--host", + "127.0.0.1", + "--port", + "3491", + "--strictPort", + ], + { stdio: ["ignore", "ignore", "inherit"] }, + ); + cleanup.unshift(() => vite.kill()); + await expect + .poll( + async () => { + try { + return (await fetch("http://127.0.0.1:3491")).ok; + } catch { + return false; + } + }, + { timeout: 15000 }, + ) + .toBe(true); + const viteService = await post(`/api/subshells/${pane.id}/services`, { + name: "Vite fixture", + host: "127.0.0.1", + port: 3491, + protocol: "http", + }); + const opened = await post(`/api/subshells/${pane.id}/views/open`, { serviceId: viteService.id }); + const direct = await context.newPage(); + direct.on("pageerror", (e) => console.log("Vite error", e.message)); + direct.on("console", (m) => { + if (m.type() === "error") console.log("Vite console", m.text()); + }); + await direct.goto(opened.proxyBase); + await expect(direct.locator("h1")).toHaveAttribute("data-ready", "yes"); + await expect(direct.locator("h1")).toHaveAttribute("data-mapped", "mapped"); + await expect(direct.locator("h1")).toHaveCSS("color", "rgb(1, 2, 3)"); + writeFileSync(`${viteRoot}/style.css`, "h1{color:rgb(4, 5, 6)}"); + await expect(direct.locator("h1")).toHaveCSS("color", "rgb(4, 5, 6)", { timeout: 15000 }); + console.log("Vite modules, CSS and hot reload verified"); +} finally { + for (const close of cleanup) await close(); +} diff --git a/native/display-helper/Consent.swift b/native/display-helper/Consent.swift index 5955b75e1..c311b313f 100644 --- a/native/display-helper/Consent.swift +++ b/native/display-helper/Consent.swift @@ -1,11 +1,21 @@ import Foundation -/// Only the native settings panel changes this preference. Node RPC has no -/// enable operation; macOS permissions alone never imply Subshell consent. +/// Only the two consent acts change this preference, and they are reachable from +/// the panel and the status menu: `Runtime.enable()` stores the agreement and +/// `Runtime.disable()` clears it. A sign-in start only resumes what is already +/// stored, and its failure path stops the runtime without touching the flag. +/// Node RPC has no enable operation, and macOS permissions alone never imply +/// Subshell consent. final class HelperConsent { - static let bundleID = "dev.subshell.display-helper" private let defaults: UserDefaults - init(defaults: UserDefaults = UserDefaults(suiteName: bundleID)!) { self.defaults = defaults } + // The default is `standard`, which for this bundled helper IS its own + // domain (`dev.subshell.display-helper.plist`). Handing `suiteName:` the + // app's own bundle identifier is not a no-op: Foundation rejects it, logs + // "Using your own bundle identifier as an NSUserDefaults suite name does + // not make sense and will not work", and returns nil. So the old + // force-unwrap trapped (`EXC_BREAKPOINT`) inside `DisplayHelperApp.init`, + // before any window or status item existed, and `open` still exited 0. + init(defaults: UserDefaults = .standard) { self.defaults = defaults } var enabled: Bool { defaults.bool(forKey: "enabled") } func setEnabled(_ enabled: Bool) { defaults.set(enabled, forKey: "enabled") } } diff --git a/native/display-helper/Cursor.swift b/native/display-helper/Cursor.swift new file mode 100644 index 000000000..fd4c26f63 --- /dev/null +++ b/native/display-helper/Cursor.swift @@ -0,0 +1,44 @@ +import AppKit +import CoreGraphics + +/// Whether the pointer is stranded: it has to fall inside at least one +/// screen's frame, or the person sitting at this Mac cannot see it at all. +/// +/// Pure so the rule is testable without a display or a cursor. +func cursorIsStranded(point: CGPoint, screens: [CGRect]) -> Bool { + !screens.contains { $0.contains(point) } +} + +/// Put the pointer back on the built-in display when a viewing session has +/// left it stranded. +/// +/// A viewer's pointer events move the REAL system cursor onto the virtual +/// display; that is how shared control works on a single macOS desktop. If the +/// phantom screen disappears while the cursor is on it (pane closed, display +/// discarded, helper quit), the local person is left with no visible cursor +/// and no way to know why, which is exactly what happened on the M5. Only +/// warping when stranded leaves a person's own pointer where they put it. +@MainActor func restoreStrandedCursor() { + let point = NSEvent.mouseLocation + let frames = NSScreen.screens.map(\.frame) + guard cursorIsStranded(point: point, screens: frames) else { return } + // The display carrying the origin is the one macOS treats as home. + let home = frames.first(where: { $0.origin == .zero }) ?? frames.first + guard let home else { return } + CGWarpMouseCursorPosition(CGPoint(x: home.midX, y: home.midY)) + CGAssociateMouseAndMouseCursorPosition(1) +} + +/// The launch order the helper's own documentation promises, in a function a test can +/// reach. +/// +/// `main.swift` is excluded from the native test build, because its top-level code +/// collides with `HelperTests`' `@main`, so the one rule that file enforces, rescue +/// before bind, would otherwise be pinned nowhere. It is not decoration: a start whose +/// socket cannot bind exits on the next line, and the quit-time rescue is gated on +/// `started`, so healing after the bind leaves exactly the failed launch holding a +/// pointer inside no screen's frame while the docs promise any start fixes that. +@MainActor func launchHeal(restore: @MainActor () -> Void, bind: @MainActor () throws -> Void) throws { + restore() + try bind() +} diff --git a/native/display-helper/Info.plist b/native/display-helper/Info.plist index 568c4e6fc..7d85717de 100644 --- a/native/display-helper/Info.plist +++ b/native/display-helper/Info.plist @@ -6,8 +6,8 @@ CFBundleDisplayNameSubshell Display Helper CFBundleExecutablesubshell-display-helper CFBundlePackageTypeAPPL - CFBundleVersion1.0.0 - CFBundleShortVersionString1.0.0 + CFBundleVersion1.0.1 + CFBundleShortVersionString1.0.1 LSMinimumSystemVersion14.0 LSUIElement NSHighResolutionCapable diff --git a/native/display-helper/Runtime.swift b/native/display-helper/Runtime.swift index 67e00241b..b70876dc2 100644 --- a/native/display-helper/Runtime.swift +++ b/native/display-helper/Runtime.swift @@ -21,6 +21,12 @@ final class HelperRuntime { func listenForStatus() throws { try listener.start() } + /// How many subshell screens are being captured right now. The menu bar + /// badge, the tooltip, and the panel's live line all read this, so the + /// person sitting at the Mac can see when someone is looking at a virtual + /// display rather than having to infer it. + var viewers: Int { captures.count } + func shutdown() { listener.stop() for connection in Array(connections.values) { connection.close() } @@ -55,13 +61,24 @@ final class HelperRuntime { private func remove(_ id: UUID) { inputs.removeValue(forKey: id)?.release() - captures.removeValue(forKey: id)?.stop() + if let capture = captures.removeValue(forKey: id) { + capture.stop() + // The menu bar badge and the panel's live line report who is + // being watched, so a capture ending has to announce itself. + onStateChange?() + } connections.removeValue(forKey: id) pending.remove(id); reservations.removeValue(forKey: id); rates.removeValue(forKey: id) } private func handle(_ request: [String: Any], _ connection: HelperConnection) { - guard let operation = request["op"] as? String, consent.enabled || operation == "status" else { connection.close(); return } + // `rescue-cursor` is allowed without consent: by the time the daemon + // asks for it the display it stranded the pointer on is already gone, + // and the action only puts the cursor back where the person at this + // Mac can see it. + guard let operation = request["op"] as? String, + consent.enabled || operation == "status" || operation == "rescue-cursor" + else { connection.close(); return } let now = ProcessInfo.processInfo.systemUptime let previous = rates[connection.id] ?? (now, 0) let rate = now - previous.0 >= 1 ? (now, 1) : (previous.0, previous.1 + 1) @@ -105,6 +122,9 @@ final class HelperRuntime { catch { input.release(); connection.send(["type": "input_blocked", "message": error.localizedDescription]) } case "release": inputs[connection.id]?.release() case "stop": connection.close() + case "rescue-cursor": + restoreStrandedCursor() + connection.send(["type": "rescued", "protocol": 1]) default: throw HelperFailure.message("Unknown helper request.") } } catch { @@ -123,12 +143,14 @@ final class HelperRuntime { allowed: allowed, frame: { [weak connection] jpeg in connection?.send(["type": "frame", "jpeg": jpeg.base64EncodedString(), "width": width, "height": height]) }, lost: { [weak connection] message in + restoreStrandedCursor() connection?.send(["type": "error", "message": message]); connection?.close() }) captures[connection.id] = capture inputs[connection.id] = DisplayInput(uuid: uuid, width: width, height: height, allowed: allowed) try await capture.start() - guard allowed() else { capture.stop(); return } + guard allowed() else { capture.stop(); onStateChange?(); return } + onStateChange?() pending.remove(connection.id) connection.send(["type": "started", "width": width, "height": height]) } catch { diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift index abcb28b4c..41931bb69 100644 --- a/native/display-helper/Settings.swift +++ b/native/display-helper/Settings.swift @@ -1,157 +1,429 @@ import AppKit import ApplicationServices import ServiceManagement +import WebKit -@MainActor -private final class SettingsStack: NSStackView { - override var isFlipped: Bool { true } +/// Where the panel's own files live inside the helper bundle. +/// +/// File-scope rather than a member of `HelperSettings` because the navigation +/// check has to run on whatever thread WebKit calls the delegate on, and a +/// `@MainActor` type's statics are main-actor state. +/// +/// Module-visible rather than `private` for one reason: `tests/HelperTests.swift` +/// checks the gate. It is the one rule standing between a document loaded from +/// somewhere else and the button that starts a screen capture, and a bundle-less +/// test binary has no `panel` directory to answer for, so the directory is a +/// parameter of the pure half rather than read from `Bundle.main` there. +enum PanelBundle { + /// Staged by `build_support.rs` from `packages/helper-panel/dist`. + static func indexURL() -> URL? { + Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "panel") + } + + /// The one directory the page may load from, and the only one the file + /// read is scoped to. + static func directoryURL() -> URL? { indexURL()?.deletingLastPathComponent() } + + /// Allow a main-frame navigation only while the document stays inside the + /// bundle. The bridge can enable capture, so a page loaded from anywhere + /// else would be handed that button. + static func allows(_ url: URL?) -> Bool { + allows(url, within: directoryURL()) + } + + /// The gate itself, with the directory handed in. + static func allows(_ url: URL?, within directory: URL?) -> Bool { + guard let url, let directory, url.isFileURL else { return false } + // Standardized, so `panel/../../Secrets/x` cannot wear the prefix of a + // directory it actually escapes. The file read is scoped separately; + // this gate is the one the header comment leans on, so it stands alone. + let path = url.standardizedFileURL.path + // The trailing separator is not decoration: without it a sibling named + // `panel-evil` shares the prefix and is read as being inside. + return path == directory.path || path.hasPrefix(directory.path + "/") + } } +/// The helper's panel: its one consent surface. +/// +/// The page is `packages/helper-panel`, built on the shared design system +/// (`packages/assistant/tokens.css`), so the panel reads as the same product as +/// the dashboard instead of as a hand-rolled approximation of it. It is still +/// not an authority. It is loaded with `loadFileURL` scoped to its own +/// directory, from a non-persistent data store, with no window opening and no +/// navigation off-bundle, and its own Content-Security-Policy (`default-src +/// 'none'`, with only the two inline sources the build's fold needs) refuses +/// every request the page might try to make. So it can fetch nothing and hold +/// nothing: what it shows comes from the helper process, and every act it +/// offers is re-checked in Swift. A page that somehow posts `requestCapture` while consent is off +/// gets the same refusal as a person who cannot click it. @MainActor -final class HelperSettings: NSObject { +final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + private static let bridge = "subshell" + private static let docsURL = "https://docs.subshell.sh/previews/macos" + private let consent: HelperConsent private let enable: () throws -> Void private let disable: () -> Void + private let accessCount: () -> Int + private var web: WKWebView? private var window: NSWindow? - private let status = NSTextField(wrappingLabelWithString: "") - private let error = NSTextField(wrappingLabelWithString: "") - private let captureStatus = NSTextField(labelWithString: "") - private let inputStatus = NSTextField(labelWithString: "") - private let toggle = NSButton(title: "Enable helper", target: nil, action: nil) - private let capture = NSButton(title: "Allow screen recording…", target: nil, action: nil) - private let input = NSButton(title: "Allow accessibility…", target: nil, action: nil) + /// The page's own entry point, so a state push before it exists is skipped + /// rather than swallowed by a rejected script. + private var loaded = false + /// The last JSON handed to the page. The poll runs every five seconds, and + /// re-rendering an unchanged panel would move the caret and the selection. + private var lastPushed = "" + private var errorMessage = "" private var timer: Timer? + /// What the person sees when the bundle has no panel: a named problem on + /// the window itself, not an empty dark rectangle. + private let fallback = NSTextField(wrappingLabelWithString: "") - init(consent: HelperConsent, enable: @escaping () throws -> Void, disable: @escaping () -> Void) { + init(consent: HelperConsent, + enable: @escaping () throws -> Void, + disable: @escaping () -> Void, + accessCount: @escaping () -> Int = { 0 }) + { self.consent = consent self.enable = enable self.disable = disable + self.accessCount = accessCount } + /// Called whenever the panel appears or disappears. The status menu's + /// Minimize item is enabled only while the panel is on screen, and the menu + /// is built once at launch, BEFORE `show()` has ever run: without this the + /// item keeps the answer it computed when nothing was visible, which is how + /// "Minimize helper" came to read as greyed out in front of an open window. + var onVisibilityChange: (() -> Void)? + + /// Whether the panel is on screen; the menu bar's Minimize item answers + /// to this so it never offers something that does nothing. + var isShowing: Bool { window?.isVisible ?? false } + func show() { if window == nil { build() } refresh() + // The panel is opened from the desktop app's button as well as from this + // helper's menu, so the click may have landed in another app, and an + // accessory app is not trusted to pull its own window over the frontmost + // one on someone else's keystroke. Ordering it up regardless is what makes + // the press visible; the activation call is the one this helper has always + // shipped and the one the macOS lane compiles clean. + // + // Keep the activation form exercised by the macOS lane. test.sh compiles + // the test executable without main.swift and treats warnings as errors; + // its separate app typecheck includes main.swift with the release build's + // flags, which don't include warnings-as-errors. + web?.evaluateJavaScript("window.subshellPanelShown?.()", completionHandler: nil) window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() NSApp.activate(ignoringOtherApps: true) + onVisibilityChange?() + } + + /// Hide the panel and leave everything else alone: consent, permissions, + /// and any screen being watched keep running. The menu bar item is still + /// there to bring it back. + func minimize() { + window?.orderOut(nil) + onVisibilityChange?() } + /// Push the machine's current truth to the page. Cheap to call: identical + /// state is not sent twice. + func refresh() { pushState() } + private func build() { - let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 680), - styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - panel.title = "Virtual display helper" + let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: Layout.windowWidth, height: Layout.initialHeight), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, defer: false) + panel.title = "Subshell virtual display helper" panel.isReleasedWhenClosed = false - let stack = SettingsStack() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 16 - stack.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) - panel.minSize = NSSize(width: 570, height: 500) - let scroll = NSScrollView(frame: panel.contentView!.bounds) - scroll.hasVerticalScroller = true - scroll.autoresizingMask = [.width, .height] - stack.translatesAutoresizingMaskIntoConstraints = false - scroll.documentView = stack - panel.contentView = scroll + // The web theme is dark-only, so the panel pins it rather than mixing a + // light-mode aqua title bar onto the void ground. + panel.appearance = NSAppearance(named: .darkAqua) + panel.backgroundColor = Theme.background + + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let config = WKWebViewConfiguration() + // Nothing persists: no cookies, no storage, no cache. The panel is a + // render of the helper's state, and it has no business remembering + // anything between launches. + config.websiteDataStore = .nonPersistent() + config.userContentController.add(self, name: Self.bridge) + config.preferences.javaScriptCanOpenWindowsAutomatically = false + + let view = WKWebView(frame: .zero, configuration: config) + view.navigationDelegate = self + view.underPageBackgroundColor = Theme.background + view.translatesAutoresizingMaskIntoConstraints = false + + fallback.font = Theme.detailFont + fallback.textColor = Theme.mutedForeground + fallback.maximumNumberOfLines = 0 + fallback.isSelectable = true + fallback.preferredMaxLayoutWidth = Layout.windowWidth - 2 * Layout.inset + fallback.isHidden = true + fallback.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(view) + container.addSubview(fallback) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), - stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), - stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor) + // No width here: the document reports its own, and the window + // follows. A constraint on the container would fight the resize and + // clip the page it is supposed to frame. + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.topAnchor.constraint(equalTo: container.topAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + fallback.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Layout.inset), + fallback.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Layout.inset), + fallback.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.inset), ]) - let intro = NSTextField(wrappingLabelWithString: - "Allow Subshell to capture and control virtual displays on this Mac. The helper is shared by Subshell Client and Subshell Server. It stays off until you enable it here.") - stack.addArrangedSubview(intro) - let warning = NSTextField(wrappingLabelWithString: - "macOS apps share your desktop, keyboard focus, and pointer. Other windows or notifications on a virtual display can appear in its stream. Only enable this for people you trust to control this Mac.") - warning.textColor = .systemOrange - stack.addArrangedSubview(warning) - stack.addArrangedSubview(status) - toggle.target = self; toggle.action = #selector(toggleHelper) - stack.addArrangedSubview(toggle) - stack.addArrangedSubview(NSTextField(wrappingLabelWithString: - "Once enabled, the helper runs in the background and starts when you sign in. Disable it here at any time to stop capture and input and prevent it from starting again. This doesn't close your subshells or revoke macOS permissions.")) - stack.addArrangedSubview(NSButton(title: "Open login items…", target: self, action: #selector(openLoginItems))) - stack.addArrangedSubview(captureStatus) - capture.target = self; capture.action = #selector(requestCapture) - stack.addArrangedSubview(capture) - stack.addArrangedSubview(inputStatus) - input.target = self; input.action = #selector(requestInput) - stack.addArrangedSubview(input) - let docs = NSButton(title: "Setup instructions…", target: self, action: #selector(openDocs)) - stack.addArrangedSubview(docs) - stack.addArrangedSubview(NSButton(title: "Quit helper", target: self, action: #selector(quitHelper))) - error.textColor = .systemRed - stack.addArrangedSubview(error) - for view in stack.arrangedSubviews { - view.setContentCompressionResistancePriority(.required, for: .vertical) - if let label = view as? NSTextField { - label.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48).isActive = true - } + panel.contentView = container + + if let index = PanelBundle.indexURL(), let directory = PanelBundle.directoryURL() { + view.loadFileURL(index, allowingReadAccessTo: directory) + } else { + reportMissingBundle() } - panel.center() + + // The red button hides this window rather than releasing it, and that is + // another way the panel leaves the screen. A block observer stands in + // for NSWindowDelegate conformance because the delegate's actor + // isolation in the SDK is not ours to assume. + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: panel, queue: .main + ) { [weak self] _ in + // willClose arrives before isVisible changes. Rebuild the menu + // after the close completes so Minimize reflects the hidden panel. + Task { @MainActor [weak self] in self?.onVisibilityChange?() } + } + + web = view window = panel - timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } + panel.center() + // Permissions are granted in System Settings, two clicks and a dialog + // away from this panel, and macOS sends no notification when they + // change. Polling the two preflight calls is how the row catches up. + // Five seconds, in every run-loop mode. The default mode stops firing + // while a menu is being tracked, and the whole point of the poll is to + // catch a change made in another app, so the wait for a switch flip in + // System Settings must not depend on this process being idle. + let poll = Timer(timeInterval: 5, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.window?.isVisible == true else { return } + self.pushState() + } } + RunLoop.main.add(poll, forMode: .common) + timer = poll } - func refresh() { - let enabled = consent.enabled - let recording = CGPreflightScreenCaptureAccess() - let accessibility = AXIsProcessTrusted() - status.stringValue = enabled ? "Helper enabled. Capture and control also require both macOS permissions below." : "Helper disabled. No capture or input is allowed." - if enabled && SMAppService.mainApp.status == .requiresApproval { - status.stringValue += " Allow background startup in System Settings → General → Login Items to resume it after signing in." - } - toggle.title = enabled ? "Disable helper" : "Enable helper" - captureStatus.stringValue = "Screen Recording: \(recording ? "Allowed" : "Not allowed")" - inputStatus.stringValue = "Accessibility: \(accessibility ? "Allowed" : "Not allowed")" - capture.isEnabled = enabled - input.isEnabled = enabled - let reason = enabled ? nil : "Enable the helper before requesting macOS permissions." - capture.toolTip = reason; input.toolTip = reason - if !enabled { status.stringValue += " Enable it before requesting macOS permissions." } - } - - @objc private func toggleHelper() { - error.stringValue = "" - if consent.enabled { - disableHelper() - } else { - do { - try enable() - try SMAppService.mainApp.register() - } catch { - disable() - self.error.stringValue = "Couldn't enable the helper: \(error.localizedDescription)" - } + private func reportMissingBundle() { + errorMessage = "The panel files are missing from this helper bundle. Reinstall Subshell to restore them." + fallback.stringValue = errorMessage + fallback.isHidden = false + web?.isHidden = true + } + + /// The state shape `packages/helper-panel/src/state.ts` renders. The keys + /// are the contract between the two, so they stay in step by name. + private func snapshot() -> [String: Any] { + [ + "enabled": consent.enabled, + "screenRecording": CGPreflightScreenCaptureAccess(), + "accessibility": AXIsProcessTrusted(), + "viewers": accessCount(), + "error": errorMessage, + "loginItem": LoginItem.pageState(SMAppService.mainApp.status), + ] + } + + private func pushState(force: Bool = false) { + guard loaded, let view = web, + let data = try? JSONSerialization.data(withJSONObject: snapshot(), options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { return } + if !force, json == lastPushed { return } + lastPushed = json + view.evaluateJavaScript("window.subshellSetState(\(json))", completionHandler: nil) + } + + /// Follow the document's own box. The page reports it after every render, so + /// the width in `packages/helper-panel/src/styles.css` is the one number and + /// nothing inside the card ever scrolls sideways. + private func resize(toWidth width: Double, height: Double) { + guard width > 0, height > 0, let window else { return } + let wanted = NSSize( + width: min(max(CGFloat(width), Layout.minWidth), Layout.maxWidth), + height: min(max(CGFloat(height), Layout.minHeight), Layout.maxHeight) + ) + window.setContentSize(wanted) + } + + private func handle(action: String) { + switch action { + case "enable": enableHelper() + case "disable": disableHelper() + case "requestCapture": requestCapture() + case "requestInput": requestInput() + case "openLoginItems": SMAppService.openSystemSettingsLoginItems() + case "openCaptureSettings": open(SettingsRoute.screenRecording) + case "openInputSettings": open(SettingsRoute.accessibility) + case "registerLoginItem": registerLoginItem() + case "unregisterLoginItem": unregisterLoginItem() + case "openDocs": + if let url = URL(string: Self.docsURL) { NSWorkspace.shared.open(url) } + case "minimize": minimize() + // Quit is lifecycle only. It used to call disableHelper() too, which + // revoked the person's stored consent and unregistered the login item: + // an app's Close button silently destroying the very permission the + // panel exists to collect, so every reopen started from Disabled. + // "Disable helper" remains the consent act, in this panel and the menu. + case "quit": NSApp.terminate(nil) + // An action this binary does not know is a page newer than the + // helper: ignore it rather than guess at what it asked for. + default: break } - refresh() + pushState(force: true) + } + + /// The consent act, and nothing else: the helper starts listening and the + /// person's agreement is stored. It deliberately does not register the + /// login item, because agreeing to be captured and agreeing to launch at + /// sign-in are two choices and macOS's list is not the place to discover + /// the second one was made for you. `registerLoginItem` is its own act with + /// its own button, in the panel: the status menu carries consent and the + /// exits, and leaves the sign-in list to the row that can read it back. + func enableHelper() { + errorMessage = "" + do { try enable() } + catch { errorMessage = "Couldn't enable the helper: \(error.localizedDescription)" } + } + + /// Registering for sign-in is its own act with its own button. Enabling the + /// helper used to do it silently, so agreeing to be captured also put a + /// launcher in macOS's login list without anyone being asked. + func registerLoginItem() { + errorMessage = "" + do { try SMAppService.mainApp.register() } + catch { errorMessage = "Couldn't set the helper to start at sign-in: \(error.localizedDescription)" } } + func unregisterLoginItem() { + errorMessage = "" + do { try SMAppService.mainApp.unregister() } + catch { errorMessage = "Couldn't stop the helper from starting at sign-in. Turn it off in System Settings under General, Login Items." } + } + + /// Disabling stops capture and input and leaves the sign-in choice alone. It + /// was the person's choice to make, and un-making it here would be the same + /// silent side effect enabling used to have. A helper that starts with + /// consent off does nothing and exits. func disableHelper() { disable() - do { try SMAppService.mainApp.unregister() } - catch { self.error.stringValue = "The helper is disabled. Remove its login item in macOS System Settings if it still appears there." } - refresh() + errorMessage = "" + pushState(force: true) + } + + /// Opens one of System Settings' panes. A malformed route is a developer + /// mistake, not something to bother the person with, so it fails quietly. + private func open(_ route: String) { + if let url = URL(string: route) { NSWorkspace.shared.open(url) } } - @objc private func requestCapture() { + private func requestCapture() { + // Consent gates the prompt as well as the capture: a helper that is + // off has no business asking macOS for someone's screen. guard consent.enabled else { return } _ = CGRequestScreenCaptureAccess() - refresh() } - @objc private func requestInput() { + private func requestInput() { guard consent.enabled else { return } _ = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) - refresh() } - @objc private func openLoginItems() { SMAppService.openSystemSettingsLoginItems() } + // MARK: - WebKit bridge + // + // The delegate methods stay `nonisolated` because that is how the SDK + // declares them, while the properties they hand us (`WKScriptMessage.body`, + // `WKNavigationAction.request`) are main-actor state. So each one reads its + // argument on main rather than from here: a `Task` where the work may wait a + // runloop turn, and `assumeIsolated` where the answer has to be synchronous, + // which WebKit guarantees by delivering both calls on the thread that owns + // the view, the main thread, since `build()` created it there. - @objc private func quitHelper() { disableHelper(); NSApp.terminate(nil) } + nonisolated func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + Task { @MainActor in + // The bridge is the page's only door into the helper, so it carries a + // name and a number and nothing else. + guard message.frameInfo.isMainFrame, + PanelBundle.allows(message.frameInfo.request.url) else { return } + guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return } + if action == "resize" { + let width = (body["width"] as? NSNumber)?.doubleValue ?? 0 + let height = (body["height"] as? NSNumber)?.doubleValue ?? 0 + self.resize(toWidth: width, height: height) + } else { + self.handle(action: action) + } + } + } - @objc private func openDocs() { - NSWorkspace.shared.open(URL(string: "https://docs.subshell.sh/nodes/manage#macos-display-helper")!) + nonisolated func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + MainActor.assumeIsolated { + decisionHandler(PanelBundle.allows(navigationAction.request.url) ? .allow : .cancel) + } } + + nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor in + self.loaded = true + self.fallback.isHidden = true + self.web?.isHidden = false + self.pushState(force: true) + } + } + + nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + reportLoadFailure(error.localizedDescription) + } + + nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error) { + reportLoadFailure(error.localizedDescription) + } + + private nonisolated func reportLoadFailure(_ words: String) { + let message = "The panel failed to load: \(words)" + Task { @MainActor in + // Only the first load can leave the person with a blank panel; a + // failure after that is some subresource the page no longer needs. + guard !self.loaded else { return } + self.errorMessage = message + self.fallback.stringValue = message + self.fallback.isHidden = false + } + } +} + +/// The window's first box and the limits it may be resized within. The width +/// the person finally sees comes from the document (`width: 520px` in +/// `packages/helper-panel/src/styles.css`); these only have to be good enough to +/// load the page in and wide enough that the first paint is not a scrollbar. +private enum Layout { + static let windowWidth: CGFloat = 520 + static let inset: CGFloat = 20 + static let initialHeight: CGFloat = 560 + static let minWidth: CGFloat = 360 + static let maxWidth: CGFloat = 720 + static let minHeight: CGFloat = 200 + static let maxHeight: CGFloat = 1400 } diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift new file mode 100644 index 000000000..e8328b9d6 --- /dev/null +++ b/native/display-helper/Theme.swift @@ -0,0 +1,167 @@ +import AppKit + +/// The three colours and one type role the native chrome still needs. +/// +/// The panel's own page draws the palette itself, from +/// `packages/assistant/tokens.css`, so this file is no longer a mirror of the +/// whole theme: what is left is what AppKit must still paint by hand, the +/// window's ground behind the web view, the muted text and `detail` size of the +/// "the bundle has no panel" label, and the gold the status item takes on while +/// someone is watching. +/// +/// The numbers are the CSS tokens themselves (L, C, H) declared again here, +/// rather than re-typed hexes: the triples must match +/// `packages/assistant/tokens.css` name for name, and the helper-panel suite's +/// palette test reads both files and compares them, so this copy cannot drift +/// quietly the way a re-typed hex would. The conversion from those triples to +/// sRGB is pure, and `test.sh` pins it. +/// +/// The web theme is dark-only, so the panel pins `darkAqua` instead of +/// following the system appearance: a light-mode aqua title bar sitting on the +/// void ground looks broken, not themed. +enum Theme { + /// Token name to (oklch L, C, H) exactly as `packages/assistant/tokens.css` + /// declares them. Add a row only when native chrome has to draw it. + static let tokens: [String: (l: Double, c: Double, h: Double)] = [ + "background": (0.224, 0.035, 296), + "muted-foreground": (0.74, 0.04, 310), + "warning": (0.828, 0.189, 84.429), + ] + + /// The panel's ground: the same void the dashboard draws. + static let background = color("background") + /// Secondary text: metadata and anything the page could not render. + static let mutedForeground = color("muted-foreground") + /// Gold: the operator can fix this (the same role `text-warning` holds). + static let warning = color("warning") + + /// The `detail` role (13px at 400). There is no 12px: detail is the floor, + /// the same rule the dashboard obeys. + static let detailFont = NSFont.systemFont(ofSize: 13, weight: .regular) + + /// The token as sRGB components. Oklab's inverse transform, then the + /// sRGB transfer curve, clamped to the gamut the way CSS does. + static func srgb(_ name: String) -> (red: Double, green: Double, blue: Double) { + guard let token = tokens[name] else { preconditionFailure("unknown token \(name)") } + let h = token.h * Double.pi / 180 + let a = token.c * cos(h) + let b = token.c * sin(h) + let l_ = token.l + 0.396_337_777_4 * a + 0.215_803_757_3 * b + let m_ = token.l - 0.105_561_345_8 * a - 0.063_854_172_8 * b + let s_ = token.l - 0.089_484_177_5 * a - 1.291_485_548 * b + let l = l_ * l_ * l_ + let m = m_ * m_ * m_ + let s = s_ * s_ * s_ + func encode(_ v: Double) -> Double { + let x = min(max(v, 0), 1) + return x <= 0.003_130_8 ? 12.92 * x : 1.055 * pow(x, 1 / 2.4) - 0.055 + } + return (encode(4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s), + encode(-1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s), + encode(-0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701 * s)) + } + + static func color(_ name: String) -> NSColor { + let rgb = srgb(name) + return NSColor(srgbRed: rgb.red, green: rgb.green, blue: rgb.blue, alpha: 1) + } +} + +/// What the menu bar says while someone is watching a screen. Pure so the +/// wording is testable without a status item. The panel's own copy lives in +/// `packages/helper-panel/src/strings.ts`; keep a sentence in one of the two, +/// not both. +#if os(macOS) +import ServiceManagement + +/// The login item's state, in the three words the page renders. Kept apart +/// because each needs a different sentence: `requiresApproval` is macOS waiting +/// for a switch the person has not thrown, while `notRegistered` means the item +/// is not in the list at all, which is what turning it OFF in System Settings +/// leaves behind. Reading both as approved is how a disabled sign-in start kept +/// claiming it starts at sign-in. +enum LoginItem { + /// macOS's answer right now, in the word the page uses. Callers that are + /// not the web view (the quit alert, mainly) read the registration through + /// this rather than importing ServiceManagement themselves. + static func currentPageState() -> String { + pageState(SMAppService.mainApp.status) + } + + static func pageState(_ status: SMAppService.Status) -> String { + switch status { + case .enabled: return "registered" + case .requiresApproval: return "needsApproval" + default: return "notRegistered" + } + } +} +#endif + +/// Deep links into System Settings' Privacy & Security panes. A granted +/// permission still deserves a way to the place it was granted, and re-running +/// the request prompt would open nothing. +enum SettingsRoute { + static let screenRecording = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + static let accessibility = "x-apple.systempreferences:com.apple.preference.security?Accessibility" +} + +enum AccessReport { + /// What the status menu calls itself. The bar item is only an icon, so a + /// person who clicks it without hovering the tooltip has nothing else + /// telling them which helper this menu belongs to. + static let heading = "Subshell virtual display helper" + + /// What the one consent item is called, in the panel and in the status menu + /// alike. The menu used to offer only "Disable helper", so a person who had + /// just disabled the helper found the way back was a settings window. + static func toggleTitle(enabled: Bool) -> String { + return enabled ? "Disable helper" : "Enable helper" + } + + /// The tooltip on the status item: honest about who is doing what. + static func tooltip(enabled: Bool, viewers: Int) -> String { + if viewers > 0 { + return "Subshell is viewing a virtual display on this Mac" + + (viewers == 1 ? "" : " (\(viewers) viewers)") + } + return enabled ? "Subshell virtual display helper enabled" : "Subshell virtual display helper disabled" + } +} + +/// The quit confirmation, worded once for both doors. The panel's Quit and the +/// status menu's Quit ask the same question, because the consequence (everyone +/// watching loses the screen now) belongs to the act, not to which control +/// started it. `packages/helper-panel/src/strings.ts` carries the originals the +/// panel renders; the helper-panel suite fails if these drift from them. +enum QuitCopy { + static let title = "Quit the display helper?" + static let confirm = "Quit helper" + static let cancel = "Cancel" + + /// The body reads the registration state rather than assuming one: enabling + /// never registered a login item and disabling never removes one, so + /// promising a return at sign-in to someone who never asked for it would be + /// a warning that misleads instead of informing. The three sentences match + /// `quitBody` in the panel's `strings.ts`, which the helper-panel suite + /// compares these against. + // One whole literal per reachable pair rather than a shared prefix, so the + // parity test can compare sentences between the two files. Consent is part + // of the answer because a registered helper that is switched off does come + // back at sign-in and then exits without capturing anything: telling that + // person "it starts again at sign-in" describes a return that delivers no + // screen, and sending them to Stop at sign-in to prevent it asks them to + // fix something that is not broken. + static func body(loginItem: String, consented: Bool) -> String { + switch (loginItem, consented) { + case ("registered", true): + return "Quitting disconnects viewers and stops capture and input. Your subshells keep running. Your helper settings and macOS permissions don't change. The helper starts again at sign-in, or you can reopen it sooner from Subshell's virtual display settings. To turn off automatic startup, cancel and choose Stop at sign-in in the panel." + case ("registered", false): + return "Quitting disconnects viewers and stops capture and input. Your subshells keep running. Your helper settings and macOS permissions don't change. The helper is disabled, so it exits without capturing anything when macOS starts it at sign-in. To remove it from macOS's login list, cancel and choose Stop at sign-in in the panel." + case ("needsApproval", _): + return "Quitting disconnects viewers and stops capture and input. Your subshells keep running. Your helper settings and macOS permissions don't change. It won't start at sign-in until you approve it in macOS. You can reopen it from Subshell's virtual display settings." + default: + return "Quitting disconnects viewers and stops capture and input. Your subshells keep running. Your helper settings and macOS permissions don't change. It won't start at sign-in. You can reopen it from Subshell's virtual display settings." + } + } +} diff --git a/native/display-helper/build_support.rs b/native/display-helper/build_support.rs index 4298074a6..e4fbbb287 100644 --- a/native/display-helper/build_support.rs +++ b/native/display-helper/build_support.rs @@ -1,7 +1,303 @@ //! Included by both desktop build scripts. Building never launches the helper. -use std::path::PathBuf; +//! +//! Staging is CONTENT-GATED on purpose. `tauri dev` watches the whole +//! `src-tauri` directory, and the staged bundle lives inside it +//! (`src-tauri/resources/Subshell Display Helper.app`), so a build script that +//! rewrites those files on every pass triggers its own watcher forever: one +//! edit under `native/display-helper` used to livelock `bun run +//! dev:desktop-client` in "File … changed. Rebuilding application…" loops. +//! The fingerprint (kept in `OUT_DIR`, outside the watched tree) plus +//! write-only-if-different copies make a rebuild touch the bundle exactly when +//! its bytes would change, so the loop settles after the one real update. +use std::path::{Path, PathBuf}; use std::process::Command; +const BUNDLE_NAME: &str = "Subshell Display Helper.app"; +const HELPER_ID: &str = "dev.subshell.display-helper"; + +/// The deployment target every helper build is compiled against. +const DEPLOYMENT_TARGET: &str = "macos14.0"; + +/// The compiler switches, in one place so {@link flag_parts} can cover them. +/// A flag is as much a part of the bundle's content as a source byte is: a +/// stamp that predates a change to one would otherwise keep shipping the old +/// binary on every machine that has built once, while a fresh clone gets the +/// new one, which is the drift the content gating exists to prevent. +const COMPILE_FLAGS: &[&str] = &["-O"]; + +/// The hardened-runtime switches, added only when there is a real identity to +/// sign with (an ad-hoc seal cannot survive them, and dev builds must not +/// depend on a timestamp authority being reachable). +const SIGN_FLAGS: &[&str] = &["--options", "runtime", "--timestamp"]; + +/// The `swiftc` invocation. Pure, so the test can prove the fingerprint covers +/// every flag it passes. +fn swiftc_argv(architecture: &str, sources: &[PathBuf], out: &Path) -> Vec { + let mut argv = vec!["swiftc".to_string()]; + argv.extend(COMPILE_FLAGS.iter().map(|flag| (*flag).to_string())); + argv.push("-target".to_string()); + argv.push(format!("{architecture}-apple-{DEPLOYMENT_TARGET}")); + argv.extend(sources.iter().map(|path| path.display().to_string())); + argv.push("-o".to_string()); + argv.push(out.display().to_string()); + argv +} + +/// The `codesign` invocation. Pure, for the same reason. +fn codesign_argv(identity: &str, keychain: &str, bundle: &Path) -> Vec { + let mut argv = vec![ + "--force".to_string(), + "--sign".to_string(), + identity.to_string(), + "--identifier".to_string(), + HELPER_ID.to_string(), + ]; + if identity != "-" { + argv.extend(SIGN_FLAGS.iter().map(|flag| (*flag).to_string())); + if !keychain.is_empty() { + argv.push("--keychain".to_string()); + argv.push(keychain.to_string()); + } + } + argv.push(bundle.display().to_string()); + argv +} + +/// The flags and the helper's own identity, as fingerprint input. +fn flag_parts() -> Vec { + let mut line = COMPILE_FLAGS.join(" "); + line.push_str(" -target "); + line.push_str(DEPLOYMENT_TARGET); + line.push(' '); + line.push_str(&SIGN_FLAGS.join(" ")); + line.push_str(" --identifier "); + line.push_str(HELPER_ID); + line.into_bytes() +} + +/// The version lines out of `swiftc --version`, and nothing else. +/// +/// The probe also prints a `Target:` line and, on some toolchains, paths. +/// Keeping only what names the compiler makes this stable across runs on one +/// machine, which the stamp cache needs, and different across the upgrades that +/// change the bytes it produces. +fn toolchain_version_lines(output: &str) -> String { + output + .lines() + .filter(|line| line.contains("Apple Swift version") || line.contains("swift-driver version")) + .map(str::trim) + .collect::>() + .join("\n") +} + +/// The compiler that produced the bundle, as fingerprint input. +/// +/// A flag is as much a part of the bundle's content as a source byte, and so is +/// the toolchain: an Xcode or Command Line Tools upgrade leaves every other input +/// untouched while changing what `swiftc` emits, so a stamp blind to it keeps a +/// developer's machine running the previous compiler's binary indefinitely. A +/// probe that cannot run is its own stable value rather than a panic: the compile +/// below is where a missing toolchain earns its error, with the compiler's words. +fn toolchain_part() -> Vec { + let probed = Command::new("/usr/bin/xcrun") + .args(["swiftc", "--version"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default(); + match toolchain_version_lines(&probed) { + lines if lines.is_empty() => b"toolchain:unavailable".to_vec(), + lines => format!("toolchain:{lines}").into_bytes(), + } +} + +/// FNV-1a over every byte that decides the bundle's content. Cheap, stable, +/// and dependency-free; a mismatch costs one rebuild, never correctness. +fn fingerprint(parts: &[&[u8]]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for part in parts { + for byte in part.iter().chain([0xff].iter()) { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + } + hash +} + +fn read_parts(sources: &[PathBuf], plist: &Path, architecture: &str, identity: &str, keychain: &str) -> Vec> { + let mut parts: Vec> = vec![ + architecture.as_bytes().to_vec(), + identity.as_bytes().to_vec(), + keychain.as_bytes().to_vec(), + ]; + for path in sources { + parts.push( + path.file_name() + .expect("named source file") + .to_string_lossy() + .into_owned() + .into_bytes(), + ); + parts.push(std::fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))); + } + parts.push(std::fs::read(plist).unwrap_or_else(|error| panic!("read {}: {error}", plist.display()))); + parts +} + +/// Where vite puts the panel page, relative to the workspace root. +fn panel_dist(root: &Path) -> PathBuf { + root.join("packages/helper-panel/dist") +} + +/// Every regular file under `dir`, sorted by its path relative to `dir`, so +/// the fingerprint and the copy do not depend on the order the OS hands out +/// directory entries. +fn tree_files(dir: &Path) -> Vec { + let mut found: Vec = Vec::new(); + let mut stack: Vec = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + // A symlinked or nested directory is not something this bundle + // should carry; only real files inside the tree are copied. + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() { + found.push(path); + } + } + } + found.sort(); + found +} + +/// What a debug bundle carries when the page was never built: a message that +/// names the command instead of a blank window. The release path refuses to +/// build at all, so this text can never reach a person who downloaded Subshell. +const PLACEHOLDER: &str = "\n\ +\n\ +\n\ +\n\ +Subshell virtual display helper\n\ +

The panel was not built. Run bunx turbo run build \ +--filter=@internal/helper-panel, then rebuild this helper.

\n"; + +/// The sentence every "no panel" path shares, so the fix is named in one place. +fn missing_panel_message(dist: &Path) -> String { + format!( + "{} has no index.html: build the display helper's panel before bundling it \ + (`bunx turbo run build --filter=@internal/helper-panel`).", + dist.display() + ) +} + +/// Every file of the panel's build, as `(relative path, bytes)` sorted by path. +/// +/// The error is a value rather than a panic so it is quotable in a test, and so +/// the release/debug split can decide what to do with it. +fn dist_files(dist: &Path) -> Result)>, String> { + if !dist.join("index.html").is_file() { + return Err(missing_panel_message(dist)); + } + Ok(tree_files(dist) + .into_iter() + .map(|path| { + let rel = path.strip_prefix(dist).expect("path under the panel dist"); + let bytes = std::fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + (rel.to_string_lossy().into_owned(), bytes) + }) + .collect()) +} + +/// What goes into `Contents/Resources/panel`. +/// +/// A release must not ship a helper whose consent page was never built: the +/// panel is the only place a person can say yes, and a bundle without it is a +/// dark window. A debug build carries the placeholder instead, because the CI +/// Rust lanes and a bare `cargo clippy` run with no `bun install` at all, and +/// failing them over a file no Rust test reads would trade a real guard for a +/// red lane nobody can act on. +fn panel_files(root: &Path, release: bool) -> Vec<(String, Vec)> { + let dist = panel_dist(root); + if let Ok(files) = dist_files(&dist) { + return files; + } + let reason = missing_panel_message(&dist); + assert!( + !release, + "{reason} A release must not ship a helper whose consent page was never built." + ); + println!("cargo:warning={reason}"); + vec![("index.html".to_string(), PLACEHOLDER.as_bytes().to_vec())] +} + +/// Make `to` hold exactly `files`, writing only what differs and removing what +/// the list no longer has. The removal matters: vite names its assets by +/// content hash, so appending would leave every bundle carrying the pages of +/// its predecessors, and the signature would cover all of them. +fn sync_panel(files: &[(String, Vec)], to: &Path) -> bool { + let mut wrote = false; + for (name, bytes) in files { + wrote = write_if_changed(bytes, &to.join(name)) || wrote; + } + for stale in tree_files(to) { + let rel = stale.strip_prefix(to).expect("path under the panel directory"); + if !files.iter().any(|(name, _)| Path::new(name) == rel) { + let _ = std::fs::remove_file(&stale); + wrote = true; + } + } + wrote +} + +/// Write `wanted` to `to` only when the bytes differ, so an unchanged rebuild +/// leaves no new mtime for the dev watcher to chase. Returns whether it wrote. +fn write_if_changed(wanted: &[u8], to: &Path) -> bool { + if std::fs::read(to).is_ok_and(|existing| existing == wanted) { + return false; + } + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent).unwrap_or_else(|error| panic!("create {}: {error}", parent.display())); + } + std::fs::write(to, wanted).unwrap_or_else(|error| panic!("write {}: {error}", to.display())); + true +} + +/// The same, from a file on disk. +fn copy_if_changed(from: &Path, to: &Path) -> bool { + let wanted = std::fs::read(from).unwrap_or_else(|error| panic!("read {}: {error}", from.display())); + let wrote = write_if_changed(&wanted, to); + // Writing bytes creates a non-executable file on a fresh checkout. Keep + // the compiler's permissions, including when repairing an older stage. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::metadata(from).expect("source metadata").permissions(); + if std::fs::metadata(to).expect("target metadata").permissions().mode() != permissions.mode() { + std::fs::set_permissions(to, permissions).expect("preserve staged permissions"); + return true; + } + } + wrote +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + path.is_file() +} + pub fn build() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { return; @@ -10,50 +306,368 @@ pub fn build() { let root = manifest.ancestors().nth(4).expect("desktop workspace root"); let source = root.join("native/display-helper"); println!("cargo:rerun-if-changed={}", source.display()); + // The page is part of the bundle, so a panel rebuild has to re-run this + // script. Cargo watches a directory recursively, and the fingerprint below + // carries the bytes anyway. + let dist = panel_dist(root); + println!("cargo:rerun-if-changed={}", dist.display()); println!("cargo:rerun-if-env-changed=APPLE_SIGNING_IDENTITY"); println!("cargo:rerun-if-env-changed=APPLE_SIGNING_KEYCHAIN"); - let bundle = manifest.join("resources/Subshell Display Helper.app"); - let contents = bundle.join("Contents"); - std::fs::create_dir_all(contents.join("MacOS")).expect("helper bundle directory"); - std::fs::copy(source.join("Info.plist"), contents.join("Info.plist")).expect("helper Info.plist"); + let bundle = manifest.join("resources").join(BUNDLE_NAME); let architecture = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() { Ok("aarch64") => "arm64", Ok("x86_64") => "x86_64", _ => panic!("unsupported display helper architecture"), }; + let identity = std::env::var("APPLE_SIGNING_IDENTITY").unwrap_or_else(|_| "-".into()); + let keychain = std::env::var("APPLE_SIGNING_KEYCHAIN").unwrap_or_default(); let mut sources: Vec<_> = std::fs::read_dir(&source) .expect("helper sources") .map(|entry| entry.expect("helper source entry").path()) .filter(|path| path.extension().is_some_and(|extension| extension == "swift")) .collect(); sources.sort(); + let release = std::env::var("PROFILE").as_deref() == Ok("release"); + // The page is bundle content, so its bytes decide whether this pass has to + // touch anything. Read once, then carried into both the fingerprint and the + // copy, which is what keeps the two from disagreeing. + let panel = panel_files(root, release); + let mut parts = read_parts(&sources, &source.join("Info.plist"), architecture, &identity, &keychain); + parts.push(flag_parts()); + parts.push(toolchain_part()); + for (name, bytes) in &panel { + parts.push(name.clone().into_bytes()); + parts.push(bytes.clone()); + } + let bytes: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect(); + let stamp_value = fingerprint(&bytes).to_string(); + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); + let stamp = out_dir.join("display-helper.stamp"); + let staged = bundle.join("Contents/MacOS/subshell-display-helper"); + let staged_plist = bundle.join("Contents/Info.plist"); + let bundled_panel = bundle.join("Contents/Resources/panel"); + if std::fs::read_to_string(&stamp).is_ok_and(|stored| stored == stamp_value) + && is_executable(&staged) + && staged_plist.is_file() + && bundled_panel.join("index.html").is_file() + // ...and the seal it was signed with. An outside hand (a sync tool, a + // cleanup, a crash between copying and stamping) can leave the bundle + // otherwise complete but unsigned, and every later pass would call that + // current because the bytes still match. + && bundle.join("Contents/_CodeSignature").is_dir() + { + // Nothing about the bundle changed since the last pass: touch no file, + // which is what keeps `tauri dev` from rebuilding itself forever. + return; + } + + // Build and sign in OUT_DIR (outside the watched tree), then sync the + // bytes in. Signing a half-written bundle in place would leave the + // watcher seeing a valid signature over old contents for one instant. + let stage = out_dir.join(BUNDLE_NAME); + let stage_contents = stage.join("Contents"); + if stage.exists() { + std::fs::remove_dir_all(&stage).expect("clear staged helper"); + } + std::fs::create_dir_all(stage_contents.join("MacOS")).expect("staged helper directory"); + std::fs::copy(source.join("Info.plist"), stage_contents.join("Info.plist")).expect("staged helper Info.plist"); + let compiler = swiftc_argv( + architecture, + &sources, + &stage_contents.join("MacOS/subshell-display-helper"), + ); let status = Command::new("/usr/bin/xcrun") - .args(["swiftc", "-O", "-target", &format!("{architecture}-apple-macos14.0")]) - .args(sources) - .arg("-o") - .arg(contents.join("MacOS/subshell-display-helper")) + .args(&compiler) .status() .expect("start Swift compiler"); assert!(status.success(), "display helper compilation failed"); - let identity = std::env::var("APPLE_SIGNING_IDENTITY").unwrap_or_else(|_| "-".into()); - assert!(std::env::var("PROFILE").as_deref() != Ok("release") || identity != "-", + // Carried into the stage BEFORE signing, so the signature covers the page + // and a swapped asset fails `codesign --verify` the way a swapped binary does. + sync_panel(&panel, &stage_contents.join("Resources/panel")); + assert!(!release || identity != "-", "release display helpers require APPLE_SIGNING_IDENTITY; ad-hoc signatures cannot preserve macOS permission grants"); - let mut sign = Command::new("/usr/bin/codesign"); - sign.args([ - "--force", - "--sign", - &identity, - "--identifier", - "dev.subshell.display-helper", - ]); - if identity != "-" { - sign.args(["--options", "runtime", "--timestamp"]); - if let Ok(keychain) = std::env::var("APPLE_SIGNING_KEYCHAIN") { - sign.args(["--keychain", &keychain]); - } - } + let signer = codesign_argv(&identity, &keychain, &stage); assert!( - sign.arg(&bundle).status().expect("sign display helper").success(), + Command::new("/usr/bin/codesign") + .args(&signer) + .status() + .expect("sign display helper") + .success(), "display helper signing failed" ); + + std::fs::create_dir_all(bundle.join("Contents/MacOS")).expect("helper bundle directory"); + let mut wrote = copy_if_changed(&stage_contents.join("Info.plist"), &staged_plist); + wrote = copy_if_changed(&stage_contents.join("MacOS/subshell-display-helper"), &staged) || wrote; + for entry in std::fs::read_dir(stage_contents.join("_CodeSignature")).expect("staged code signature") { + let path = entry.expect("signature entry").path(); + wrote = copy_if_changed( + &path, + &bundle + .join("Contents/_CodeSignature") + .join(path.file_name().expect("named")), + ) || wrote; + } + // A previous staging pass may have left a stale file (a renamed source, an + // older signature layout); drop anything the current bundle does not have. + if let Ok(entries) = std::fs::read_dir(bundle.join("Contents/_CodeSignature")) { + for entry in entries.flatten() { + if !stage_contents.join("_CodeSignature").join(entry.file_name()).is_file() { + let _ = std::fs::remove_file(entry.path()); + } + } + } + // The page into the bundle, from the same bytes that were signed a moment + // ago. Per file rather than wholesale, so an unchanged rebuild stamps no + // mtime for `tauri dev` to chase. + sync_panel(&panel, &bundled_panel); + // Record the fingerprint whether or not anything was copied, so the next + // pass short-circuits before the swiftc call. + std::fs::write(&stamp, stamp_value).expect("write helper stamp"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_fingerprint_covers_every_flag_the_commands_actually_pass() { + // The drift this guards: someone adds a switch to an argv builder and + // forgets the fingerprint, so machines that already built once keep the + // old bundle while a fresh clone gets the new one. + let covered = String::from_utf8(flag_parts()).expect("utf-8 flags"); + let argv = swiftc_argv("arm64", &[PathBuf::from("a.swift")], &PathBuf::from("out")); + for token in &["-O", "-target", DEPLOYMENT_TARGET, "swiftc"] { + assert!(argv.iter().any(|arg| arg.contains(token)), "swiftc passes {token}"); + } + for flag in COMPILE_FLAGS + .iter() + .chain(["-target"].iter()) + .chain([DEPLOYMENT_TARGET].iter()) + { + assert!(covered.contains(flag), "the fingerprint omits the compiler flag {flag}"); + } + let adhoc = codesign_argv("-", "", &PathBuf::from("bundle")); + let real = codesign_argv("Developer ID Application: X", "", &PathBuf::from("bundle")); + assert!( + !adhoc.iter().any(|arg| arg == "--options"), + "an ad-hoc seal gets no hardened runtime" + ); + for flag in SIGN_FLAGS { + assert!(real.iter().any(|arg| arg == *flag), "a real identity passes {flag}"); + assert!(covered.contains(flag), "the fingerprint omits the sign flag {flag}"); + } + assert!( + covered.contains(HELPER_ID), + "the fingerprint omits the bundle identifier" + ); + // The other direction, which is the half that bites: a switch added to + // a builder without a name in the constants above changes what lands + // in the bundle while the fingerprint, and so the replacement check, + // stays put. Every dash-lead token either command passes must appear in + // one of the declared lists. + let mut declared: Vec = COMPILE_FLAGS.iter().map(|flag| (*flag).to_string()).collect(); + declared.extend(SIGN_FLAGS.iter().map(|flag| (*flag).to_string())); + declared.extend(["-target", "-o", "--force", "--sign", "--identifier", "--keychain"].map(str::to_string)); + for probe in [ + argv, + codesign_argv("Developer ID Application: X", "keychain", &PathBuf::from("bundle")), + ] { + for token in probe { + if token.starts_with('-') { + assert!( + declared.contains(&token), + "an undeclared flag {token} reaches the bundle without reaching the fingerprint" + ); + } + } + } + } + + #[test] + fn the_fingerprint_sees_the_compiler_that_produced_the_bundle() { + // The one input above that no source byte carries: an Xcode or Command + // Line Tools upgrade changes what `swiftc` emits while every file, flag, + // and page stays exactly as it was, and a stamp blind to it lets a + // developer's machine keep running the previous compiler's binary. + let base = fingerprint(&[b"Consent.swift", b"toolchain:Apple Swift version 6.0"]); + assert_eq!( + base, + fingerprint(&[b"Consent.swift", b"toolchain:Apple Swift version 6.0"]) + ); + assert_ne!( + base, + fingerprint(&[b"Consent.swift", b"toolchain:Apple Swift version 6.2"]) + ); + // What is kept is the compiler's name and version only, so a probe that + // also prints a target triple still agrees with the run before it. + let verbose = "swift-driver version 1.127.18\nApple Swift version 6.2 (swiftlang-6.2.0.1.2)\nTarget: arm64-apple-macosx14.0\n"; + assert_eq!( + toolchain_version_lines(verbose), + "swift-driver version 1.127.18\nApple Swift version 6.2 (swiftlang-6.2.0.1.2)" + ); + assert!(toolchain_version_lines(verbose).contains("Swift version 6.2")); + // An unreadable probe is empty rather than a crash in the build script. + assert_eq!(toolchain_version_lines(""), ""); + assert_eq!(toolchain_version_lines("xcrun: error: unable to find utility"), ""); + } + + #[test] + fn the_fingerprint_follows_every_input_byte_and_their_order() { + let base = fingerprint(&[b"Consent.swift", b"let x = 1"]); + assert_eq!(base, fingerprint(&[b"Consent.swift", b"let x = 1"])); + assert_ne!(base, fingerprint(&[b"Consent.swift", b"let x = 2"])); + assert_ne!(base, fingerprint(&[b"Settings.swift", b"let x = 1"])); + assert_ne!(base, fingerprint(&[b"let x = 1", b"Consent.swift"])); + } + + #[test] + fn an_unchanged_rebuild_touches_nothing_and_a_changed_one_writes() { + let directory = std::env::temp_dir().join(format!("subshell-helper-stage-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let from = directory.join("from"); + let to = directory.join("to"); + std::fs::write(&from, b"v1").unwrap(); + assert!(copy_if_changed(&from, &to), "a missing target must be written"); + assert!( + !copy_if_changed(&from, &to), + "identical bytes must not rewrite the file" + ); + let before = std::fs::metadata(&to).unwrap().modified().unwrap(); + std::fs::write(&from, b"v2-longer").unwrap(); + assert!(copy_if_changed(&from, &to)); + assert_eq!(std::fs::read(&to).unwrap(), b"v2-longer"); + assert!(std::fs::metadata(&to).unwrap().modified().unwrap() > before); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn staging_preserves_and_repairs_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + let directory = scratch("executable-mode"); + let from = directory.join("compiled"); + let to = directory.join("bundled"); + std::fs::write(&from, b"#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&from, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(copy_if_changed(&from, &to)); + assert!(Command::new(&to).status().unwrap().success()); + std::fs::set_permissions(&to, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!( + copy_if_changed(&from, &to), + "unchanged bytes still need their mode repaired" + ); + assert!(Command::new(&to).status().unwrap().success()); + assert!(!copy_if_changed(&from, &to)); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn scratch(name: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!("subshell-helper-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + directory + } + + fn write(path: &Path, bytes: &[u8]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn dist_files_lists_the_built_page_and_refuses_an_absent_build_by_command() { + let empty = scratch("dist-empty"); + let error = dist_files(&empty).expect_err("a dist with no page must not pass"); + assert!( + error.contains("bunx turbo run build --filter=@internal/helper-panel"), + "{error}" + ); + + let dist = scratch("dist-built"); + write(&dist.join("index.html"), b""); + write(&dist.join("assets/index-abc.js"), b"js"); + write(&dist.join("assets/index-abc.css"), b"css"); + let files = dist_files(&dist).unwrap(); + let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["assets/index-abc.css", "assets/index-abc.js", "index.html"]); + assert_eq!(files[2].1, b""); + std::fs::remove_dir_all(empty).unwrap(); + std::fs::remove_dir_all(dist).unwrap(); + } + + #[test] + fn a_debug_bundle_carries_a_placeholder_that_names_the_build_and_a_release_refuses() { + // The natural reading of "the bundle has no panel" is that someone + // deleted it, so both paths have to say what to run instead. + let root = scratch("panel-missing"); + let files = panel_files(&root, false); + assert_eq!(files.len(), 1); + assert_eq!(files[0].0, "index.html"); + let page = String::from_utf8(files[0].1.clone()).expect("utf-8 placeholder"); + assert!( + page.contains("bunx turbo run build --filter=@internal/helper-panel"), + "{page}" + ); + + // The release path refuses rather than placeholdering. Quietly, and + // with the directory removed, because a panic that prints a stack and + // leaves a temp tree behind is noise a looped run accumulates. + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let refused = std::panic::catch_unwind(|| panel_files(&root, true)); + std::panic::set_hook(hook); + assert!( + refused.is_err(), + "a release must not ship a helper whose consent page was never built" + ); + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn sync_panel_replaces_stale_assets_and_writes_nothing_when_already_current() { + let to = scratch("sync-to"); + let v1: Vec<(String, Vec)> = vec![ + ("index.html".into(), b"v1".to_vec()), + ("assets/new.js".into(), b"js".to_vec()), + ]; + // A previous vite pass emitted a differently hashed file; the bundle + // must not accumulate the pages of its predecessors. + write(&to.join("assets/old.js"), b"stale"); + + assert!(sync_panel(&v1, &to), "new files must be written"); + assert!(!to.join("assets/old.js").exists(), "the stale hashed asset is pruned"); + assert_eq!(std::fs::read(to.join("index.html")).unwrap(), b"v1"); + assert!( + !sync_panel(&v1, &to), + "an identical set must leave no new mtime for the dev watcher" + ); + + let v2: Vec<(String, Vec)> = vec![("index.html".into(), b"v2".to_vec())]; + assert!(sync_panel(&v2, &to)); + assert_eq!(std::fs::read(to.join("index.html")).unwrap(), b"v2"); + assert!(!to.join("assets/new.js").exists(), "a dropped asset leaves with it"); + std::fs::remove_dir_all(to).unwrap(); + } + + #[test] + fn read_parts_carries_sources_plist_arch_and_identity() { + let directory = std::env::temp_dir().join(format!("subshell-helper-parts-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let source = directory.join("Consent.swift"); + let plist = directory.join("Info.plist"); + std::fs::write(&source, b"final class HelperConsent {}").unwrap(); + std::fs::write(&plist, b"").unwrap(); + let first = read_parts(std::slice::from_ref(&source), &plist, "arm64", "-", ""); + let second = read_parts(std::slice::from_ref(&source), &plist, "x86_64", "-", ""); + assert_ne!( + fingerprint(&first.iter().map(Vec::as_slice).collect::>()), + fingerprint(&second.iter().map(Vec::as_slice).collect::>()) + ); + let signed = read_parts(&[source], &plist, "arm64", "Developer ID Application: X", ""); + assert_ne!( + fingerprint(&first.iter().map(Vec::as_slice).collect::>()), + fingerprint(&signed.iter().map(Vec::as_slice).collect::>()) + ); + std::fs::remove_dir_all(directory).unwrap(); + } } diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift index f45e242a9..251a2a0be 100644 --- a/native/display-helper/main.swift +++ b/native/display-helper/main.swift @@ -5,10 +5,19 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate { private let consent = HelperConsent() private lazy var runtime = HelperRuntime(consent: consent) private lazy var settings = HelperSettings(consent: consent, - enable: { [weak self] in try self?.runtime.enable() }, disable: { [weak self] in self?.runtime.disable() }) + enable: { [weak self] in try self?.runtime.enable() }, disable: { [weak self] in self?.runtime.disable() }, + accessCount: { [weak self] in self?.runtime.viewers ?? 0 }) private var statusItem: NSStatusItem? private var installLock: Int32 = -1 private var started = false + /// macOS naps a background app whose windows are not in front, and a napped + /// app's timers are coalesced to minutes. This helper's job includes + /// reporting consent and permission state truthfully while the person is + /// sitting in System Settings, so it stays awake enough to poll. + private let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiatedAllowingIdleSystemSleep], + reason: "Keeping the helper's consent and permission state current" + ) func applicationDidFinishLaunching(_ notification: Notification) { // Hold the install lock even while disabled: another desktop app must @@ -22,18 +31,56 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate { NSApp.terminate(nil); return } } catch { NSApp.terminate(nil); return } + // A viewer's pointer is the real cursor, so the screen it was parked on + // can go away while this process is not running: the docs promise an + // existing virtual screen may outlive the helper, and whoever closes the + // last pane over it cannot ask a dead socket to warp the cursor back. Any + // start heals that here rather than leaving the person at the Mac without + // a pointer, which is why this runs BEFORE the bind. A start whose socket + // cannot come up exits on the next line, and `applicationWillTerminate`'s + // rescue is gated on `started`, so healing after the bind would mean the + // one kind of failed launch leaves the pointer stranded while the docs say + // every start fixes it. Warping a cursor that is inside no screen's frame + // is harmless, so a launch that does not survive loses nothing. // Neither install nor a node request can opt in. Login resumes only // the consent the person previously gave in this native panel. - do { try runtime.listenForStatus() } catch { NSApp.terminate(nil); return } + do { + try launchHeal(restore: { restoreStrandedCursor() }, bind: { try runtime.listenForStatus() }) + } catch { + NSApp.terminate(nil); return + } started = true runtime.onStateChange = { [weak self] in self?.updateMenu() } + settings.onVisibilityChange = { [weak self] in self?.updateMenu() } if consent.enabled { do { try runtime.enable() } - catch { runtime.disable() } + // Two wrong answers, and one right one. `disable()` clears consent + // before it closes anything, so a transient failure here would + // silently un-consent a Mac whose person never changed their mind. + // Staying alive is wrong the other way: with the listener down the + // menu and the panel both read "Enabled" over a machine capturing + // nothing, and this panel exists to be truthful about capture. Leaving + // is the same answer the socket gives two pages up, and it keeps the + // agreement. Unreachable today, because `listenForStatus()` has already + // bound and `Listener.start()` returns early when a descriptor exists: + // it is here so that whoever adds a throwing step to `enable()` finds a + // honest failure, not a lying menu. + catch { NSApp.terminate(nil); return } + } + // Who is allowed to open the panel decides whether this run stays. + let opensPanel = CommandLine.arguments.contains("--settings") + if !opensPanel && !consent.enabled { + // The sign-in case the docs describe as exiting at once: a helper left + // registered after the person switched it off comes back, finds no + // consent, and leaves. Drawing the menu-bar icon first would flash a + // display glyph on a Mac that is capturing nothing, which reads as a + // watcher that is not there, so the icon is installed only for a run + // that stays. + NSApp.terminate(nil) + return } updateMenu() - if CommandLine.arguments.contains("--settings") { settings.show() } - else if !consent.enabled { NSApp.terminate(nil) } + if opensPanel { settings.show() } } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -45,30 +92,110 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate { // resume it. Closing the listener tears down every capture and key. guard started else { return } runtime.shutdown() + // A viewer may have left the pointer on a virtual display this quit + // ends; do not hand the person at the Mac a missing cursor. + restoreStrandedCursor() } private func updateMenu() { if statusItem == nil { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem?.button?.image = NSImage(systemSymbolName: "display", accessibilityDescription: "Subshell virtual display helper") } + let viewers = runtime.viewers let menu = NSMenu() menu.autoenablesItems = false - let settingsItem = NSMenuItem(title: "Virtual Display Helper Settings…", action: #selector(showSettings), keyEquivalent: "") + // The item in the bar is an icon. Opening the menu without a word about + // what it belongs to left people guessing, so the menu says its name. + let heading = NSMenuItem(title: AccessReport.heading, action: nil, keyEquivalent: "") + heading.isEnabled = false; menu.addItem(heading) + menu.addItem(.separator()) + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: "") settingsItem.target = self; menu.addItem(settingsItem) - let disable = NSMenuItem(title: "Disable Helper", action: #selector(disableHelper), keyEquivalent: "") - disable.target = self; disable.isEnabled = consent.enabled; menu.addItem(disable) + let minimize = NSMenuItem(title: "Minimize helper", action: #selector(minimizeSettings), keyEquivalent: "m") + minimize.target = self; minimize.isEnabled = settings.isShowing; menu.addItem(minimize) + // One item, named for the direction it goes. The menu used to carry + // only "Disable helper", greyed once consent was off, which left a + // person who had just disabled the helper with a settings window as the + // only way back to running. + let toggle = NSMenuItem(title: AccessReport.toggleTitle(enabled: consent.enabled), action: #selector(toggleHelper), keyEquivalent: "") + toggle.target = self; menu.addItem(toggle) menu.addItem(.separator()) - let quit = NSMenuItem(title: "Quit Helper", action: #selector(quitHelper), keyEquivalent: "") + let quit = NSMenuItem(title: "Quit helper", action: #selector(quitHelper), keyEquivalent: "") quit.target = self; menu.addItem(quit) statusItem?.menu = menu - statusItem?.button?.toolTip = consent.enabled ? "Subshell display helper enabled" : "Subshell display helper disabled" + statusItem?.button?.toolTip = AccessReport.tooltip(enabled: consent.enabled, viewers: viewers) + // Someone is watching a screen: the icon takes the warning gold and + // gains a dot, so the awareness survives a click elsewhere on the + // menu bar and does not depend on reading the tooltip. + statusItem?.button?.contentTintColor = viewers > 0 ? Theme.warning : nil + statusItem?.button?.attributedTitle = viewers > 0 + ? NSAttributedString(string: " ●", attributes: [ + .foregroundColor: Theme.warning, + .font: NSFont.systemFont(ofSize: 9), + ]) + : NSAttributedString(string: "") settings.refresh() } @objc private func showSettings() { settings.show() } - @objc private func disableHelper() { settings.disableHelper(); settings.show() } - @objc private func quitHelper() { settings.disableHelper(); NSApp.terminate(nil) } + @objc private func minimizeSettings() { settings.minimize() } + /// Enable or disable from the menu, then raise the panel: the consent + /// sentence and the state belong where the person can read what they just + /// agreed to, and a failure has to land on the thing that failed. + @objc private func toggleHelper() { + if consent.enabled { + settings.disableHelper() + } else { + settings.enableHelper() + } + settings.show() + } + /// Whether the quit confirmation is on screen, so a second menu press + /// cannot stack a duplicate question. + private var quitAlertShown = false + + /// The menu's Quit asks the same question the panel asks, because the + /// consequence lives in the act rather than in the control. An accessory + /// app has no window to host a sheet while the panel is closed, so this is + /// a native alert; Cancel is the button Return presses, the way the panel + /// focuses the way out rather than the act. `applicationWillTerminate` + /// closes every capture, and leaving consent alone is deliberate: a quit is + /// not a revocation. + @objc private func quitHelper() { + // The status menu stays clickable through an app-modal session, so a + // second press would nest another identical alert: the person answers + // once, and a second question is still waiting. One at a time. + if quitAlertShown { + // The proven form, not the no-argument `activate()`. A re-press has + // to drag the pending alert over whatever app the person is in now, + // which is exactly the insistence the plain call does not make. + // test.sh typechecks main.swift with the release flags; the desktop + // build also compiles every Swift source through build_support.rs. + NSApp.activate(ignoringOtherApps: true) + return + } + quitAlertShown = true + defer { quitAlertShown = false } + let alert = NSAlert() + alert.messageText = QuitCopy.title + alert.informativeText = QuitCopy.body(loginItem: LoginItem.currentPageState(), consented: consent.enabled) + alert.alertStyle = .warning + // Added first, so Return and the rightmost position land on the way out. + alert.addButton(withTitle: QuitCopy.cancel) + let confirm = alert.addButton(withTitle: QuitCopy.confirm) + confirm.hasDestructiveAction = true + // An accessory app has no key window to hang the alert on, so it comes + // forward first, and it has to come forward over whatever app the person + // was in: the menu is still clickable through an app-modal session, so an + // alert left behind another window reads as a menu that does nothing. + // + // Keep the activation form exercised by Settings.swift and this file's + // whole-app typecheck in test.sh. + NSApp.activate(ignoringOtherApps: true) + if alert.runModal() == .alertFirstButtonReturn { return } + NSApp.terminate(nil) + } } umask(0o077) diff --git a/native/display-helper/test.sh b/native/display-helper/test.sh index c7890461c..69334e8a1 100755 --- a/native/display-helper/test.sh +++ b/native/display-helper/test.sh @@ -12,4 +12,15 @@ for source in "$source_dir"/*.swift; do [[ "$(basename "$source")" == main.swift ]] || sources+=("$source") done xcrun swiftc -warnings-as-errors -target "$(uname -m)-apple-macos14.0" "${sources[@]}" "$source_dir/tests/HelperTests.swift" -o "$build_dir/tests" +# The test build cannot include `main.swift`, so this typechecks the module the desktop +# build actually compiles: every source here, main.swift included, and no test file. It +# catches a type error in the app delegate on this job rather than waiting for a desktop +# build on another one. Flags match `build_support.rs`, which passes only `-O` plus a +# macos14.0 target and no `-warnings-as-errors`; `-typecheck` needs neither output nor +# optimization. +app_sources=() +for source in "$source_dir"/*.swift; do + app_sources+=("$source") +done +xcrun swiftc -typecheck -target "$(uname -m)-apple-macos14.0" "${app_sources[@]}" "$build_dir/tests" diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift index abbd4ca77..65fcd2694 100644 --- a/native/display-helper/tests/HelperTests.swift +++ b/native/display-helper/tests/HelperTests.swift @@ -1,4 +1,5 @@ import Foundation +import ServiceManagement import Darwin import CoreVideo @@ -31,7 +32,10 @@ func request(_ path: String, _ line: String) throws -> [String: Any]? { @main struct HelperTests { @MainActor static func main() async throws { - // No app bundle, login registration, capture, input or permission requests. + // No app bundle, login registration, capture, or permission requests, and + // no posted input events. The rescue-cursor case below does reach the + // pointer warp, which is a no-op unless the host's cursor is stranded in + // no screen's frame (then it is the fix, and the thing being asserted). // Only a private temporary socket and a disposable preference suite. let suite = "dev.subshell.helper-tests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -40,6 +44,82 @@ struct HelperTests { precondition(!consent.enabled) consent.setEnabled(true); precondition(HelperConsent(defaults: defaults).enabled) consent.setEnabled(false); precondition(!consent.enabled) + // The DEFAULT initializer must read the standard (own) domain. The + // released code handed `suiteName:` the app's own bundle id, which + // Foundation rejects, and the force-unwrap trapped in + // DisplayHelperApp.init before any panel could open. + UserDefaults.standard.removeObject(forKey: "enabled") + precondition(!HelperConsent().enabled, "A fresh helper starts without consent") + UserDefaults.standard.set(true, forKey: "enabled") + precondition(HelperConsent().enabled, "The default init must read the app's own domain") + UserDefaults.standard.removeObject(forKey: "enabled") + // The conversion, not the palette: these pins catch a broken oklch + // → sRGB here, and can see nothing about the shared file (they read + // Theme.tokens, the same source they check). The drift guard against + // packages/assistant/tokens.css is the helper-panel suite's palette + // test, which parses that file and compares the triples by name. + func close(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 0.02 } + let warning = Theme.srgb("warning") + precondition(close(warning.red, 1.0) && close(warning.green, 0.727) && close(warning.blue, 0.0), "warning token drifted") + let ground = Theme.srgb("background") + precondition(close(ground.red, 0.113) && close(ground.green, 0.094) && close(ground.blue, 0.165), "background token drifted") + let muted = Theme.srgb("muted-foreground") + precondition(close(muted.red, 0.700) && close(muted.green, 0.644) && close(muted.blue, 0.746), "muted-foreground token drifted") + // The awareness copy: a dot on the menu bar is only useful if the + // words say who is doing what, and the idle state must not claim + // anyone is watching. + precondition(AccessReport.tooltip(enabled: true, viewers: 2) == "Subshell is viewing a virtual display on this Mac (2 viewers)") + precondition(AccessReport.tooltip(enabled: true, viewers: 1) == "Subshell is viewing a virtual display on this Mac") + precondition(AccessReport.tooltip(enabled: false, viewers: 0) == "Subshell virtual display helper disabled") + // The granted rows' way into System Settings: real pane URLs, so a + // typo would open nothing and read as a dead button. + precondition(SettingsRoute.screenRecording == "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + precondition(SettingsRoute.accessibility == "x-apple.systempreferences:com.apple.preference.security?Accessibility") + // The login item's three macOS states stay three, because collapsing + // notRegistered into "approved" is what made a turned-off sign-in start + // still read "Starts at sign-in with macOS." + precondition(LoginItem.pageState(.enabled) == "registered") + precondition(LoginItem.pageState(.requiresApproval) == "needsApproval") + precondition(LoginItem.pageState(.notRegistered) == "notRegistered") + precondition(LoginItem.pageState(.notFound) == "notRegistered") + // The menu's one consent item flips with the state, in both directions. + precondition(AccessReport.toggleTitle(enabled: true) == "Disable helper") + precondition(AccessReport.toggleTitle(enabled: false) == "Enable helper") + // The bar item is an icon, so the menu it opens has to say what it is. + precondition(AccessReport.heading == "Subshell virtual display helper", "the status menu must name the helper") + // Both Quit doors ask, and the menu's copy is this. The panel's own + // strings are compared against it by the helper-panel suite. + precondition(QuitCopy.title.hasSuffix("?"), "a confirmation names the act as a question") + for state in ["registered", "needsApproval", "notRegistered"] { + for consented in [true, false] { + let body = QuitCopy.body(loginItem: state, consented: consented) + precondition(body.contains("disconnects viewers and stops capture and input"), "every variant says what stopping means") + precondition(body.contains("virtual display settings") || body.contains("login list"), + "every variant says how the helper comes back") + } + } + // The sentences that used to be shared: only a registered helper that is + // switched on comes back at sign-in, and only Stop at sign-in removes it. + precondition(QuitCopy.body(loginItem: "registered", consented: true).contains("starts again at sign-in")) + let registeredOff = QuitCopy.body(loginItem: "registered", consented: false) + precondition(registeredOff.contains("without capturing anything"), "a disabled start captures nothing") + precondition(registeredOff.contains("Stop at sign-in")) + precondition(!registeredOff.contains("starts again at sign-in")) + for state in ["notRegistered", "needsApproval"] { + for consented in [true, false] { + precondition(!QuitCopy.body(loginItem: state, consented: consented).contains("starts again at sign-in")) + } + } + precondition(QuitCopy.confirm != QuitCopy.cancel && !QuitCopy.cancel.isEmpty) + // The pointer rescue: a viewer's cursor lives on the virtual display, + // so losing that screen strands it off every display the person at + // this Mac can see. Only a point inside no frame counts as stranded. + let builtIn = CGRect(x: 0, y: 0, width: 1512, height: 982) + precondition(cursorIsStranded(point: CGPoint(x: 2400, y: 1800), screens: [builtIn]), "a point off every screen is stranded") + precondition(!cursorIsStranded(point: CGPoint(x: 100, y: 100), screens: [builtIn]), "a point on the built-in display is fine") + precondition(!cursorIsStranded(point: CGPoint(x: 1800, y: 1500), screens: [builtIn, CGRect(x: 1512, y: 982, width: 1280, height: 800)]), + "a point on any connected screen is fine") + precondition(cursorIsStranded(point: CGPoint(x: 0, y: 0), screens: []), "no screens means nowhere visible") precondition(DisplayInput.keyCode(symbol: 0x63) == 8) // Command-C precondition(DisplayInput.keyCode(symbol: 0x43) == 8) precondition(DisplayInput.keyCode(symbol: 0x01000063) == 8) @@ -67,6 +147,46 @@ struct HelperTests { } catch { precondition(stops == 2, "Stop must run again after asynchronous startup completes") } + // The navigation gate: the one rule between a document loaded from + // somewhere else and the button that starts a screen capture. It is + // checked here rather than assumed because its two halves are both easy + // to break by editing for looks: drop the standardization and an escaped + // path wears the bundle's prefix, drop the trailing separator and a + // sibling named `panel-evil` counts as inside. + // Built the way `directoryURL()` builds it in production, from the page's + // own URL. `URL(fileURLWithPath:isDirectory:)` would be the natural thing + // to reach for and is wrong here: it appends a trailing slash, so the gate + // would compare against `panel/` and `panel//`, every allow case would + // fail, and every refuse case would pass without the two guards doing any + // work. The precondition below turns that Foundation subtlety into a named + // failure instead of a confusing one. + let panelIndex = URL(fileURLWithPath: "/tmp/subshell-panel-test/Contents/Resources/panel/index.html") + let panel = panelIndex.deletingLastPathComponent() + let bundleRoot = panel.deletingLastPathComponent() + precondition(!panel.path.hasSuffix("/"), "the fixture must match directoryURL()'s shape") + func file(_ path: String) -> URL { URL(fileURLWithPath: path) } + precondition(PanelBundle.allows(panelIndex, within: panel), "the page itself is allowed") + precondition(PanelBundle.allows(file(panel.path), within: panel), "the bundle directory is allowed") + precondition(PanelBundle.allows(file(panel.path + "/assets/deep/x.js"), within: panel), "nested files are allowed") + precondition(!PanelBundle.allows(file("/etc/passwd"), within: panel), "outside the bundle is refused") + precondition( + !PanelBundle.allows(file(panel.path + "/../../Secrets/id"), within: panel), + "a path that escapes while wearing the prefix is refused") + precondition( + !PanelBundle.allows(file(bundleRoot.path + "/panel-evil/x.html"), within: panel), + "a sibling sharing the directory's name is refused") + // Scheme, not shape. Every other refusal here has a path that could not + // match the prefix anyway, so only a network URL WEARING the bundle's own + // path pins the `isFileURL` half of the guard: delete it and this is the + // case that goes green on a page fetched from anywhere. + precondition( + !PanelBundle.allows(URL(string: "https://plane.example.test\(panel.path)/x.html")!, within: panel), + "a network document is refused even wearing the bundle's path") + precondition(!PanelBundle.allows(nil, within: panel), "no url is no answer") + precondition(!PanelBundle.allows(file(panel.path + "/index.html"), within: nil), "no bundle directory allows nothing") + // And the real entry point, with no bundle around it at all: the same + // refusal a helper whose panel files went missing has to answer with. + precondition(!PanelBundle.allows(file(panel.path + "/index.html")), "a bundle-less binary allows nothing") // Synthetic pixels exercise the worker without screen-capture permission. var buffer: CVPixelBuffer? precondition(CVPixelBufferCreate(kCFAllocatorDefault, 320, 320, @@ -93,6 +213,17 @@ struct HelperTests { precondition(result == nil) precondition(!consent.enabled) } + // The two admitted operations are admissions, not mere exceptions to a + // refusal. Deleting `|| operation == "rescue-cursor"` from `Runtime.swift` + // leaves every assertion above green while silently breaking the one act a + // disabled helper must still perform: by the time the daemon asks, the + // display that stranded the pointer is already gone, and a helper that + // answers by closing the socket strands the person at the Mac with no + // cursor and no way to click anything. + let rescued = try await Task.detached { try request(path, #"{"op":"rescue-cursor"}"#) }.value + precondition(rescued?["type"] as? String == "rescued", "a disabled helper still returns the pointer") + precondition(rescued?["protocol"] as? Int == 1, "the rescue answer keeps the frame's protocol") + precondition(!consent.enabled, "rescuing the cursor must not grant consent") try runtime.enable() let enabled = try await Task.detached { try request(path, #"{"op":"status"}"#) }.value precondition(enabled?["enabled"] as? Bool == true) @@ -103,6 +234,36 @@ struct HelperTests { precondition(!consent.enabled) let final = try await Task.detached { try request(path, #"{"op":"status"}"#) }.value precondition(final?["enabled"] as? Bool == false) + // The launch heal's contract is order, and `main.swift` is outside this build + // (its top-level code collides with `@main` here), so the rule lives in + // `launchHeal` where it can be asserted. The failing-bind case is the one that + // matters: that process exits without ever setting `started`, and the quit-time + // rescue is gated on that flag, so a rescue running after the bind would leave + // the person at this Mac with a pointer inside no screen's frame. + var healOrder: [String] = [] + try launchHeal(restore: { healOrder.append("restore") }, bind: { healOrder.append("bind") }) + precondition(healOrder == ["restore", "bind"], "the pointer must be rescued before the socket binds") + healOrder = [] + do { + try launchHeal(restore: { healOrder.append("restore") }, bind: { throw HelperFailure.message("bind failed") }) + preconditionFailure("A bind that fails must still surface its error") + } catch { + precondition(healOrder == ["restore"], "the rescue must run even when the bind will fail") + } + // Consent is recorded only after the listener is actually up. Swapping those two + // lines in `enable()` leaves the panel and the menu reading `consent.enabled` and + // saying "Enabled" over a Mac nothing is capturing, and a later sign-in start + // comes up enabled on a consent this press never granted. A failed enable has to + // leave the store exactly as it found it. + let stuck = directory.appendingPathComponent("not-a-directory") + try Data().write(to: stuck) + let blocked = HelperRuntime(consent: consent, listener: HelperListener(directory: stuck.appendingPathComponent("nested"))) + do { + try blocked.enable() + preconditionFailure("A listener that cannot bind must fail the enable") + } catch { + precondition(!consent.enabled, "a failed enable must not record consent") + } print("Helper consent, protocol validation, and disabled RPC tests passed") } } diff --git a/packages/assistant/tokens.css b/packages/assistant/tokens.css new file mode 100644 index 000000000..0164be5f8 --- /dev/null +++ b/packages/assistant/tokens.css @@ -0,0 +1,106 @@ +/** + * The Subshell palette and type scale: one source, imported by every surface + * that renders the product — the SPA, both desktop assistants, and the macOS + * display helper's panel. The node's own dashboard (`apps/node/web`) still + * carries its own copy of these values; `bun run lint:design` walks it under the + * SPA's rules so it cannot escape the role system, but nothing yet compares its + * palette to this file, so a new surface should import rather than copy. + * + * This file deliberately lives in `packages/` (Apache-2.0) rather than beside + * the surfaces that use it. The alternative, keeping the block in + * `apps/server/web/src/styles.css` and importing it from Apache code, would + * pull AGPL text into an Apache artifact; the repo's licence line runs through + * `apps/server/`, and a stylesheet is implementation, not types, so the + * API Type Surface exception does not reach it. Copying the values instead + * (what `apps/node/web` did first) leaves N palettes that a checker must keep + * equal; one file leaves nothing to drift. + * + * Spec: docs/superpowers/specs/2026-09-14-design-system-design.md § 3. + * `bun run lint:design` checks the roles, the scale, and the contrast. + */ + +/* + * Ground is the operator-approved slate-indigo #1d182a (oklch 0.224); the ramp + * steps around it at hue 296°, the indigo side of the family, calmer than the + * 318° magenta first pass. `--primary` stays bright so text-primary, links and + * border-primary glows read on the void; primary BUTTONS get a quieter sunk + * plum gradient in each surface's own button rule rather than riding the token. + * + * Status colors for anything outside Badge (dots, inline confirmations) are + * seeded from Badge's success/warning variants (Tailwind emerald-400 and + * amber-400) so chips and dots share one palette. + * + * `--scrim` is not a tint of the palette: it is BLACK at 70%, and every + * surface that draws a modal draws the same one. + * + * The theme is dark-only, so there is no light block to keep in step. + */ +:root { + --background: oklch(0.224 0.035 296); + --foreground: oklch(0.92 0.03 312); + + --card: oklch(0.255 0.032 296); + --card-foreground: oklch(0.92 0.03 312); + + --popover: oklch(0.255 0.032 296); + --popover-foreground: oklch(0.92 0.03 312); + + --primary: oklch(0.75 0.17 322); + --primary-foreground: oklch(0.16 0.05 322); + + --secondary: oklch(0.29 0.034 296); + --secondary-foreground: oklch(0.9 0.025 312); + + --muted: oklch(0.29 0.034 296); + --muted-foreground: oklch(0.74 0.04 310); + + --accent: oklch(0.33 0.04 296); + --accent-foreground: oklch(0.95 0.02 315); + + --destructive: oklch(0.65 0.2 25); + --destructive-foreground: oklch(0.98 0 0); + + --border: oklch(0.33 0.035 296); + --input: oklch(0.33 0.035 296); + --ring: oklch(0.62 0.13 322); + + --success: oklch(0.765 0.177 163.223); + --warning: oklch(0.828 0.189 84.429); + + --scrim: #000000b3; + + /* The primary button's "sunk plum" gradient (Dreamframe: quiet at the + * component, bright at the token), used by the SPA, both desktop assistants + * and the macOS helper's panel. It lives here rather than beside the buttons + * because `lint:design` refuses colour literals outside a token file, and a + * gradient is a design decision rather than a one-off. */ + --button-primary-from: oklch(0.34 0.1 322); + --button-primary-to: oklch(0.4 0.1 340); + --button-primary-hover-from: oklch(0.4 0.11 322); + --button-primary-hover-to: oklch(0.46 0.11 340); + --button-primary-foreground: oklch(0.9 0.05 320); +} + +/* + * Five type roles at two weights. There is no 12px: `detail` (13) is the + * floor, and `text-xs`/`text-caption` are refused by the check. Surfaces write + * the roles, never a number — `text-label font-strong`, + * `font-size: var(--text-label)`, or mobile's `font("label")`. + */ +:root { + --text-display: 30px; + --text-display--line-height: 1.2; + --text-heading: 20px; + --text-heading--line-height: 1.2; + --text-label: 15px; + --text-label--line-height: 1.5; + --text-body: 14px; + --text-body--line-height: 1.5; + --text-detail: 13px; + --text-detail--line-height: 1.5; + + --font-weight-strong: 600; + --font-weight-regular: 400; + + --default-font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif; +} diff --git a/packages/helper-panel/biome.json b/packages/helper-panel/biome.json new file mode 100644 index 000000000..14a6bfa47 --- /dev/null +++ b/packages/helper-panel/biome.json @@ -0,0 +1,4 @@ +{ + "root": false, + "extends": ["../../biome.json"] +} diff --git a/packages/helper-panel/bunfig.toml b/packages/helper-panel/bunfig.toml new file mode 100644 index 000000000..37ff1f948 --- /dev/null +++ b/packages/helper-panel/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/__tests__/setup.ts"] diff --git a/packages/helper-panel/index.html b/packages/helper-panel/index.html new file mode 100644 index 000000000..13790746e --- /dev/null +++ b/packages/helper-panel/index.html @@ -0,0 +1,31 @@ + + + + + + + + Subshell virtual display helper + + + + +
+ + diff --git a/packages/helper-panel/inline.ts b/packages/helper-panel/inline.ts new file mode 100644 index 000000000..723defb5e --- /dev/null +++ b/packages/helper-panel/inline.ts @@ -0,0 +1,71 @@ +import type { Plugin } from "vite"; + +/** + * The two tags vite emits for the entry, matched loosely enough to survive a + * rename of the hashed files but tied to the `./assets/` layout it writes. + */ +const SCRIPT_TAG = /]*\bsrc="\.\/assets\/[^"]+"[^>]*><\/script>/; +const STYLE_TAG = /]*\bhref="\.\/assets\/[^"]+\.css"[^>]*>/; + +/** Close-tag defense: text that would end the element early is escaped. */ +function embed(code: string, closing: string): string { + // HTML end tags are case-insensitive, even inside JS strings and comments. + return code.replace(new RegExp(` `<\\/${tag.slice(2)}`); +} + +/** + * Fold the built stylesheet and script into the document, so the panel is one + * file that fetches nothing. + * + * This is not a size optimization. The helper loads the page with + * `loadFileURL`, and a `file://` document gets an opaque origin, so vite's + * `crossorigin` module script and stylesheet are CORS requests from a `null` + * origin and WebKit refuses both. The result was a white window: the document + * existed, and neither its type nor its behaviour could arrive. Inlined, the + * page needs no fetch at all, and the consent surface is one signed file. + * + * Throws rather than returning the document untouched: a tag vite stops + * emitting would otherwise rebuild "successfully" into a blank panel again. + */ +export function inlineInto(html: string, js: string, css: string): string { + if (!SCRIPT_TAG.test(html) || !STYLE_TAG.test(html)) { + throw new Error("the panel document no longer carries the script and stylesheet tags this build emits"); + } + // The fold puts the bundle in a classic script. Module-only syntax there is a + // SyntaxError at load, which is the same white window by another route, so it + // is refused here rather than discovered on the Mac. + if (/\bimport\.meta\b/.test(js) || /^\s*(?:export|import)\s/m.test(js)) { + throw new Error("the panel bundle uses module-only syntax and cannot be inlined as a classic script"); + } + const inlined = html + .replace(SCRIPT_TAG, () => ``) + .replace(STYLE_TAG, () => ``); + // Anything still pointing at ./assets/ escaped the fold, and would be + // refused by WebKit exactly as the two that this function handles. + const leftover = /(?:src|href)="\.\/assets\/[^"]+"/.exec(inlined); + if (leftover !== null) { + throw new Error(`the panel document still fetches ${leftover[0]}; inlineInto does not handle it`); + } + return inlined; +} + +/** A vite plugin that leaves `dist/` holding nothing but `index.html`. */ +export function panelSingleFile(): Plugin { + return { + name: "helper-panel-single-file", + apply: "build", + enforce: "post", + generateBundle(_options, bundle) { + const entries = Object.values(bundle); + const script = entries.find((chunk) => chunk.type === "chunk"); + const style = entries.find((chunk) => chunk.type === "asset" && chunk.fileName.endsWith(".css")); + const document = entries.find((chunk) => chunk.type === "asset" && chunk.fileName.endsWith(".html")); + if (script?.type !== "chunk" || style?.type !== "asset" || document?.type !== "asset") { + this.error("the panel build emitted something other than one script, one stylesheet and one document"); + } + document.source = inlineInto(String(document.source), script.code, String(style.source)); + delete bundle[script.fileName]; + delete bundle[style.fileName]; + }, + }; +} diff --git a/packages/helper-panel/package.json b/packages/helper-panel/package.json new file mode 100644 index 000000000..c844fe912 --- /dev/null +++ b/packages/helper-panel/package.json @@ -0,0 +1,23 @@ +{ + "name": "@internal/helper-panel", + "version": "1.0.0", + "license": "Apache-2.0", + "type": "module", + "private": true, + "scripts": { + "build": "vite build", + "clean": "rm -rf .turbo node_modules dist", + "lint": "biome check --no-errors-on-unmatched --write --unsafe src", + "lint:check": "biome check --no-errors-on-unmatched src", + "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", + "verify-types": "tsc --noEmit" + }, + "devDependencies": { + "@happy-dom/global-registrator": "21.0.4", + "@internal/tsconfig": "workspace:*", + "@types/bun": "1.3.14", + "typescript": "7.0.2", + "vite": "8.2.4" + } +} diff --git a/packages/helper-panel/src/__tests__/bridge.test.ts b/packages/helper-panel/src/__tests__/bridge.test.ts new file mode 100644 index 000000000..a50a399d2 --- /dev/null +++ b/packages/helper-panel/src/__tests__/bridge.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { EMPTY_STATE, type LoginItemState } from "../state"; + +/** + * The two names that cross between the helper process and this page: the state + * keys Swift pushes in, and the login-item words it pushes as values. Nothing + * type-checks either edge (one side is Swift), and both fail the same quiet way. + * `main.ts` merges whatever arrives over `EMPTY_STATE`, so a key Swift renames is + * not an error, it is a panel that keeps reading "Disabled. Nothing is captured + * and no input is accepted." on a Mac that is consented and capturing. The + * consent surface, lying about the machine, is the failure this guards. + */ +const settingsPath = new URL("../../../../native/display-helper/Settings.swift", import.meta.url); +const themePath = new URL("../../../../native/display-helper/Theme.swift", import.meta.url); + +describe("the helper's state push", () => { + test("Swift's snapshot names exactly the fields the page renders", async () => { + const swift = await Bun.file(settingsPath).text(); + const body = /func snapshot\(\) -> \[String: Any\] \{([\s\S]*?)\n {8}\]/.exec(swift)?.[1]; + // A miss here means Swift reshaped the function and this test stopped + // looking. Empty is never a pass. + expect(body, "Settings.swift still builds snapshot() as a dictionary literal").toBeTruthy(); + const keys = [...(body as string).matchAll(/"([A-Za-z]+)"\s*:/g)].map((match) => match[1] as string); + expect(keys.sort()).toEqual(Object.keys(EMPTY_STATE).sort()); + }, 10_000); + + test("every login-item word the page can render is one Swift can return", async () => { + // `loginItemLine` has a sentence per state, and the panel's `notRegistered` + // reading is what a renamed word would silently fall back to: a helper + // registered with macOS told it was not, with no way to see the difference. + const [settings, theme] = await Promise.all([Bun.file(settingsPath).text(), Bun.file(themePath).text()]); + expect(settings, "snapshot() feeds loginItem from LoginItem.pageState").toContain("LoginItem.pageState("); + const words = [...(theme as string).matchAll(/return "(registered|needsApproval|notRegistered)"/g)].map( + (match) => match[1] as LoginItemState, + ); + expect([...new Set(words)].sort()).toEqual(["needsApproval", "notRegistered", "registered"]); + }, 10_000); +}); diff --git a/packages/helper-panel/src/__tests__/inline.test.ts b/packages/helper-panel/src/__tests__/inline.test.ts new file mode 100644 index 000000000..ab42bb4ac --- /dev/null +++ b/packages/helper-panel/src/__tests__/inline.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { inlineInto, panelSingleFile } from "../../inline"; + +/** + * The white panel this exists to prevent: a `file://` document has an opaque + * origin, so vite's `crossorigin` script and stylesheet are refused and the + * window paints nothing. The build therefore has to leave a document that + * references no file at all, and these are the ways that can fail. + */ +const VITE_HTML = ` + +Subshell virtual display helper + + +
`; + +describe("inlineInto", () => { + test("carries both the behaviour and the palette inside the document", () => { + const doc = inlineInto(VITE_HTML, "document.body.dataset.ready = 1;", ":root { --background: oklch(0.2 0 0); }"); + expect(doc).toContain("document.body.dataset.ready"); + expect(doc).toContain("--background"); + expect(doc).not.toContain("./assets/"); + expect(doc).not.toContain("crossorigin"); + }); + + test("leaves the markup the page renders into alone", () => { + const doc = inlineInto(VITE_HTML, "void 0;", "body{}"); + expect(doc).toContain('
'); + expect(doc).toContain(""); + }); + + test("escapes closing tags regardless of HTML casing", async () => { + const doc = inlineInto(VITE_HTML, 'const note = "</ScRiPt><script>unexpected()</script>";', "/* </StYlE> */"); + let scripts = 0; + await new HTMLRewriter() + .on("script", { + element: () => { + scripts++; + }, + }) + .transform(new Response(doc)) + .text(); + expect(scripts).toBe(1); + expect(doc.match(/<\/script\s*>/gi)?.length).toBe(1); + expect(doc.match(/<\/style\s*>/gi)?.length).toBe(1); + expect(doc).toContain("<\\/ScRiPt>"); + expect(doc).toContain("<\\/StYlE>"); + }); + + test("preserves replacement tokens in script and stylesheet contents", () => { + const js = `const tokens = ["$&", "$'", "$` + "`" + `", "$$"];`; + const css = `/* $& $' $` + "`" + ` $$ */`; + const doc = inlineInto(VITE_HTML, js, css); + expect(doc).toContain(`<script>${js}</script>`); + expect(doc).toContain(`<style>${css}</style>`); + }); + + test("refuses a document whose tags it no longer recognizes", () => { + // The failure this guards is a quiet one: vite changes its output, the + // plugin matches nothing, and the build still "succeeds" into a blank panel. + expect(() => inlineInto("<html><body></body></html>", "void 0;", "body{}")).toThrow( + "no longer carries the script and stylesheet tags", + ); + }); + + test("refuses a bundle that could only run as a module", () => { + // The fold emits a classic script, so `import.meta` or a top-level export + // would be a load-time SyntaxError: the same white window by another route. + expect(() => inlineInto(VITE_HTML, "const u = import.meta.url;", "body{}")).toThrow("classic script"); + expect(() => inlineInto(VITE_HTML, "export const ready = 1;", "body{}")).toThrow("classic script"); + }); + + test("refuses a document still fetching anything", () => { + const withFont = VITE_HTML.replace("</head>", '<link rel="stylesheet" href="./assets/fonts-abc.css"></head>'); + expect(() => inlineInto(withFont, "void 0;", "body{}")).toThrow("still fetches"); + }); +}); + +test("the build plugin folds the emitted chunk and CSS into the document and removes both assets", () => { + const plugin = panelSingleFile(); + const bundle = { + "assets/main.js": { type: "chunk", fileName: "assets/main.js", code: "window.ready = true;" }, + "assets/main.css": { type: "asset", fileName: "assets/main.css", source: "body{}" }, + "index.html": { type: "asset", fileName: "index.html", source: VITE_HTML }, + }; + const generate = plugin.generateBundle; + if (typeof generate !== "function") throw new Error("No bundle hook"); + generate.call( + { + error: (message: string) => { + throw new Error(message); + }, + } as never, + {} as never, + bundle as never, + false, + ); + expect(Object.keys(bundle)).toEqual(["index.html"]); + expect(bundle["index.html"].source).toContain("window.ready = true;"); + expect(bundle["index.html"].source).toContain("<style>body{}</style>"); +}); diff --git a/packages/helper-panel/src/__tests__/main.test.ts b/packages/helper-panel/src/__tests__/main.test.ts new file mode 100644 index 000000000..ba459bbbd --- /dev/null +++ b/packages/helper-panel/src/__tests__/main.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, expect, spyOn, test } from "bun:test"; +import "../main"; +import { EMPTY_STATE, type PanelMessage } from "../state"; + +const messages: PanelMessage[] = []; +beforeEach(() => { + window.webkit = { messageHandlers: { subshell: { postMessage: (message) => messages.push(message) } } }; + messages.length = 0; + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + window.subshellSetState?.(EMPTY_STATE); +}); +function press(label: string) { + const button = [...document.querySelectorAll("button")].find((el) => el.textContent === label); + if (!button) throw new Error(`No button: ${label}`); + button.click(); +} +test("state pushes preserve quit confirmation focus and update its consequences", async () => { + press("Quit helper"); + await Promise.resolve(); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(document.activeElement?.textContent).toBe("Cancel"); + const confirm = dialog?.querySelectorAll("button")[1]; + confirm?.focus(); + window.subshellSetState?.({ ...EMPTY_STATE, enabled: true, viewers: 1 }); + expect(document.querySelector('[role="dialog"]')).toBe(dialog); + expect(document.activeElement).toBe(confirm ?? null); + expect(dialog?.textContent).toContain("stops capture and input"); + confirm?.click(); + expect(messages).toContainEqual({ action: "quit" }); +}); +test("Tab stays inside the confirmation and Escape dismisses it", async () => { + press("Quit helper"); + await Promise.resolve(); + const scrim = document.querySelector(".scrim"); + scrim?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement?.textContent).toBe("Quit helper"); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); +test("hiding and reopening settings discards its transient quit confirmation", () => { + press("Quit helper"); + Object.defineProperty(document, "hidden", { configurable: true, value: true }); + document.dispatchEvent(new Event("visibilitychange")); + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + window.subshellSetState?.(EMPTY_STATE); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); +test("each render sends finite document dimensions to the native bridge", () => { + const resize = messages.find((message) => message.action === "resize"); + expect(resize?.action).toBe("resize"); + if (resize?.action === "resize") { + expect(Number.isFinite(resize.width)).toBe(true); + expect(Number.isFinite(resize.height)).toBe(true); + } +}); + +test("native reactivation clears the dialog even without a state change", () => { + press("Quit helper"); + window.subshellPanelShown?.(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); + +test("non-finite layout dimensions are replaced before crossing the bridge", () => { + const layout = spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + width: Number.NaN, + height: Number.NaN, + } as DOMRect); + try { + messages.length = 0; + window.subshellSetState?.(EMPTY_STATE); + expect(messages).toContainEqual({ action: "resize", width: 0, height: 0 }); + } finally { + layout.mockRestore(); + } +}); + +test("Confirm dismisses the quit dialog even without a native bridge", () => { + press("Quit helper"); + window.webkit = undefined; + document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')[1]?.click(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); diff --git a/packages/helper-panel/src/__tests__/setup.ts b/packages/helper-panel/src/__tests__/setup.ts new file mode 100644 index 000000000..9eae5faae --- /dev/null +++ b/packages/helper-panel/src/__tests__/setup.ts @@ -0,0 +1,7 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// Bun's HTMLRewriter expects its native Response, not the DOM fetch shim. +const NativeResponse = globalThis.Response; +GlobalRegistrator.register(); +globalThis.Response = NativeResponse; +document.body.innerHTML = '<main id="panel"></main>'; diff --git a/packages/helper-panel/src/__tests__/strings.test.ts b/packages/helper-panel/src/__tests__/strings.test.ts new file mode 100644 index 000000000..08dcfa939 --- /dev/null +++ b/packages/helper-panel/src/__tests__/strings.test.ts @@ -0,0 +1,797 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { EMPTY_STATE, type PanelState } from "../state"; +// The module's whole surface, pinned below. A namespace import rather than a +// dynamic one, because this repo forbids `await import()` everywhere a bundler has +// to see the target. +import * as copyModule from "../strings"; +import { + consentControl, + loginItemLine, + permissionValue, + quitBody, + showsSignInSection, + stateLine, + strings, + viewerLine, +} from "../strings"; + +/** + * The panel's copy is its consent interface, so the words are the behavior + * under test: a line that overstates what is running (or hides that someone is + * watching) leads a person to leave the helper on. Each assertion below names + * the misreading it prevents. + */ +function stateWith(overrides: Partial<PanelState>): PanelState { + return { ...EMPTY_STATE, ...overrides }; +} + +/** + * The two consent sentences, spelled out here because the panel cannot name + * them. `strings.ts` keeps them module-private so no page module can take the + * label and the note from different states by any spelling, and `consentControl` + * is the only door through them, for this file exactly as for the page. These + * literals are what that door must hand back. + */ +const ENABLE_NOTE = + "Enabling allows capture and input once both macOS permissions are granted. Your subshells keep running, and your macOS permissions and sign-in choice don't change."; +const DISABLE_NOTE = + "Disabling disconnects viewers and stops capture and input. Your subshells keep running, and your macOS permissions and sign-in choice don't change."; +/** The triple while the helper is off, so the button offers to enable it. */ +const offerEnable = consentControl(stateWith({ enabled: false })); +/** And while it is on, so the button offers to disable it. */ +const offerDisable = consentControl(stateWith({ enabled: true })); + +/** + * The one module allowed to hold the panel's words, named by its path from the + * PACKAGE root, which is where `pageCode` starts. + */ +const COPY_FILE = "src/strings.ts"; +/** + * Where the panel's own suites live, and their assertions quote the copy on + * purpose. Pinned to that path, not to any directory named `__tests__`: a helper + * under such a name is still shipped code if a page module imports it, and the ban + * in `MUTATES_AN_OBJECT`'s companion catches that rather than excusing it. + */ +const TEST_DIR = "src/__tests__/"; +/** Not source. The root is the package, so build output sits in the walk's way. */ +const SKIP_DIRS = new Set(["node_modules", "dist", ".turbo", "coverage"]); + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** + * Turn the escapes a browser or a bundler will undo back into characters before + * anything is compared. Round 27 found this by building the panel: a stylesheet + * carrying `content: "En\61 ble helper"` squashes to `contentEn61blehelper`, which + * contains no banned word, while vite's minifier decodes the escape and the shipped + * page renders "Enable helper" from a file the ban called clean. + */ +function decodeEscapes(source: string): string { + return ( + source + .replace(/\\u\{[0-9a-fA-F]+\}/g, (m) => String.fromCodePoint(parseInt(m.slice(3, -1), 16))) + .replace(/\\u[0-9a-fA-F]{4}/g, (m) => String.fromCodePoint(parseInt(m.slice(2), 16))) + .replace(/\\x[0-9a-fA-F]{2}/g, (m) => String.fromCodePoint(parseInt(m.slice(2), 16))) + // CSS ends a hex run with a space, which is not part of the character. + .replace(/\\[0-9a-fA-F]{1,6}[ \t]?/g, (m) => String.fromCodePoint(parseInt(m.slice(1).trim(), 16))) + .replace(/&#x[0-9a-fA-F]+;/g, (m) => String.fromCodePoint(parseInt(m.slice(3, -1), 16))) + .replace(/&#\d+;/g, (m) => String.fromCodePoint(Number(m.slice(2, -1)))) + ); +} + +/** + * Reduce both sides of a copy comparison to letters and digits, so the only thing + * that has to match is the words themselves. Round 25 showed a plain substring check + * was only as strong as its spelling, and round 26 showed even this was reachable: a + * sentence carried as `["Disabling…", "Your subshells…"].join(" ")` survives a strip + * of quotes and `+`, because the array's commas and brackets are punctuation too. + * Case is kept, deliberately: lowercasing would match `state.accessibility` against + * the banned label "Accessibility", and an identifier is not the panel showing a word. + */ +function squash(source: string): string { + return decodeEscapes(source).replace(/[^A-Za-z0-9]/g, ""); +} + +/** + * Every page source except the one file allowed to hold copy, comments stripped. + * + * A guard that reads only `main.ts` is satisfied by moving the defect into a second + * module, and the repository's own file-size rule ("split past ~300-400 lines", and + * `main.ts` is 351) pushes an editor straight into doing that, so the walk is + * recursive. It starts at the PACKAGE root rather than `src/`, because round 25 + * found the reason: the document the webview actually loads is `index.html` beside + * `package.json`, along with the `inline.ts` that composes the shipped file. Rooted + * one level down, the sweep had never read the page's own shell, and a consent + * sentence pasted there stayed green while the same text in `src/` failed. + */ +async function pageCode(dir: URL = new URL("../../", import.meta.url), prefix = ""): Promise<PageSource[]> { + const found: PageSource[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const child = `${prefix}${entry.name}/`; + // Build output and dependencies are skipped at the PACKAGE root, where they + // actually live, and nowhere else: round 27 hid a shipped `src/dist/copy.json` + // from the sweep by using a directory name that is only special at the top. + if (child !== TEST_DIR && !(prefix === "" && SKIP_DIRS.has(entry.name))) + found.push(...(await pageCode(new URL(`${entry.name}/`, dir), child))); + continue; + } + // Stylesheets, the page shell, and every other shape the bundler takes: CSS + // `content:`, HTML markup, and a JSON file imported as data all put words on this + // same consent surface. Round 26 found the gap by asking what an editor crowded + // out of a 351-line `main.ts` would naturally do, which is move the sentences into + // a data file the sweep never opened. Root config JSON is swept too; it costs + // nothing and the alternative is a rule about which files count, which is the + // judgement this sweep has already been caught making twice. + if (!entry.isFile()) continue; + if (!/\.(ts|tsx|js|jsx|mjs|cjs|css|html|json|svg|txt|md|toml)$/i.test(entry.name)) continue; + if (entry.name.endsWith(".d.ts")) continue; + if (`${prefix}${entry.name}` === COPY_FILE) continue; + // `dir` already names the directory it was recursed into, so the file URL is + // built from the entry alone; the prefix belongs only to the reported name. + const code = await Bun.file(new URL(entry.name, dir)).text(); + found.push({ file: `${prefix}${entry.name}`, code: withoutComments(code) }); + } + return found; +} + +type PageSource = { file: string; code: string }; + +/** + * Every file under a directory, with no opinion about whether it counts as source. + * + * The sweep's extension filter is a judgement, and a judgement cannot double as the + * evidence that it missed nothing, which is exactly how a `src/copy.json` stayed + * invisible to both the walk and the list built from it. `SKIP_DIRS` applies at the + * package root only, so a directory called `dist` inside `src/` is a file someone + * shipped rather than build output the enumerator is allowed to ignore. + */ +function listShipped(dirAbs: string, base = ""): string[] { + const out: string[] = []; + for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + const rel = base === "" ? entry.name : `${base}/${entry.name}`; + if (entry.isDirectory()) { + if (base !== "" || !SKIP_DIRS.has(entry.name)) out.push(...listShipped(path.join(dirAbs, entry.name), rel)); + } else if (entry.isFile()) { + out.push(rel); + } + } + return out; +} + +/** + * What this file enforces, and what it deliberately does not. + * + * Four checks carry the weight, and all four are about facts rather than spelling: + * the consent strings are not on the exported copy object or in the module's export + * set (closed sets, so a new way to reach the words has to be typed into a list); + * no shipped file holds a consent sentence as its own text (the copy ban, squashed + * so a sentence split across literals or rewrapped is still the same words); the + * shell's title is the copy's title; and the panel's triple comes from one call to + * `consentControl`. + * + * Rounds 22-25 spent four passes chasing patterns that tried to recognise every way + * of naming or reaching a property, and every finding in those rounds was in the + * pattern list, never in the product: escapes, concatenation, `Reflect.get`, + * spreads, non-enumerable definitions, an aliased receiver. A regex cannot evaluate + * a program, so those lists are not closed and are gone. What is left unpolicied, + * said plainly so nobody reads more protection into it than exists: page code may + * still call `consentControl` twice and cherry-pick `label` from one result and + * `note` from another, because that is a call to the sanctioned door, not a name + * this file can recognise. The one-decision check makes the slip less likely by + * counting calls; it does not make it impossible. + */ + +/** + * A page module importing from a test directory. + * + * `pageCode` skips `src/__tests__`, which is right for the suites and wrong for a + * helper file that happens to live under a directory named like one: if page code + * imports it, it ships, and it can hold a consent sentence the sweep never reads. + * This is the exemption's own plug, not a general rule about syntax. It names the + * path rather than the `from` keyword, because round 26 shipped a side-effect + * `import "./__tests__/evil.css";` straight past the anchored version, and a + * stylesheet pulled in that way is exactly as rendered as one bound to a name. + */ +const IMPORTS_TEST_DIR = /["'`][^"'`]*__tests__\//; + +/** + * Words the page may not hold in its own source, however it obtained them. + * + * Making the notes unreachable closes reading them off an object, but a module can + * still type the disabling sentence beneath its own enable button, which is the + * mispairing this file exists to prevent and no access rule can see. Comparing the + * sentences themselves is the check that does not care where they came from, so + * the same test that stops a page restating "Screen recording" stops it restating + * a consent note. + */ +const COPY_THE_PAGE_MAY_NOT_HOLD = [ + ENABLE_NOTE, + DISABLE_NOTE, + offerEnable.label, + offerDisable.label, + strings.capturePermissionLabel, + strings.inputPermissionLabel, +]; + +describe("the consent warning", () => { + test("says plainly what enabling exposes, so the decision is informed", () => { + // The defect this guards: a warning that reads like a generic privacy + // notice, so someone enables capture without knowing a viewer drives the + // real cursor and sees their other windows. + const warning = strings.warning; + expect(warning).toContain("pointer"); + expect(warning).toContain("share your Mac login, keyboard focus, and pointer"); + expect(warning).toContain("other windows"); + expect(warning).toContain("trust"); + }); + + test("keeps every control label to the action it takes", () => { + // "Minimize helper" once read as "stop the helper", so a person thought + // capture had ended while it had not. The label names the window. + expect(strings.minimize).toBe("Minimize helper"); + expect(strings.quit).toBe("Quit helper"); + expect(offerEnable.label).toBe("Enable helper"); + expect(offerDisable.label).toBe("Disable helper"); + }); + + test("keeps the words the page says in this file", async () => { + // `strings.ts` opens by claiming to hold every word the panel shows, and the + // two permission row labels sat in `main.ts` as literals, where no wording + // review, voice check, or parity test looks. Copy that lives outside this + // file can disagree with the buttons beside it and nothing notices. + expect(strings.capturePermissionLabel).toBe("Screen recording"); + expect(strings.inputPermissionLabel).toBe("Accessibility"); + const files = await pageCode(); + expect( + files.some((f) => f.file === "src/main.ts"), + "the page still has a main.ts to check", + ).toBe(true); + // Asked of the whole sweep rather than of one filename, because the same + // split that moves a row out of `main.ts` would otherwise fail a check that + // is about where the words live, not about which module renders them. + for (const token of ["capturePermissionLabel", "inputPermissionLabel"]) { + expect( + files.some((f) => f.code.includes(`.${token}`)), + `a page module renders ${token} from strings.ts`, + ).toBe(true); + } + // The ban on the page holding these words at all is the sweep test of its own, + // so a failure there reads as "someone typed the words" rather than as a + // question about which module renders them. + }); + + test("puts the ellipsis only where another app opens", () => { + // macOS convention, and the dashboard's: "…" means a dialog or another + // app is about to take over. System Settings does; the browser link and + // the "Allow" buttons do not, because the row already names the permission. + for (const label of [strings.openLoginItems, strings.openSettings]) { + expect(label.endsWith("…")).toBe(true); + } + for (const label of [strings.allow, strings.openDocs, strings.minimize, strings.quit]) { + expect(label.endsWith("…")).toBe(false); + } + }); +}); + +describe("stateLine", () => { + test("acknowledges both permissions once macOS allows them", () => { + expect(stateLine(stateWith({ enabled: true, screenRecording: true, accessibility: true }))).toBe( + "Enabled. Capture and input are allowed by macOS.", + ); + }); + + test("denies both capture and input while disabled", () => { + const line = stateLine(stateWith({ enabled: false, screenRecording: true, accessibility: true })); + expect(line).toContain("Disabled"); + expect(line).toContain("Nothing is captured"); + expect(line).toContain("no input is accepted"); + }); + + test("does not promise a working display while a permission is missing", () => { + // Enabled alone is not enough: saying "Enabled and capturing" here would + // tell someone their subshells have a screen when macOS still refuses. + const line = stateLine(stateWith({ enabled: true })); + expect(line).toContain("Enabled"); + expect(line).toContain("still require both macOS permissions"); + }); + + test("keeps the state line to the state", () => { + // The login item got its own row once the panel stopped being a report, so + // the state line carries the permission facts only. Two sentences telling + // one person the same thing in two places is how they start to disagree. + const line = stateLine(stateWith({ enabled: true, loginItem: "needsApproval" })); + expect(line).toContain("Enabled"); + expect(line).not.toContain("Login Items"); + }); +}); + +describe("loginItemLine", () => { + test("tells the three macOS states apart instead of collapsing them", () => { + // Turning the switch OFF in System Settings leaves the item NOT REGISTERED, + // which is a different fact from "registered but awaiting approval". The + // panel once read both as approved, so a disabled sign-in start kept + // claiming it starts at sign-in. + expect(loginItemLine("registered")).toEqual({ text: "Starts when you sign in to macOS.", blocked: false }); + const pending = loginItemLine("needsApproval"); + expect(pending.blocked).toBe(true); + expect(pending.text).toContain("Login Items"); + const missing = loginItemLine("notRegistered"); + expect(missing.blocked).toBe(true); + expect(missing.text).toContain("Won't start when you sign in"); + // Enabling the helper no longer registers anything, so the sentence must + // not send the person to toggle consent to get a sign-in start back. The + // button beside the row does it. + expect(missing.text.toLowerCase()).not.toContain("re-enable"); + }); +}); + +describe("the consent button's note", () => { + test("each note names the act its own button performs", () => { + // One sentence served both states, so a screen reader reaching "Enable + // helper" heard "Disabling stops capture and input", and the person + // deciding whether this Mac may be captured has only that announcement to + // decide from. `consentControl` picks by `state.enabled`; these two must not + // be interchangeable. + expect(offerEnable.label).toBe("Enable helper"); + expect(offerDisable.label).toBe("Disable helper"); + expect(offerEnable.note).toContain("Enabling allows capture and input once both macOS permissions are granted"); + expect(offerEnable.note).not.toMatch(/Disabling/); + expect(offerDisable.note).toContain("Disabling disconnects viewers and stops capture and input"); + expect(offerDisable.note).not.toMatch(/Enabling/); + }); + + test("neither note promises a sign-in start, and both say the rest stays", () => { + // Enabling no longer registers anything by itself, and that choice has its + // own row and control, so a second place claiming it is how the two start + // to disagree. What genuinely stays belongs in both sentences. + for (const note of [offerEnable.note, offerDisable.note]) { + expect(note).not.toContain("starts at sign-in"); + expect(note).toContain("sign-in choice don't change"); + expect(note).toContain("macOS permissions"); + } + }); + + test("the button, its action, and its note come from one decision", async () => { + // Asserting that the two sentences differ tests `strings.ts`; the bug was in + // how the panel PAIRED them, so a future edit handing "Enable helper" the + // disabling sentence would have left every string test green. One call now + // returns the triple, and the page has nothing left to get wrong on its own. + expect(consentControl(stateWith({ enabled: false }))).toEqual({ + label: "Enable helper", + action: "enable", + note: ENABLE_NOTE, + }); + expect(consentControl(stateWith({ enabled: true }))).toEqual({ + label: "Disable helper", + action: "disable", + note: DISABLE_NOTE, + }); + // The page draws the pair from that one call, rather than reading + // `state.enabled` a second time beside the note. Asked of the sweep rather + // than of `main.ts`, so moving the call into a split module is not the + // change that turns this green. + const files = await pageCode(); + // One read of `state.enabled` is the rule, so the door gets used once: the + // identifier may appear exactly twice in the whole page, once as the import + // specifier and once as the call. Round 25 laundered it through a local wrapper, + // `function consentPair(s) { return consentControl(s); }`, and called the wrapper + // three times on different state, so a count of the CALL spelling saw one use + // while the note came back from the opposite state. Returning the door's result + // and aliasing the door are refused for that reason. A second consent control + // would be a different design, and this count is where someone says so. + const door = files.flatMap((f) => f.code.match(/\bconsentControl\b/g) ?? []); + expect(door, "one import and one call; a second consent control is a design change").toHaveLength(2); + const laundered: string[] = []; + for (const { file, code } of files) { + if (/\breturn\s+consentControl\s*\(/.test(code)) + laundered.push(`${file} returns the door's result from a helper`); + if (/=\s*consentControl\b(?!\s*\()/.test(code)) laundered.push(`${file} aliases the door`); + } + expect(laundered).toEqual([]); + }); + + test("offers the sign-in act that matches the state it leaves", () => { + // Two verbs, one per direction, plus the setting that waits for approval. + expect(strings.startAtSignIn).toBe("Start at sign-in"); + expect(strings.stopAtSignIn).toBe("Stop at sign-in"); + expect(strings.openLoginItems.endsWith("…")).toBe(true); + }); +}); + +describe("the consent copy's reachability", () => { + /** + * Every word the panel may show, and nothing else. + * + * Round 23 took the consent strings off the exported object so no page module + * could name them, and round 24 showed that a list of FORBIDDEN names cannot guard + * that: it checked the old spellings (`enable`, `disable`) and stayed silent while + * the same labels came back under their current ones (`enableLabel`, + * `disableLabel`), which is the mispairing defect reintroduced with every test + * green. A closed set has no such blind spot. Reaching for the copy means adding a + * key to this list, and a key someone has to type is a decision made in the open. + */ + const COPY_KEYS = [ + "access", + "allow", + "cancel", + "capturePermissionLabel", + "inputPermissionLabel", + "loginItemLabel", + "loginItemMissing", + "loginItemPending", + "loginItemRegistered", + "minimize", + "openCaptureSettingsLabel", + "openDocs", + "openInputSettingsLabel", + "openLoginItems", + "openSettings", + "permissionNeedsConsent", + "permissions", + "quit", + "quitTitle", + "requestCapture", + "requestInput", + "signInSection", + "startAtSignIn", + "stopAtSignIn", + "subtitle", + "title", + "warning", + ]; + /** The copy module's whole surface: helpers that decide wording, never the pair. */ + const MODULE_EXPORTS = [ + "consentControl", + "loginItemLine", + "permissionValue", + "quitBody", + "showsSignInSection", + "stateLine", + "strings", + "viewerLine", + ]; + + test("the copy object holds exactly the words it is meant to hold", () => { + // `Reflect.ownKeys`, not `Object.keys`: a property defined with + // `enumerable: false` is invisible to the latter, and `Object.defineProperty` + // inside this very module (which the page sweep exempts) is how a consent note + // could come back while the closed set below still read 27 keys. + expect(Reflect.ownKeys(strings).map(String).sort()).toEqual([...COPY_KEYS].sort()); + }); + + test("the module exports no way past the one door", () => { + // The same argument one level up, and invisible to the check above: what makes + // the consent pair unreachable is `consentCopy` being module-private, and + // writing `export` in front of it changes nothing about `strings`. + expect(Object.keys(copyModule).sort()).toEqual([...MODULE_EXPORTS].sort()); + }); + + test("the door still hands back the private pair", () => { + // Which is what keeps the two closed sets above from being satisfied by simply + // deleting the copy. + expect(consentControl(stateWith({ enabled: false }))).toEqual({ + label: "Enable helper", + action: "enable", + note: ENABLE_NOTE, + }); + expect(consentControl(stateWith({ enabled: true }))).toEqual({ + label: "Disable helper", + action: "disable", + note: DISABLE_NOTE, + }); + }); +}); + +describe("the page sweep", () => { + test("covers the package the way it is really laid out", async () => { + // Every guard above is a claim about the files this walk returns, so the walk + // gets checked too. Round 25 is the reason it has to mirror the REAL layout: + // the synthetic tree planted its `index.html` inside the swept directory, which + // is not where the shipped shell lives, so a sweep rooted one level below the + // package passed a self-test certifying a shape the repository never had. + const root = mkdtempSync(path.join(tmpdir(), "subshell-panel-sweep-")); + try { + for (const name of [ + "index.html", + "inline.ts", + "package.json", + "vite.config.ts", + "src/main.ts", + "src/state.ts", + "src/strings.ts", + "src/vite-env.d.ts", + "src/styles.css", + "src/copy.json", + "src/icon.svg", + "src/notes.txt", + "src/consent/row.ts", + "src/consent/row.css", + "src/dist/inner.json", + "src/guide.md", + "src/__tests__/strings.test.ts", + "dist/index.html", + "node_modules/dep/index.ts", + ]) { + const file = path.join(root, name); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, "export const x = 1;\n"); + } + const swept = (await pageCode(pathToFileURL(`${root}/`))).map((source) => source.file); + // The shell and the fold beside package.json count as page code, the copy + // module is the only exemption, build output and dependencies are skipped at the + // root only, and a directory is not a reason to miss a file: `src/dist/` is + // someone's shipped data, not this package's build output. + expect(swept.sort()).toEqual([ + "index.html", + "inline.ts", + "package.json", + "src/consent/row.css", + "src/consent/row.ts", + "src/copy.json", + "src/dist/inner.json", + "src/guide.md", + "src/icon.svg", + "src/main.ts", + "src/notes.txt", + "src/state.ts", + "src/styles.css", + "vite.config.ts", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the shell's title is the copy's title", async () => { + // `index.html` carries the document title the webview shows, and it sat outside + // the sweep long enough to duplicate `strings.title` by hand with nothing + // comparing the two. Renaming the copy left a window titled one thing and a + // panel heading saying another, both green. + const shell = (await pageCode()).find((source) => source.file === "index.html"); + expect(shell, "the package still ships a root index.html").toBeTruthy(); + expect(/<title>([^<]*)<\/title>/.exec(shell?.code ?? "")?.[1]).toBe(strings.title); + }); + + test("every shipped file in the package is swept, whatever shape it is", async () => { + // The list in the test above is hand-written, and round 26 found what that costs: + // a `src/copy.json` holding the disabling sentence was invisible to the walk AND + // invisible to the list, because the list came from the same filtered walk it was + // meant to check. Round 27 found the same flaw inside that fix: enumerating `src/` + // alone never asked about a package-root `data.json`, and reusing the sweep's own + // skip set let `src/dist/copy.json` hide behind a directory name that is only + // special at the top. So this walks the whole package with no extension filter, + // skips nothing below the root, and exempts a named set instead of a derived one. + const swept = new Set((await pageCode()).map((source) => source.file)); + const packageRoot = fileURLToPath(new URL("../../", import.meta.url)); + const unswept = listShipped(packageRoot) + .filter((file) => !file.endsWith(".d.ts")) // a declaration is not rendered + .filter((file) => file !== COPY_FILE && !file.startsWith(TEST_DIR)) + .filter((file) => !swept.has(file)) + .sort(); + expect(unswept, "a shipped file the copy sweep never reads").toEqual([]); + // The two root files round 25 lost, checked by name because their absence from a + // `src/` listing would never have been noticed. + for (const shell of ["index.html", "inline.ts"]) expect(swept.has(shell), `${shell} must be swept`).toBe(true); + }); +}); + +describe("the copy ban's decoding", () => { + test("reads back the escapes a browser or a bundler will undo", () => { + // Round 27 built the panel and found the words in the shipped page from a source + // file the ban had called clean: vite decodes CSS escapes while minifying, so + // `content: "En\61 ble helper"` renders "Enable helper" while its raw text + // contains no banned word at all. Break a branch of `decodeEscapes` and this is + // the test that says the ban went blind, rather than the plant that gets past it. + expect(squash('content: "En\\61 ble helper";')).toContain(squash("Enable helper")); + expect(squash('"En\\u0061ble helper"')).toContain(squash("Enable helper")); + expect(squash("\\x45nable helper")).toContain(squash("Enable helper")); + expect(squash("Enable helper")).toContain(squash("Enable helper")); + expect(squash("Enable helper")).toContain(squash("Enable helper")); + // Ordinary text is unaffected in the ways that matter: letters and digits only. + expect(squash("Disabling stops capture and input.")).toBe("Disablingstopscaptureandinput"); + }); +}); + +describe("the panel's own copy stays in one file", () => { + test("nothing outside strings.ts holds a consent sentence", async () => { + // Kept separate from the door test so a failure says "someone typed the words" + // rather than "someone reached through the object". + const offenders: string[] = []; + for (const { file, code } of await pageCode()) { + // Plugs the one exemption this check's own reach depends on: a helper under a + // test directory is not swept, so page code must not be importing from one. + if (file !== "bunfig.toml" && IMPORTS_TEST_DIR.test(code)) + offenders.push(`${file} imports shipped copy from a test directory`); + const flat = squash(code); + for (const copy of COPY_THE_PAGE_MAY_NOT_HOLD) { + if (flat.includes(squash(copy))) offenders.push(`${file} holds "${copy.slice(0, 32)}..." as its own copy`); + } + } + expect(offenders).toEqual([]); + }); +}); + +describe("viewerLine", () => { + test("says nobody is watching when no capture is held", () => { + expect(viewerLine(stateWith({ enabled: true, viewers: 0 }))).toContain("No one is viewing"); + }); + + test("carries the count and agrees with it in number", () => { + // A gold line reading "1 viewers" reads as a bug, and a person who stops + // trusting the count stops reading it at all. + const one = viewerLine(stateWith({ viewers: 1 })); + expect(one).toContain("A Subshell viewer"); + expect(one).not.toContain("viewers are"); + const three = viewerLine(stateWith({ viewers: 3 })); + expect(three).toContain("3"); + expect(three).toContain("viewers are"); + }); +}); + +describe("quit confirmation", () => { + test("says what stops and what stays, in every sign-in state", () => { + // Quitting ends every screen being watched and the window does not come + // back on its own, so the act needs the consequences in front of the + // person BEFORE they take it, not in the docs after. + expect(strings.quitTitle.endsWith("?")).toBe(true); + expect(strings.quitTitle).toContain("Quit"); + for (const state of ["registered", "needsApproval", "notRegistered"] as const) { + for (const enabled of [true, false]) { + const body = quitBody(state, enabled); + expect(body).toContain("disconnects viewers and stops capture and input"); + expect(body).toContain("helper settings and macOS permissions don't change"); + expect(body).toContain("Your subshells keep running"); + // Every variant also has to say how the helper comes back: the sign-in + // list, or the settings screen that launches it. A sentence that + // mentions neither leaves the person guessing whether quitting is + // recoverable, which is the question the dialog exists to answer. + expect(body.includes("virtual display settings") || body.includes("login list")).toBe(true); + } + } + }); + + test("promises a sign-in return only to the person who asked for one and switched it on", () => { + // Two halves decide the sentence. Enabling never registered a login item + // and disabling never removes one, so "registered" is its own choice; and a + // registered helper that is switched off does launch at sign-in, then exits + // without capturing anything, so "it starts again at sign-in" describes a + // return that delivers no screen. Only one pair may claim it. + const ready = quitBody("registered", true); + expect(ready).toContain("starts again at sign-in"); + expect(ready).toContain("Stop at sign-in"); + expect(ready).not.toContain("disable it first"); + const disabled = quitBody("registered", false); + expect(disabled).toContain("without capturing anything"); + expect(disabled).toContain("Stop at sign-in"); + expect(disabled).not.toContain("starts again at sign-in"); + for (const state of ["needsApproval", "notRegistered"] as const) { + for (const enabled of [true, false]) { + expect(quitBody(state, enabled)).not.toContain("starts again at sign-in"); + } + } + expect(quitBody("notRegistered", true)).toContain("It won't start at sign-in."); + expect(quitBody("needsApproval", true)).toContain("until you approve it in macOS"); + }); + + test("gives the granted row a control that says where it goes", () => { + // "Allow" twice would promise a prompt macOS has already answered, and a + // bare "Settings" would not say which pane the press opens. + expect(strings.openSettings).toBe("Open settings…"); + expect(strings.openCaptureSettingsLabel).toBe("Open screen recording settings"); + expect(strings.openInputSettingsLabel).toBe("Open accessibility settings"); + }); + + test("names the same act in the button, the heading, and the confirm", () => { + // A dialog that says "Close" over a button that said "Quit" is how a person + // ends up unsure which thing they just did. + expect(strings.quit).toBe("Quit helper"); + expect(strings.quitTitle.startsWith("Quit")).toBe(true); + }); +}); + +describe("showsSignInSection", () => { + test("keeps the unregister door reachable while the helper is off", () => { + // The row holds the only Stop at sign-in button, and disabling leaves the + // registration in place on purpose. Hiding the section with the consent + // toggle strands a registered helper: the person would have to accept + // capture again to take it off macOS's list. + expect(showsSignInSection(stateWith({ enabled: false, loginItem: "registered" }))).toBe(true); + expect(showsSignInSection(stateWith({ enabled: false, loginItem: "needsApproval" }))).toBe(true); + expect(showsSignInSection(stateWith({ enabled: false, loginItem: "notRegistered" }))).toBe(false); + // Enabled is where the choice gets made, whatever the registration says. + expect(showsSignInSection(stateWith({ enabled: true, loginItem: "notRegistered" }))).toBe(true); + }); +}); + +describe("permissionValue", () => { + test("says the state, and marks the one the person can still act on", () => { + // Gold for "Not allowed" and plain ink for "Allowed" is the whole point: + // a settled fact must not compete for attention with the row that still + // needs a click in System Settings. + expect(permissionValue(true)).toEqual({ text: "Allowed", blocked: false }); + expect(permissionValue(false)).toEqual({ text: "Not allowed", blocked: true }); + }); +}); + +describe("the menu's quit confirmation", () => { + // The status menu asks the panel's question before ending the helper, in + // Swift. Two copies of a consent sentence drift, and the drift is invisible + // to both sides: the menu would quietly stop explaining what quitting costs + // while the panel still did. Pin the menu's words to these. + const swiftPath = new URL("../../../../native/display-helper/Theme.swift", import.meta.url); + test("the native menu carries the same question and buttons", async () => { + // Whitespace collapsed, so a re-wrapping on either side cannot fail a check + // that is about the words. + const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " "); + for (const [name, value] of Object.entries({ + title: strings.quitTitle, + confirm: strings.quit, + cancel: strings.cancel, + })) { + expect(swift).toContain(`static let ${name} = ${JSON.stringify(value)}`); + } + }, 10_000); + + test("the native menu answers the sign-in question state by state, like the panel", async () => { + // The bodies are the part that used to lie: one sentence for all three + // states promised a sign-in return to people who never registered one. If + // Swift's copy of a state's sentence drifts from this one, the menu warns + // about a future the panel says will not happen. + const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " "); + for (const state of ["registered", "needsApproval", "notRegistered"] as const) { + for (const enabled of [true, false]) { + expect(swift).toContain(JSON.stringify(quitBody(state, enabled))); + } + } + }, 10_000); + + test("the panel's own strings keep the shape the alert depends on", () => { + // The Swift side treats the first button as the safe one; an empty label + // would hand Return to the destructive act. + expect(strings.cancel).toBeTruthy(); + expect(strings.quit).not.toBe(strings.cancel); + }); +}); + +describe("the native chrome's palette", () => { + // The helper still paints three colours by hand (its window ground, the + // muted label, the watching gold). Their numbers live in Theme.swift as + // oklch triples copied from packages/assistant/tokens.css, and a copy that + // is only compared against itself proves nothing: an editor who moves + // --warning in the shared file changes every web surface and leaves the + // menu bar's gold behind, with every native check still green. This reads + // both files, so that drift fails here. + const themePath = new URL("../../../../native/display-helper/Theme.swift", import.meta.url); + const tokensPath = new URL("../../../assistant/tokens.css", import.meta.url); + test("the swift triples are the shared file's triples, token by token", async () => { + const [swift, css] = await Promise.all([Bun.file(themePath).text(), Bun.file(tokensPath).text()]); + const block = /static let tokens: [^=]+ = \[([\s\S]*?)\n {4}\]/.exec(swift)?.[1] ?? ""; + const native = new Map<string, string>(); + for (const match of block.matchAll(/"([a-z0-9-]+)": \(([\d.]+), ([\d.]+), ([\d.]+)\)/g)) { + native.set(match[1] as string, `${match[2]} ${match[3]} ${match[4]}`); + } + // Both patterns take the whole shadcn name space, digits included + // (`--chart-1`): a name either regex cannot spell is a row compared to + // nothing, which is the same hole as a missed number. + // Count the rows loosely as well. A strict parser that silently misses an + // entry would otherwise certify the three it happened to read and let a + // fourth added token drift from the shared file unchecked, which is the + // exact failure this test exists to catch. + const declared = [...block.matchAll(/"([a-z0-9-]+)"\s*:\s*\(/g)].map((match) => match[1] as string); + expect(declared.sort()).toEqual([...native.keys()].sort()); + expect(native.size).toBeGreaterThan(0); + for (const [name, triple] of native) { + const declared = new RegExp(`--${name}: oklch\\(([^)]+)\\)`).exec(css); + expect(declared, `packages/assistant/tokens.css declares --${name}`).not.toBeNull(); + // The shared file writes the same three numbers; whitespace is the only + // thing allowed to differ. + expect((declared as RegExpExecArray)[1]?.replace(/\s+/g, " ").trim()).toBe(triple); + } + }, 10_000); +}); diff --git a/packages/helper-panel/src/main.ts b/packages/helper-panel/src/main.ts new file mode 100644 index 000000000..52ea84702 --- /dev/null +++ b/packages/helper-panel/src/main.ts @@ -0,0 +1,378 @@ +import "./styles.css"; +import { EMPTY_STATE, type PanelAction, type PanelMessage, type PanelState, type StateValue } from "./state"; +import { + consentControl, + loginItemLine, + permissionValue, + quitBody, + showsSignInSection, + stateLine, + strings, + viewerLine, +} from "./strings"; + +/** The bridge the Swift side installs. Absent in a browser, which is how the + * page stays previewable while every real act needs the helper process. */ +interface PanelBridge { + postMessage(message: PanelMessage): void; +} +declare global { + interface Window { + subshellPanelShown?: () => void; + subshellSetState?: (state: PanelState) => void; + webkit?: { messageHandlers?: { subshell?: PanelBridge } }; + } +} + +/** Which of the assistant's button looks to wear, in plain CSS. Only these two + * exist on purpose: a control that does something wears a shape, the one + * primary wears the gradient, and nothing is a bare hover-wash that reads as a + * link. */ +type Variant = "primary" | "outline" | "destructive"; + +function bridge(): PanelBridge | undefined { + return window.webkit?.messageHandlers?.subshell; +} + +function element<Tag extends keyof HTMLElementTagNameMap>( + tag: Tag, + className: string, + content?: string, +): HTMLElementTagNameMap[Tag] { + const el = document.createElement(tag); + el.className = className; + if (content !== undefined) el.textContent = content; + return el; +} + +function makeButton(label: string, variant: Variant): HTMLButtonElement { + const el = element("button", `btn btn-${variant}`); + el.type = "button"; + el.textContent = label; + return el; +} + +/** A control that asks the helper to do something. */ +function button(label: string, action: PanelAction, variant: Variant = "outline"): HTMLButtonElement { + const el = makeButton(label, variant); + el.addEventListener("click", () => bridge()?.postMessage({ action })); + return el; +} + +/** A control that does something in the page instead, like opening a dialog. */ +function pageButton(label: string, variant: Variant, onClick: () => void): HTMLButtonElement { + const el = makeButton(label, variant); + el.addEventListener("click", onClick); + return el; +} + +/** + * A section heading with the trailing rule: the assistant's `FormSectionTitle`. + * That rule is the only divider a section brings, so grouping reads as heading + * and spacing rather than as a stack of hairlines. + */ +function sectionTitle(label: string): HTMLElement { + const heading = element("h2", "section-title"); + const rule = element("span", "section-rule"); + rule.setAttribute("aria-hidden", "true"); + heading.append(element("span", "section-caption", label), rule); + return heading; +} + +function section(label: string, children: Node[]): HTMLElement { + const el = element("section", "section"); + el.append(sectionTitle(label), ...children); + return el; +} + +/** + * One labelled fact, in the dashboard's own shape: a strong muted `detail` + * label above a regular `body` value, weight, size, and colour distinguishing + * them together because muted colour alone is not enough to tell them apart. + */ +function fact(label: string, value: StateValue): HTMLElement { + const pair = element("div", "fact"); + pair.append( + element("dt", "fact-label", label), + element("dd", `fact-value${value.blocked ? " is-blocked" : ""}`, value.text), + ); + return pair; +} + +/** + * A fact and the one control that changes it. The value is gold when the + * person still has something to do and the page's own ink when it is settled, + * which is how the row tells "waiting on you" from "already true" at a glance. + */ +function row(label: string, value: StateValue, control?: HTMLElement): HTMLElement { + const el = element("dl", "row"); + el.append(fact(label, value)); + if (control) el.append(control); + return el; +} + +/** + * A permission row. While consent is off the buttons are `disabled` rather than + * hidden, because a control that is not there cannot be explained, and the + * reason is drawn inline in gold and named by aria-describedby: a native title + * is invisible to a person on touch who cannot hover, and unreliable for a + * screen reader. The helper refuses these requests as well, so a page that + * somehow fires one still gets a refusal instead of a prompt. + */ +interface PermissionRowParts { + /** The permission, as the row's label. */ + label: string; + /** macOS's answer, which decides which control the row gets. */ + granted: boolean; + /** The helper's own consent, which gates the prompt. */ + consented: boolean; + /** The request to make while macOS has not answered. */ + ask: { label: string; action: PanelAction }; + /** The pane to open once it has: the prompt has nothing left to say. */ + review: { label: string; action: PanelAction }; + /** The gold line explaining why the control is off. */ + reasonId: string; +} + +function permissionRow(parts: PermissionRowParts) { + // Every row carries a control, because the row that reads "Allowed" is also + // the row a person may want to go and check. What it may not do is offer the + // prompt again once macOS has answered it: that press would do nothing, and + // a button that does nothing reads as a hang. + const granted = parts.granted; + const control = granted + ? button(strings.openSettings, parts.review.action, "outline") + : button(strings.allow, parts.ask.action, "outline"); + control.setAttribute("aria-label", granted ? parts.review.label : parts.ask.label); + if (!granted) { + control.disabled = !parts.consented; + if (!parts.consented) control.setAttribute("aria-describedby", parts.reasonId); + } + return row(parts.label, permissionValue(granted), control); +} + +/** + * Whether the quit confirmation is up. Page-local and transient on purpose: + * it is not a fact about the machine, and the helper never reads it. It + * survives a state push because `render` draws it from here. + */ +let confirmingQuit = false; + +function closeQuitDialog(): void { + confirmingQuit = false; + render(current); +} + +/** + * The confirmation for the one act in this panel that is not reversible from + * the window itself: quitting. The title asks and names the action; the body + * says what stops, what stays, and how to get back, which is the part a silent + * quit never told anyone. Cancel is focused, and Escape dismisses, because the + * safe answer must be the easy one. + */ +function quitDialog(state: PanelState): HTMLElement { + const scrim = element("div", "scrim"); + const dialog = element("div", "dialog"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", "quit-title"); + dialog.setAttribute("aria-describedby", "quit-body"); + const title = element("h2", "dialog-title", strings.quitTitle); + title.id = "quit-title"; + const body = element("p", "dialog-body", quitBody(state.loginItem, state.enabled)); + body.id = "quit-body"; + const foot = element("div", "dialog-foot"); + const cancel = pageButton(strings.cancel, "outline", closeQuitDialog); + const confirm = pageButton(strings.quit, "destructive", () => { + closeQuitDialog(); + bridge()?.postMessage({ action: "quit" }); + }); + foot.append(cancel, confirm); + dialog.append(title, body, foot); + scrim.append(dialog); + // Focus the way out, not the act. `aria-modal` is a promise the background + // cannot be reached, and the scrim has no inert attribute to enforce it, so + // Tab is kept inside the two controls here rather than left to the browser. + scrim.tabIndex = -1; + scrim.addEventListener("keydown", (event) => { + if (event.key !== "Tab") return; + const controls = [cancel, confirm]; + const at = controls.indexOf(document.activeElement as HTMLButtonElement); + const step = event.shiftKey ? -1 : 1; + const next = (at + step + controls.length) % controls.length; + event.preventDefault(); + controls[next]?.focus(); + }); + queueMicrotask(() => cancel.focus()); + return scrim; +} + +window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && confirmingQuit) { + event.preventDefault(); + closeQuitDialog(); + } +}); + +function render(state: PanelState): void { + const root = document.getElementById("panel"); + if (!root) return; + const existingDialog = confirmingQuit ? root.querySelector<HTMLElement>(".scrim") : null; + const focused = existingDialog?.contains(document.activeElement) ? (document.activeElement as HTMLElement) : null; + root.replaceChildren(); + + const card = element("section", "card"); + const header = element("header", "head"); + header.append(element("h1", "title", strings.title), element("p", "subtitle", strings.subtitle)); + card.append(header); + + // role=alert so a refusal that arrives while the panel is open is announced, + // not just painted red. Above the controls, because it is why the screen + // still looks like this and has to be read before the failed button is + // pressed again. + if (state.error) { + const err = element("p", "error", state.error); + err.setAttribute("role", "alert"); + card.append(err); + } + + const content = element("div", "content"); + + // The warning sits above the button it warns about, not under it where a + // person has already pressed the thing. + // One call decides the button and the sentence under it, because the defect + // this replaces was a pair of ternaries reading the same state separately: the + // control said "Enable helper" while its own note described disabling, and a + // person deciding whether this Mac may be captured had that announcement to + // decide from. + const consent = consentControl(state); + const toggle = button(consent.label, consent.action, "primary"); + const note = element("p", "note", consent.note); + // One id for both states, named for what it is: it describes the consent act + // on offer, which flips with the button. "enable-note" read as a leftover the + // moment the button said "Disable helper", which is how a second describedby + // gets wired to the wrong half. + note.id = "consent-note"; + toggle.setAttribute("aria-describedby", "consent-note"); + content.append( + section(strings.access, [ + element("p", "state", stateLine(state)), + element("p", state.viewers > 0 ? "viewers is-live" : "viewers", viewerLine(state)), + element("p", "warning", strings.warning), + toggle, + note, + ]), + ); + + // The section appears with the consent that usually created a registration, + // and stays for a registration that outlived it: disabling leaves the person's + // sign-in choice alone, and this row is the only way to take it back. + if (showsSignInSection(state)) { + content.append( + section(strings.signInSection, [ + // One control, named for the state it is leaving: register, stop + // registering, or go to the setting macOS is waiting on. None of them + // happen by themselves when the helper is enabled. + row( + strings.loginItemLabel, + loginItemLine(state.loginItem), + state.loginItem === "registered" + ? button(strings.stopAtSignIn, "unregisterLoginItem") + : state.loginItem === "notRegistered" + ? button(strings.startAtSignIn, "registerLoginItem") + : button(strings.openLoginItems, "openLoginItems"), + ), + ]), + ); + } + + // One reason for both disabled buttons, drawn once in gold under the heading + // and named by each control's aria-describedby. + const blockedId = "permission-blocked"; + const reason = element("p", "blocked-reason", strings.permissionNeedsConsent); + reason.id = blockedId; + content.append( + section(strings.permissions, [ + ...(state.enabled ? [] : [reason]), + permissionRow({ + label: strings.capturePermissionLabel, + granted: state.screenRecording, + consented: state.enabled, + ask: { label: strings.requestCapture, action: "requestCapture" }, + review: { label: strings.openCaptureSettingsLabel, action: "openCaptureSettings" }, + reasonId: blockedId, + }), + permissionRow({ + label: strings.inputPermissionLabel, + granted: state.accessibility, + consented: state.enabled, + ask: { label: strings.requestInput, action: "requestInput" }, + review: { label: strings.openInputSettingsLabel, action: "openInputSettings" }, + reasonId: blockedId, + }), + ]), + ); + + card.append(content); + + // macOS puts a window's help at the bottom left and its exits at the bottom + // right, so that is where they go. What they may NOT do is wear different + // shapes: this row once mixed a plum text link with two buttons that have no + // outline until you hover them, and it read as three unrelated things that + // had wandered in from other screens. The primary stays in the card with the + // state it changes and the warning that says what it costs. + const bar = element("footer", "bar"); + const help = element("div", "bar-side"); + help.append(button(strings.openDocs, "openDocs")); + const exits = element("div", "bar-side bar-exits"); + // The bar's Quit opens the confirmation; only the dialog's Confirm posts + // `quit`, so the act still belongs to the helper and the page only asks. + const askQuit = pageButton(strings.quit, "outline", () => { + confirmingQuit = true; + render(current); + }); + exits.append(button(strings.minimize, "minimize"), askQuit); + bar.append(help, exits); + card.append(bar); + + root.append(card); + if (confirmingQuit) { + const dialog = existingDialog ?? quitDialog(state); + const body = dialog.querySelector("#quit-body"); + if (body) body.textContent = quitBody(state.loginItem, state.enabled); + root.append(dialog); + focused?.focus(); + } + + // The Swift window sizes to the document, so the page reports both of its + // own dimensions rather than the window guessing and the content scrolling. + const box = root.getBoundingClientRect(); + bridge()?.postMessage({ + action: "resize", + width: Math.ceil(Number.isFinite(box.width) ? box.width : 0), + height: Math.ceil(Number.isFinite(box.height) ? box.height : 0), + }); +} + +let current: PanelState = EMPTY_STATE; + +/** Called by the helper after every change: consent, a permission grant, a + * viewer arriving or leaving, a refusal. The page keeps no state of its own, + * so a missed push is impossible: the last one wins. */ +window.subshellSetState = (state: PanelState) => { + current = { ...EMPTY_STATE, ...state }; + render(current); +}; + +render(current); + +// AppKit keeps the document alive when the settings window closes. +document.addEventListener("visibilitychange", () => { + if (document.hidden && confirmingQuit) closeQuitDialog(); +}); + +// AppKit explicitly signals reopening; WebKit visibility events alone aren't +// reliable when a retained native window is hidden rather than unloaded. +window.subshellPanelShown = () => { + if (confirmingQuit) closeQuitDialog(); +}; diff --git a/packages/helper-panel/src/state.ts b/packages/helper-panel/src/state.ts new file mode 100644 index 000000000..1ed981411 --- /dev/null +++ b/packages/helper-panel/src/state.ts @@ -0,0 +1,68 @@ +/** + * The state the helper pushes into the page. The page owns no state of its + * own: every field is a fact about the machine that only the Swift side can + * know, and the panel is a render of the latest one. + */ +export interface PanelState { + /** The person's own consent, stored by the helper. */ + enabled: boolean; + screenRecording: boolean; + accessibility: boolean; + /** Virtual displays being captured right now, so the local user can see when someone is looking. */ + viewers: number; + /** The last refusal, shown in red. Empty when there is nothing to report. */ + error: string; + /** macOS's own answer about the sign-in registration, not a guess built from one case. */ + loginItem: LoginItemState; +} + +/** + * The three states `SMAppService.Status` can report about this helper's login + * item that a person can act on. They are kept apart because each needs a + * different sentence: `requiresApproval` means macOS is waiting for a switch + * the person has not thrown, while `notRegistered` means the item is gone from + * the list, which is what turning it OFF in System Settings does. Collapsing + * the two is how a disabled sign-in start kept reading "Starts at sign-in". + */ +export type LoginItemState = "registered" | "needsApproval" | "notRegistered"; + +export const EMPTY_STATE: PanelState = { + enabled: false, + screenRecording: false, + accessibility: false, + viewers: 0, + error: "", + loginItem: "notRegistered", +}; + +/** + * A state word and whether it still needs the person to act on it. Gold is the + * design system's "the owner can fix this here"; the page's own ink is a fact + * that is already true. + */ +export interface StateValue { + text: string; + blocked: boolean; +} + +/** What the page sends back. One field, an action name, keeps the bridge small enough to audit. */ +export type PanelAction = + | "enable" + | "disable" + | "registerLoginItem" + | "unregisterLoginItem" + | "requestCapture" + | "requestInput" + | "openCaptureSettings" + | "openInputSettings" + | "openLoginItems" + | "openDocs" + | "minimize" + | "quit"; + +/** + * What the page sends back. `{ action: "resize" }` is the one message the + * helper answers without a person asking: the window sizes to the document, so + * the document reports what it needs and nothing inside it scrolls. + */ +export type PanelMessage = { action: PanelAction } | { action: "resize"; width: number; height: number }; diff --git a/packages/helper-panel/src/strings.ts b/packages/helper-panel/src/strings.ts new file mode 100644 index 000000000..ccaa0415b --- /dev/null +++ b/packages/helper-panel/src/strings.ts @@ -0,0 +1,195 @@ +import type { LoginItemState, PanelAction, PanelState, StateValue } from "./state"; + +/** + * Every word the panel shows, in one file. The copy is the interface: a + * consent surface that does not say plainly what it enables has not asked for + * consent. Keeping it pure is also what makes it testable without a web view. + */ +export const strings = { + title: "Subshell virtual display helper", + subtitle: "Let Subshell viewers see and control virtual displays on this Mac.", + warning: + "Virtual displays share your Mac login, keyboard focus, and pointer. Viewers can control this Mac and may see other windows on the display. Enable the helper only for people you trust.", + access: "Access", + /* Its own section, not a row under Access: whether the helper comes back + * after a sign-in is macOS's launch bookkeeping, a different question from + * whether the person has agreed to be captured. The heading is also what + * lets the enable note below stop claiming the fact twice. */ + signInSection: "Launch at sign-in", + permissions: "macOS permissions", + /* The sign-in choice is the person's own, so each state offers the one act + * that changes it: register, stop registering, or go where macOS is waiting + * for approval. Enabling the helper never registers anything by itself. */ + startAtSignIn: "Start at sign-in", + stopAtSignIn: "Stop at sign-in", + loginItemPending: "Waiting for your approval in System Settings under Login Items.", + loginItemRegistered: "Starts when you sign in to macOS.", + /* No advice about toggling consent: the button beside this row registers it, + * and enabling the helper no longer does, so sending the person to disable + * and re-enable would be a detour to the same act. */ + loginItemMissing: "Won't start when you sign in.", + loginItemLabel: "Login item", + openLoginItems: "Open login items…", + openDocs: "Setup instructions", + minimize: "Minimize helper", + quit: "Quit helper", + /* The confirmation's shape. Its body is `quitBody` below, because what the + * helper does after a sign-in depends on a state the panel can already see. */ + quitTitle: "Quit the display helper?", + cancel: "Cancel", + /* What the button says, and what it says to a screen reader. The visible + * label is one word because the row already names the permission; the + * accessible name has to stand alone in a list of controls, where two + * buttons called "Allow" would be indistinguishable. */ + allow: "Allow", + requestCapture: "Allow screen recording", + requestInput: "Allow accessibility", + /* The granted rows keep a control. What they cannot do is offer the prompt + * again: macOS has nothing left to ask, so a press would look like a hang. + * This opens the pane where the grant lives, and the ellipsis is honest + * because another app is about to take over. */ + openSettings: "Open settings…", + openCaptureSettingsLabel: "Open screen recording settings", + openInputSettingsLabel: "Open accessibility settings", + permissionNeedsConsent: "Enable the helper before requesting macOS permissions.", + /* The two row labels name macOS's own panes, so they read the way System + * Settings reads. They live here for the same reason as everything else the + * page says: a word kept outside this file is a word no wording review, voice + * check, or parity test ever sees. */ + capturePermissionLabel: "Screen recording", + inputPermissionLabel: "Accessibility", +} as const; + +/** + * The consent button's label and its sentence, per state. + * + * Deliberately NOT members of `strings` above. The defect these exist to prevent + * is a page module taking the label and the note from different states, and a + * property that lives on an exported object is a property any module can reach by + * some spelling: `strings["enableNote"]`, `"disable" + "N" + "ote"`, + * `Reflect.get`, `Object.values({...strings})[8]`. Round 22 chased those with + * patterns and round 23 proved regexes cannot evaluate an escape. What nothing can + * reach is what was never exported, so the four strings are module-private here + * and `consentControl` is the only door through them. Both are `detail` text at + * one size in muted ink (gold belongs to what the owner can still act on). + */ +const consentCopy = { + enableLabel: "Enable helper", + disableLabel: "Disable helper", + enableNote: + "Enabling allows capture and input once both macOS permissions are granted. Your subshells keep running, and your macOS permissions and sign-in choice don't change.", + disableNote: + "Disabling disconnects viewers and stops capture and input. Your subshells keep running, and your macOS permissions and sign-in choice don't change.", +}; + +/** + * The consent control and the note that explains it, decided together. + * + * Both used to read `state.enabled` at their own site, three ternaries deep in + * the render, and that is exactly how the pair got out of step: the button said + * "Enable helper" while its own note said "Disabling stops capture and input", + * so the sentence a screen reader announces for the act being refused was the + * sentence for the opposite act. One call, one state read, no way to pair them + * wrongly. + */ +export function consentControl(state: PanelState): { label: string; action: PanelAction; note: string } { + return state.enabled + ? { label: consentCopy.disableLabel, action: "disable", note: consentCopy.disableNote } + : { label: consentCopy.enableLabel, action: "enable", note: consentCopy.enableNote }; +} + +/** The one-line answer to "is this machine exposing itself right now". */ +export function stateLine(state: PanelState): string { + if (!state.enabled) return "Disabled. Nothing is captured and no input is accepted."; + return state.screenRecording && state.accessibility + ? "Enabled. Capture and input are allowed by macOS." + : "Enabled. Capture and input still require both macOS permissions below."; +} + +/** Gold while a viewer holds a screen, muted when nobody does. */ +export function viewerLine(state: PanelState): string { + if (state.viewers <= 0) return "No one is viewing a virtual display on this Mac."; + return state.viewers === 1 + ? "A Subshell viewer is watching this Mac's virtual display right now." + : `${state.viewers} Subshell viewers are watching this Mac's virtual displays right now.`; +} + +/** + * The login item's own line. Registered is a settled fact and reads in the + * page's own ink; awaiting macOS is the thing still waiting on them, so it + * reads in gold, the same split the permission rows use. + */ +export function loginItemLine(state: LoginItemState): StateValue { + switch (state) { + case "registered": + return { text: strings.loginItemRegistered, blocked: false }; + case "needsApproval": + return { text: strings.loginItemPending, blocked: true }; + case "notRegistered": + return { text: strings.loginItemMissing, blocked: true }; + } +} + +/** + * Whether the **Launch at sign-in** section earns its heading. Consent is what + * usually creates a registration, but it is not the only thing that keeps one: + * disabling leaves the person's sign-in choice alone on purpose, and that row + * holds the only button that takes it back off macOS's list. Hiding the section + * with the consent toggle would strand a registered helper with no way to + * unregister it short of enabling capture again. + */ +export function showsSignInSection(state: PanelState): boolean { + return state.enabled || state.loginItem !== "notRegistered"; +} + +/** + * The quit confirmation's body, which has to answer the sign-in question from + * the state the panel already holds. Enabling never registered a login item and + * disabling never removes one, so whether this process returns at the next + * sign-in is a fact about that row, not a promise a dialog may make: saying + * "it comes back at sign-in" to someone who never asked for it is how the + * warning that exists to inform a decision ends up misleading instead. Every + * variant names what stops, what stays, and how to get it back. + */ +const LOSS = "Quitting disconnects viewers and stops capture and input. Your subshells keep running."; +const STAYS = "Your helper settings and macOS permissions don't change."; + +/** + * One whole sentence per reachable pair rather than a shared prefix, so the + * helper's Swift copy can be compared to these literal for literal. + * + * The consent half matters as much as the registration half. A registered + * helper that is switched off does come back at sign-in, and then exits + * without capturing anything (`main.swift` terminates a consentless start), so + * promising "it starts again at sign-in" to that person describes a return that + * delivers no screen and sends them to a button they do not need. Naming the + * state is what makes the sentence actionable in the right direction. + */ +const QUIT_BODIES: Record<LoginItemState, { enabled: string; disabled: string }> = { + registered: { + enabled: `${LOSS} ${STAYS} The helper starts again at sign-in, or you can reopen it sooner from Subshell's virtual display settings. To turn off automatic startup, cancel and choose Stop at sign-in in the panel.`, + disabled: `${LOSS} ${STAYS} The helper is disabled, so it exits without capturing anything when macOS starts it at sign-in. To remove it from macOS's login list, cancel and choose Stop at sign-in in the panel.`, + }, + needsApproval: { + enabled: `${LOSS} ${STAYS} It won't start at sign-in until you approve it in macOS. You can reopen it from Subshell's virtual display settings.`, + disabled: `${LOSS} ${STAYS} It won't start at sign-in until you approve it in macOS. You can reopen it from Subshell's virtual display settings.`, + }, + notRegistered: { + enabled: `${LOSS} ${STAYS} It won't start at sign-in. You can reopen it from Subshell's virtual display settings.`, + disabled: `${LOSS} ${STAYS} It won't start at sign-in. You can reopen it from Subshell's virtual display settings.`, + }, +}; + +export function quitBody(loginItem: LoginItemState, enabled: boolean): string { + return QUIT_BODIES[loginItem][enabled ? "enabled" : "disabled"]; +} + +/** + * The state word, and whether it is something the person can still act on. + * Gold is the design system's "the owner can fix this here"; white is a + * settled fact. Reading both the same way is how an ungranted permission stops + * being the thing to notice. + */ +export function permissionValue(granted: boolean): StateValue { + return granted ? { text: "Allowed", blocked: false } : { text: "Not allowed", blocked: true }; +} diff --git a/packages/helper-panel/src/styles.css b/packages/helper-panel/src/styles.css new file mode 100644 index 000000000..944713c49 --- /dev/null +++ b/packages/helper-panel/src/styles.css @@ -0,0 +1,338 @@ +/* The macOS display helper's panel. Colours and type come from the one shared + palette; the shapes are the control plane's, taken from the components a + person already meets in the dashboard: + - `Card` (packages/node-admin/src/ui/card.tsx): bg-card, a --border hairline, + radius 8, 16px content padding. + - `Fact` (packages/node-admin/src/components/fact-list.tsx): a strong + `detail` label in muted-foreground above a regular `body` value in + foreground. + - `FormSectionTitle` (packages/assistant/src/form-section-title.tsx): a label + role heading with a trailing rule. + Flat-on-the-void with inline "label: value" text was the defect that made + this read as another product's dialog. Spacing stays on the 4px grid and the + one motion is 150ms micro-feedback, both per docs/design-system.md. */ +@import "../../assistant/tokens.css"; + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + /* The window's ground is the page void; the card is the plate on it, the way + a card sits on the dashboard's background. */ + background: var(--background); + color: var(--foreground); + font-family: var(--default-font-family); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + -webkit-font-smoothing: antialiased; +} + +/* The document owns its width and the Swift window sizes to it, which is why + the number appears once. 520 fits the card's longest row without wrapping. */ +.panel { + display: flex; + flex-direction: column; + width: 520px; + padding: 16px; +} + +.card { + display: flex; + flex-direction: column; + flex: 1; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--foreground); +} + +/* CardHeader: a heading title over a detail description, 4px apart. */ +.head { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; +} + +.title { + margin: 0; + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + font-weight: var(--font-weight-strong); +} + +.subtitle { + margin: 0; + color: var(--muted-foreground); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); +} + +/* The refusal reads under the header, the way a dialog's problem line is read + before the control that failed. role=alert announces it; it is the only live + region, because the card is rebuilt on every state push. */ +.error { + margin: 0; + padding: 0 16px 12px; + color: var(--destructive); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); +} + +.content { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; + padding: 0 16px 16px; +} + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-title { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + font-size: var(--text-label); + line-height: var(--text-label--line-height); + font-weight: var(--font-weight-strong); +} + +.section-rule { + flex: 1; + min-width: 24px; + border-top: 1px solid var(--border); +} + +.state, +.viewers, +.warning, +.note { + margin: 0; + font-size: var(--text-body); + line-height: var(--text-body--line-height); +} + +/* An explanation a control gives about itself is `detail` at one size: the + hint, the reason a control is off. `body` is running text, not help. */ +.viewers, +.note { + color: var(--muted-foreground); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); +} + +/* Gold is "the person who owns this can act on it", the role the dashboard + gives text-warning: what enabling exposes, and who is watching right now. */ +.warning, +.viewers.is-live { + color: var(--warning); +} + +/* A row is a Fact and the one control that changes it: the label and value + stacked on the leading edge, the control at the trailing one. The button + never wraps its own label, so a long value and a short control share the row + honestly instead of fighting for the same pixels. */ +.row { + display: flex; + align-items: center; + gap: 12px; +} + +.fact { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + margin: 0; +} + +.fact-label { + color: var(--muted-foreground); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); + font-weight: var(--font-weight-strong); +} + +.fact-value { + margin: 0; + color: var(--foreground); + font-size: var(--text-body); + line-height: var(--text-body--line-height); +} + +.fact-value.is-blocked { + color: var(--warning); +} + +/* A disabled control explains itself to someone who cannot hover it, so the + reason is drawn inline in gold and named by aria-describedby rather than + hidden in a native title attribute. */ +.blocked-reason { + margin: 0; + color: var(--warning); + font-size: var(--text-detail); + line-height: var(--text-detail--line-height); +} + +.btn { + /* The assistant's `Button`: h-9 at px-4 py-2, rounded-md, strong at body + size, content-sized rather than stretched across the card. */ + display: inline-flex; + align-self: flex-start; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 36px; + padding: 8px 16px; + border: 1px solid transparent; + border-radius: 6px; + background: none; + color: var(--foreground); + font-family: inherit; + font-size: var(--text-body); + font-weight: var(--font-weight-strong); + white-space: nowrap; + transition: + background-color 150ms, + color 150ms; +} + +.btn:hover { + background: var(--accent); + color: var(--accent-foreground); +} + +.btn:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:disabled:hover { + background: none; + color: var(--foreground); +} + +/* Placed after `.btn` so the more specific selector wins by order as well as by + weight: only the control goes to the trailing edge, while the label and its + value stay stacked together on the leading one. */ +.row .btn { + margin-left: auto; +} + +/* Dreamframe: the primary is a sunk plum gradient with frost text, quieter + than --primary, which stays bright for links and glows. */ +.btn-primary { + background-image: linear-gradient(135deg, var(--button-primary-from), var(--button-primary-to)); + color: var(--button-primary-foreground); +} + +.btn-primary:hover { + background-image: linear-gradient(135deg, var(--button-primary-hover-from), var(--button-primary-hover-to)); + color: var(--button-primary-foreground); +} + +/* The look the assistant gives a control that really does something. Every + control in this card wears it except the one primary. */ +.btn-outline { + border-color: var(--input); + background: var(--card); +} + +/* The assistant's destructive variant: the act that cannot be undone from + here wears red, and it is the last control in the row. */ +.btn-destructive { + background: var(--destructive); + color: var(--destructive-foreground); +} + +.btn-destructive:hover { + background: var(--destructive); + color: var(--destructive-foreground); + filter: brightness(0.94); +} + +/* The confirmation, drawn the way the dashboard draws a dialog: the shared + scrim, a popover plate on the radius, a question that names the action, and + the consequences under it. Cancel is the outline control and Quit the + destructive one, because the reversible answer should be the cheaper one. */ +.scrim { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--scrim); +} + +.dialog { + display: flex; + flex-direction: column; + gap: 12px; + width: 380px; + padding: 16px; + background: var(--popover); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--popover-foreground); +} + +.dialog-title { + margin: 0; + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + font-weight: var(--font-weight-strong); +} + +.dialog-body { + margin: 0; + font-size: var(--text-body); + line-height: var(--text-body--line-height); + color: var(--muted-foreground); +} + +.dialog-foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +/* CardFooter: the hairline above it, help at the leading edge, the exits at the + trailing one. The bottom corners follow the plate, inset by its 1px border. */ +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 64px; + padding: 16px; + border-top: 1px solid var(--border); + border-radius: 0 0 7px 7px; +} + +.bar-side { + display: flex; + align-items: center; + gap: 8px; +} + +.bar-exits { + margin-left: auto; +} diff --git a/packages/helper-panel/src/vite-env.d.ts b/packages/helper-panel/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/helper-panel/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/packages/helper-panel/tsconfig.json b/packages/helper-panel/tsconfig.json new file mode 100644 index 000000000..11b215af6 --- /dev/null +++ b/packages/helper-panel/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@internal/tsconfig/tsconfig.json", + "include": [ + "src/**/*", + "inline.ts", + "vite.config.ts" + ], + "compilerOptions": { + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "types": [ + "bun" + ], + "noEmit": true, + "allowImportingTsExtensions": true + } +} diff --git a/packages/helper-panel/turbo.json b/packages/helper-panel/turbo.json new file mode 100644 index 000000000..fb139b71c --- /dev/null +++ b/packages/helper-panel/turbo.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "clean": {}, + "build": { + "dependsOn": ["lint"], + "inputs": ["*.json", "*.ts", "*.html", "src/**"], + "outputs": ["dist/**"] + }, + "lint": { + "inputs": ["*.ts", "*.json", "src/**"] + }, + "verify-types": { + "inputs": ["*.ts", "*.json", "src/**"] + } + } +} diff --git a/packages/helper-panel/vite.config.ts b/packages/helper-panel/vite.config.ts new file mode 100644 index 000000000..ca1a12f56 --- /dev/null +++ b/packages/helper-panel/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "vite"; +import { panelSingleFile } from "./inline.ts"; + +/** + * The macOS display helper's settings panel, built to `dist/` and copied into + * the helper bundle by `native/display-helper/build_support.rs`. + * + * The output is ONE self-contained `index.html`, which `panelSingleFile` + * enforces. It is not about size. The helper loads the page with `loadFileURL`, + * and WebKit gives a `file://` document an opaque origin, so vite's usual + * `crossorigin` module script and stylesheet become CORS requests from a `null` + * origin that WebKit refuses: the document arrives, nothing else does, and the + * person gets a white window. Inlined, the page issues no request at all, so + * what it shows can only come from the helper's own bundle, and there is one + * signed file to check rather than a folder of them. + */ +export default defineConfig({ + // Relative anyway, so a leaked URL would point inside the bundle rather than + // at a server; the inline plugin refuses to leave one in the document. + base: "./", + plugins: [panelSingleFile()], + build: { + outDir: "dist", + emptyOutDir: true, + target: "es2022", + // One stylesheet rather than styles injected by the script: the panel's + // chrome should paint correctly from the document itself, so a script that + // fails cannot bring back the white window this build exists to prevent. + cssCodeSplit: false, + // The inlined script is a classic script, so it must not be emitted as a + // module, and nothing may fetch it through a preload link either. + modulePreload: false, + rollupOptions: { + output: { format: "iife" }, + }, + }, +}); diff --git a/packages/mcp-core/src/__tests__/view-tools.test.ts b/packages/mcp-core/src/__tests__/view-tools.test.ts index 04bb74c8f..938706abd 100644 --- a/packages/mcp-core/src/__tests__/view-tools.test.ts +++ b/packages/mcp-core/src/__tests__/view-tools.test.ts @@ -61,6 +61,179 @@ describe("MCP preview discovery and launch", () => { expect(calls.every((call) => call.method === "GET")).toBe(true); expect(calls.at(-1)?.path).toBe(`/api/nodes/${node.id}/display-readiness?provider=betterdisplay`); }); + it("calls a provider outside the catalog disabled, the way create refuses it", async () => { + // The route that writes this list checks it against the catalog, so a row + // reaching an id this build does not ship arrives by some other door: a + // hand-edit, a restore, or a later release shrinking the catalog out from + // under a stored value. `resolveNodeDisplayRequest` refuses it at create + // with `preview-display-unsupported` whatever its source, so this mirrors + // that check rather than the writer's, and an agent is never told a thing is + // ready by the same tool that would recommend the launch which 400s. + const stored = ["xvfb-x11vnc", "my-x11-thing"]; + const { deps } = fixture((path) => + path === "/api/nodes" + ? { + nodes: [ + { + ...node, + os: "linux", + virtualDisplayEnabled: true, + virtualDisplayProvider: "my-x11-thing", + virtualDisplayProviders: stored, + }, + ], + } + : { supported: false, missing: [] }, + ); + const asDefault = await getNode(deps, { node: node.id }); + expect(asDefault.virtualDisplay).toMatchObject({ provider: "my-x11-thing", enabled: false }); + // Asked for explicitly, same answer; and a catalog provider on the same node + // is still enabled, so this is the id being refused, not the node. + const asAsk = await getNode(deps, { node: node.id, provider: "my-x11-thing" }); + expect(asAsk.virtualDisplay).toMatchObject({ provider: "my-x11-thing", enabled: false }); + const bundled = await getNode(deps, { node: node.id, provider: "xvfb-x11vnc" }); + expect(bundled.virtualDisplay).toMatchObject({ provider: "xvfb-x11vnc", enabled: true }); + }); + + it("answers an absent enabled list with the protocol's default, as the plane does", async () => { + // `storedDisplayProviderIds` reads an absent row as `[DEFAULT_VIRTUAL_DISPLAY_PROVIDER]`, + // and the gate above claims to mirror it. No other fixture left the field out + // with the switch on, so the claim was untested: swapping the default for `[]` + // left every test green while an older plane's node reported each provider + // disabled here and launched fine. + const { deps } = fixture((path) => + path === "/api/nodes" + ? { nodes: [{ ...node, os: "linux", virtualDisplayEnabled: true }] } + : { supported: true, missing: [] }, + ); + expect((await getNode(deps, { node: node.id })).virtualDisplay).toMatchObject({ + provider: "xvfb-x11vnc", + enabled: true, + }); + }); + + it("refuses a catalog provider the owner's enabled list leaves out", async () => { + // The membership clause had no fixture of its own: every stored list here + // contained the provider being asked about, and the case that did not + // (`native-example`) is refused by the catalog clause first. Replacing + // `allowed.includes(provider)` with `true` left the whole suite green while + // get_node promised a provider the owner had switched off and create answered + // `preview-display-provider-disabled`. The OS is left unanswered on purpose, so + // membership is the only clause that can say no to this one. + const { deps } = fixture((path) => + path === "/api/nodes" + ? { + nodes: [ + { + ...node, + os: "unknown", + virtualDisplayEnabled: true, + virtualDisplayProvider: "xvfb-x11vnc", + virtualDisplayProviders: ["xvfb-x11vnc"], + }, + ], + } + : { supported: true, missing: [] }, + ); + expect((await getNode(deps, { node: node.id, provider: "betterdisplay" })).virtualDisplay).toMatchObject({ + provider: "betterdisplay", + enabled: false, + }); + }); + + it("reports a provider that cannot run on this node's OS as disabled, the way create refuses it", async () => { + // BetterDisplay is macOS-only, and the plane refuses it on a Linux node outright + // with `preview-display-unsupported`. This gate used to be left to `status`, which + // is the NODE's answer and goes silent whenever the probe cannot run at all: a + // 409 or 502 gave the agent `enabled: true, status: "unavailable"` for a provider + // that could never launch. The probe is answered as a Linux node really answers + // it, unsupported, to show the gate holds regardless of what readiness says. + const { deps } = fixture((path) => + path === "/api/nodes" + ? { + nodes: [ + { + ...node, + os: "linux", + virtualDisplayEnabled: true, + virtualDisplayProvider: "betterdisplay", + virtualDisplayProviders: ["betterdisplay"], + }, + ], + } + : { supported: false, missing: [] }, + ); + expect((await getNode(deps, { node: node.id })).virtualDisplay).toMatchObject({ + provider: "betterdisplay", + enabled: false, + status: "unsupported", + }); + }); + + it("promises nothing at all when the probe cannot answer", async () => { + // The honest shape, pinned because it is the one an agent reads as a go-ahead: + // with no probe answer the tool reports the state of the question and stops. + // Neither `enabled` nor `provider` may appear here, because either would be a + // promise about a launch the plane was never asked about. Add `enabled: true` to + // that arm and this is the test that says so. + const { deps } = fixture((path) => { + if (path === "/api/nodes") + return { + nodes: [ + { + ...node, + os: "linux", + virtualDisplayEnabled: true, + virtualDisplayProvider: "betterdisplay", + virtualDisplayProviders: ["betterdisplay"], + }, + ], + }; + throw new ApiError(502, "display probe unavailable"); + }); + expect((await getNode(deps, { node: node.id })).virtualDisplay).toEqual({ + status: "unavailable", + reason: "display probe unavailable", + }); + }); + + it("treats a held node as no answer at all, never as a display that is ready", async () => { + // `held` is the plane's connection-hold flag, and an online-but-held node gets + // no probe. Dropping `|| node.held` from the guard kept every test green and + // sent the tool on to a readiness read that cannot succeed there. + const { deps, calls } = fixture((path) => + path === "/api/nodes" + ? { + nodes: [ + { + ...node, + virtualDisplayEnabled: true, + held: true, + virtualDisplayProvider: "xvfb-x11vnc", + virtualDisplayProviders: ["xvfb-x11vnc"], + }, + ], + } + : { supported: true, missing: [] }, + ); + expect((await getNode(deps, { node: node.id })).virtualDisplay.status).toBe("unavailable"); + expect(calls.every((call) => call.path === "/api/nodes")).toBe(true); + }); + + it("reads a 502 from the readiness probe as no answer, like the 409", async () => { + // The plane answers 502 when its own probe cannot run. Both codes share one + // arm, and a suite that only ever threw 409 would let the 502 fall through to + // a tool failure, which reads to an agent as "broken" rather than "not now". + const { deps } = fixture((path) => { + if (path === "/api/nodes") return { nodes: [node] }; + throw new ApiError(502, "display probe unavailable"); + }); + expect((await getNode(deps, { node: node.id })).virtualDisplay).toMatchObject({ + status: "unavailable", + reason: "display probe unavailable", + }); + }); + it("preserves capability settings and honest harness health", async () => { const facts = { webPreviewsEnabled: false, diff --git a/packages/mcp-core/src/view-tools.ts b/packages/mcp-core/src/view-tools.ts index 97a136d76..5b9fef527 100644 --- a/packages/mcp-core/src/view-tools.ts +++ b/packages/mcp-core/src/view-tools.ts @@ -1,4 +1,10 @@ -import { type NodePreviewScreenWire, parsePreviewDisplayReadiness } from "@internal/subshell-protocol"; +import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + displayProviderRunsOnOs, + type NodePreviewScreenWire, + parsePreviewDisplayReadiness, + VIRTUAL_DISPLAY_PROVIDERS, +} from "@internal/subshell-protocol"; import { z } from "zod"; import { ApiError } from "./api-client.js"; import { listNodes, resolveNodeId } from "./subshell-tools.js"; @@ -52,16 +58,32 @@ export async function getNode(deps: ToolDeps, args: z.infer<typeof GetNodeSchema ), ); if (!readiness) throw new Error("subshell: invalid display readiness response; update the server and retry"); + // `enabled` is the plane's whole gate, the same four checks + // `resolveNodeDisplayRequest` makes, so an agent that trusts it is never + // surprised by the create: the switch, the catalog (an id we do not ship is + // refused outright, even when the owner's stored list carries it), the stored + // enabled list, whose absent answer is `[DEFAULT_VIRTUAL_DISPLAY_PROVIDER]`, and + // whether this provider runs on this node's operating system. The fourth one used + // to be left to `status` on the theory that it is the machine's answer, and that + // theory is only as good as the report: readiness comes from the node, so a build + // that failed to gate a provider on its platform would have this tool answer + // `enabled: true, status: "ready"` for a launch the plane refuses outright with + // `preview-display-unsupported`. So `enabled` is the plane's answer in full, + // `status` stays the node's, and where the node cannot answer at all the arm below + // emits neither field. `displayProviderRunsOnOs` stays true for an OS the node has + // not reported yet, which is what the plane does too. + const provider = args.provider ?? node.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER; + const allowed = node.virtualDisplayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER]; return { ...node, virtualDisplay: { ...readiness, - provider: args.provider ?? node.virtualDisplayProvider ?? "xvfb-x11vnc", + provider, enabled: node.virtualDisplayEnabled === true && - (node.virtualDisplayProviders ?? [node.virtualDisplayProvider ?? "xvfb-x11vnc"]).includes( - args.provider ?? node.virtualDisplayProvider ?? "xvfb-x11vnc", - ), + VIRTUAL_DISPLAY_PROVIDERS.some((entry) => entry.id === provider) && + allowed.includes(provider) && + displayProviderRunsOnOs(provider, node.os), status: !readiness.supported ? "unsupported" : readiness.missing.length ? "missing-programs" : "ready", }, }; diff --git a/packages/pane-runtime/src/__tests__/tmux-runner-argv.test.ts b/packages/pane-runtime/src/__tests__/tmux-runner-argv.test.ts index 866d2bba7..3c7d2fd93 100644 --- a/packages/pane-runtime/src/__tests__/tmux-runner-argv.test.ts +++ b/packages/pane-runtime/src/__tests__/tmux-runner-argv.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -85,6 +85,33 @@ describe("TmuxRunner", () => { } }); + it("pipe-pane with replace:true drops the -o, which is what re-arms a dead child", async () => { + // The capture-child guard (apps/node/agent report.ts) re-arms the writer. + // -o lets tmux treat a configured pipe as "already on" and no-op the + // re-arm (the flag cannot arbitrate: measured both 0 and 1 for a dead + // writer on the same build), so the guard re-issues WITHOUT -o. Pin that + // the option changes exactly that word, nothing else in the command. + const stubDir = mkdtempSync(join(tmpdir(), "subshell-tmux-stub-")); + const argvFile = join(stubDir, "argv.txt"); + const stub = join(stubDir, "tmux-stub"); + writeFileSync(stub, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argvFile}"\n`, { mode: 0o755 }); + try { + const child = { command: "/usr/bin/subshell", args: ["pane-log"] }; + new TmuxRunner(stub).pipePane("sock", "s1", "/tmp/live.log", child, { replace: true }); + const argv = (await Bun.file(argvFile).text()).trim().split("\n"); + expect(argv).toEqual([ + "-L", + "sock", + "pipe-pane", + "-t", + "s1", + `(umask 077; exec '/usr/bin/subshell' 'pane-log' --file '/tmp/live.log')`, + ]); + } finally { + rmSync(stubDir, { recursive: true, force: true }); + } + }); + // Issue #244: tmux's command parser eats a trailing `;` out of every string // argument BEFORE `-l` can make it literal (full account on // encodeTrailingSemicolon), so a `;` typed into a pane typed nothing at all, @@ -519,4 +546,38 @@ echo "server exited unexpectedly" >&2; exit 1 expect(elapsed).toBeLessThan(4_900); }); }); + + describe("paneCursor parsing refuses to guess", () => { + /** A stub that prints `value` for ANY tmux call. */ + const cursorStub = (value: string): TmuxRunner => { + const { dir, path } = writeStub(`#!/bin/sh\nprintf '%s\\n' '${value}'\n`); + stubDirs.push(dir); + return new TmuxRunner(path); + }; + const stubDirs: string[] = []; + afterAll(() => { + for (const dir of stubDirs) rmSync(dir, { recursive: true, force: true }); + }); + + it("an empty field is NO ANSWER, never row/column zero", async () => { + // Measured on a live pane mid-redraw (2026-10-27): tmux can print ":" + // — both fields blank. `Number("")` is 0, so a lenient parse turns a + // format failure into a CONFIDENT top-left cursor, the replay ends + // 16-odd rows off the pane, and every later relative-positioned frame + // (Ink's pinned-block repaints) lands on the wrong rows for the whole + // life of that viewer. The no-restore degrade is the honest answer. + expect(await cursorStub(":").paneCursor("sock", "s1")).toBeNull(); + expect(await cursorStub("3:").paneCursor("sock", "s1")).toBeNull(); + expect(await cursorStub(":7").paneCursor("sock", "s1")).toBeNull(); + expect(await cursorStub("").paneCursor("sock", "s1")).toBeNull(); + expect(await cursorStub("garbage").paneCursor("sock", "s1")).toBeNull(); + expect(await cursorStub("1:2:3").paneCursor("sock", "s1")).toBeNull(); + }); + + it("accepts the real grid answers, zero included", async () => { + expect(await cursorStub("0:0").paneCursor("sock", "s1")).toEqual({ x: 0, y: 0 }); + expect(await cursorStub("3:2").paneCursor("sock", "s1")).toEqual({ x: 3, y: 2 }); + expect(await cursorStub(" 16:4 ").paneCursor("sock", "s1")).toEqual({ x: 16, y: 4 }); + }); + }); }); diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts index 2b1abee19..86c73710b 100644 --- a/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts +++ b/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts @@ -24,6 +24,7 @@ function fixture() { const calls: string[][] = []; let failedCreate = false; let stopCount = 0; + let rescues = 0; const bridge = { port: 5922, start: async () => {}, @@ -37,6 +38,9 @@ function fixture() { kernelRelease: "23.0.0", status: async () => status, alive: () => false, + rescue: async () => { + rescues++; + }, bridge: () => bridge, run: async (args: string[]) => { calls.push(args); @@ -95,6 +99,9 @@ function fixture() { get stops() { return stopCount; }, + get rescueCount() { + return rescues; + }, }; } @@ -236,3 +243,25 @@ test("allocation reuses its readiness status without a second helper handshake", expect(probes).toBe(1); await f.provider.closeFor(pane); }); + +test("discarding the display hands the pointer back to the person at the Mac", async () => { + // A viewer's pointer lives on the screen being discarded, so the teardown + // must ask the helper to put it back on a visible display (measured on the + // M5: the local user lost their cursor entirely). + const f = fixture(); + await f.provider.openFor(pane, { width: 1280, height: 800 }); + expect(f.rescueCount).toBe(0); + await f.provider.closeFor(pane); + expect(f.rescueCount).toBe(1); +}); + +test("a display that vanished under a viewer still gets the pointer rescued", async () => { + // The screen is already gone, which is precisely when the local cursor can + // be stranded on it, so the ask happens even though there is nothing left + // to discard. + const f = fixture(); + await f.provider.openFor(pane, { width: 1280, height: 800 }); + f.disappear(); + await f.provider.closeFor(pane); + expect(f.rescueCount).toBe(1); +}); diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.ts index 2fa7bc49d..2c674c77c 100644 --- a/packages/pane-runtime/src/preview/betterdisplay-provider.ts +++ b/packages/pane-runtime/src/preview/betterdisplay-provider.ts @@ -25,7 +25,7 @@ import { type PreviewScreenHandle, type VirtualDisplayProvider, } from "./display-provider.js"; -import { type HelperStatus, helperStatus } from "./macos-helper.js"; +import { type HelperStatus, helperStatus, rescueStrandedCursor } from "./macos-helper.js"; import { MacOSRfb } from "./macos-rfb.js"; type Journal = { @@ -45,6 +45,12 @@ export interface BetterDisplayDeps { bridge?: (size: VirtualDisplayRequest, display: OwnedDisplay) => Bridge; alive?: (pid: number) => boolean; log?: (line: string) => void; + /** + * Put the local pointer back on a visible screen after a display is + * discarded. Defaults to {@link rescueStrandedCursor}; a host without a + * helper (tests, and any non-macOS caller) can pass a no-op. + */ + rescue?: () => Promise<void>; } /** macOS shares one login desktop. This provider is not an isolated X session. */ @@ -82,7 +88,17 @@ export class BetterDisplayProvider implements VirtualDisplayProvider { ); } async readiness(): Promise<PreviewDisplayReadiness> { - return this.checkReadiness((this.supported() && (await this.status())) || null); + // `supported()` first, and it decides whether the helper is asked at all: + // a Linux host has no helper to query (pinned by test). + const status = this.supported() ? await this.status() : null; + const result = await this.checkReadiness(status); + if (result.missing.length) { + // Same reason as the allocation log: the missing list alone cannot say + // whether the helper answered nothing, or answered with BetterDisplay + // absent from LaunchServices. + this.deps.log?.(`betterdisplay readiness refused: ${JSON.stringify({ status, missing: result.missing })}`); + } + return result; } private async checkReadiness(status: HelperStatus | null): Promise<PreviewDisplayReadiness> { if (!this.supported()) return { supported: false, missing: [] }; @@ -191,8 +207,16 @@ export class BetterDisplayProvider implements VirtualDisplayProvider { if (!this.supported()) throw new Error("preview-display-unsupported"); const status = await this.status(); const readiness = await this.checkReadiness(status); - if (readiness.missing.length) + if (readiness.missing.length) { + // The missing list alone could not tell us whether the helper answered + // nothing or answered `{enabled:true,…,betterDisplayExecutable:null}`; + // that distinction cost a long debugging session, so the raw answer + // rides the refusal. + this.deps.log?.( + `betterdisplay refused pane ${id}: missing [${readiness.missing.join("; ")}] status ${JSON.stringify(status)}`, + ); throw new Error(`preview-display-missing-requirements: ${readiness.missing.join(", ")}`); + } if (![size.width, size.height].every((n) => Number.isInteger(n) && n >= 320 && n <= 4096)) throw new Error("Invalid display dimensions"); if (!status?.betterDisplayExecutable) throw new Error(PREVIEW_DISPLAY_NOT_READY); @@ -267,7 +291,13 @@ export class BetterDisplayProvider implements VirtualDisplayProvider { } return; } - if (display) await removeDisplay(run, display); + if (display) { + await removeDisplay(run, display); + // A viewer's pointer lives on the screen being discarded; without this + // the person at the Mac is left chasing a cursor that is off any + // display they can see. + await (this.deps.rescue ?? rescueStrandedCursor)(); + } rmSync(this.directory(id, root), { recursive: true }); } closeFor(id: string) { diff --git a/packages/pane-runtime/src/preview/macos-helper.ts b/packages/pane-runtime/src/preview/macos-helper.ts index 00f3830c3..e313855b9 100644 --- a/packages/pane-runtime/src/preview/macos-helper.ts +++ b/packages/pane-runtime/src/preview/macos-helper.ts @@ -101,3 +101,37 @@ export function helperStatus(path = MACOS_HELPER_SOCKET): Promise<HelperStatus | helper.send({ op: "status" }); }); } + +/** + * Ask the helper to put a stranded pointer back on a screen the person at + * this Mac can actually see, and wait no longer than a heartbeat and a half + * for it. + * + * A viewer's pointer events move the real cursor onto the virtual display, so + * discarding that display mid-session can leave the local user with no cursor + * at all (measured on the M5). The caller is a display teardown: a helper that + * is not running cannot warp the cursor, and a slow one must never hold up + * closing a pane, so every failure path here resolves quietly. Nothing is lost + * by that silence, because a virtual screen can outlive the helper and the + * helper rescues a stranded pointer on its next start; the request is the + * fast path, not the only one. + */ +export function rescueStrandedCursor(path = MACOS_HELPER_SOCKET): Promise<void> { + return new Promise((resolve) => { + const helper = new MacOSHelper(path); + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + helper.close(); + resolve(); + }; + const timer = setTimeout(finish, 1500); + helper.onClose = finish; + helper.onMessage = (message) => { + if (message.type === "rescued") finish(); + }; + helper.send({ op: "rescue-cursor" }); + }); +} diff --git a/packages/pane-runtime/src/preview/xvfb-display-provider.ts b/packages/pane-runtime/src/preview/xvfb-display-provider.ts index a8bb89f97..e9c15cde2 100644 --- a/packages/pane-runtime/src/preview/xvfb-display-provider.ts +++ b/packages/pane-runtime/src/preview/xvfb-display-provider.ts @@ -3,11 +3,13 @@ import { chmodSync, closeSync, existsSync, + fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, + readSync, rmSync, unlinkSync, writeFileSync, @@ -68,10 +70,23 @@ const DISPLAY_MAX = 199; /** Where the RFB chooser starts. 5900-5909 stay the range of real X displays. */ const RFB_PORT_MIN = 5910; const RFB_PORT_PROBES = 100; -/** How long Xvfb and x11vnc get to come up before the allocation is refused. */ -const DEFAULT_READY_BUDGET_MS = 5000; +/** + * How long Xvfb and x11vnc get to come up before the allocation is refused. + * Measured at well under a second on a warm box, but a cold or loaded node has + * been seen missing it (the `preview-display-not-ready` report), and the + * plane gives the whole launch command 60 s, so a slow first spawn costs a + * later refusal, never a stranded pane. The number the AI PC tripped over was + * 5000; doubled rather than tuned blind, because the refusal now also names + * its gate and logs both children's output. + */ +const DEFAULT_READY_BUDGET_MS = 10_000; /** Readiness poll cadence. */ const READY_POLL_MS = 100; +/** How many trailing lines each child log keeps in the daemon log on a refusal. */ +const REFUSAL_TAIL_LINES = 30; +/** How far back into a child's log the refusal read goes: enough for the lines + * above, bounded so a chatty stranger cannot make the daemon size its whole file. */ +const REFUSAL_TAIL_BYTES = 16_384; /** * The X auth file for one pane's display, byte-derived from `dataDir` the @@ -332,7 +347,10 @@ export class XvfbDisplayProvider implements VirtualDisplayProvider { ); writeRecord(paneDir, { display, rfbPort, xvfbPid: xvfbChild.pid, x11vncPid: vncChild.pid }); - await this.awaitReady(displayNumber, rfbPort, vncChild.pid, d); + await this.awaitReady(displayNumber, rfbPort, vncChild.pid, d, [ + { name: "Xvfb", exited: xvfbChild.exited }, + { name: "x11vnc", exited: vncChild.exited }, + ]); const entry: DisplayEntry = { paneId, @@ -352,8 +370,13 @@ export class XvfbDisplayProvider implements VirtualDisplayProvider { this.say(`preview display ${display}: opened for pane ${paneId} (rfb 127.0.0.1:${rfbPort})`); return { display, viewer: { protocol: "vnc", port: rfbPort } }; } catch (err: unknown) { - // Whatever started, none of it outlives the refusal. teardown reads only - // the child pids, so the fields the failure never reached stay zeroed. + // Whatever started, none of it outlives the refusal. The pane dir holds + // the only copy of what Xvfb and x11vnc said about WHY they did not + // come up, and teardown reaps it seconds later - so the tails ride the + // daemon log first. A refusal that predates the children (the xauth + // mint, the binaries, the lock) started nothing, so it says nothing + // about them. + if (xvfbChild) this.logRefusalTails(paneDir, display, paneId); this.teardown( { paneId, @@ -648,22 +671,72 @@ export class XvfbDisplayProvider implements VirtualDisplayProvider { throw new Error(`${PREVIEW_DISPLAY_NOT_READY}: no free rfb port from ${RFB_PORT_MIN}`); } - /** X socket first (the server itself), then the RFB port answering yes. Budget, then refuse. */ + /** + * X socket first (the server itself), then the RFB port answering yes. + * + * Two ways out, not one. A child that has already exited can never become + * ready, so waiting the budget for it only stretches the Create spinner and + * spends a third of the plane's launch window learning nothing new: the + * moment either one is dead, refuse and name the gate, and let the caller + * journal the child logs, which are complete at that point. + */ private async awaitReady( displayNumber: number, rfbPort: number, vncPid: number, d: ReturnType<XvfbDisplayProvider["deps"]>, + children: { name: string; exited: Promise<unknown> }[], ): Promise<void> { const start = d.now(); + // A rejection is an exit too: the watch treats a spawn error like death, + // and readiness that kept polling a crashed child would burn the whole + // budget learning nothing. + const dead = Promise.race( + children.map((child) => + child.exited.then( + () => child.name, + () => child.name, + ), + ), + ); for (;;) { - if (d.xSocketReady(displayNumber) && (await d.bindProbe(rfbPort)) && d.ownsRfbListener(vncPid, rfbPort)) return; + const xSocket = d.xSocketReady(displayNumber); + const rfb = xSocket && (await d.bindProbe(rfbPort)); + if (rfb && d.ownsRfbListener(vncPid, rfbPort)) return; + // Name the gate that was still shut: the three failures (Xvfb never + // made its socket, x11vnc never bound the port, and someone else owns + // the listener) want different eyes on the child logs below. An exited + // child names itself in the same slot, because that IS the gate. + const gate = !xSocket + ? "Xvfb socket never appeared" + : !rfb + ? "rfb port answered nothing" + : "listener not owned by the x11vnc pid"; + const diedIn = await Promise.race([dead, d.sleep(READY_POLL_MS).then(() => undefined)]); + if (diedIn !== undefined) { + throw new Error( + `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} gave up: ${diedIn} exited (${gate})`, + ); + } if (d.now() - start >= d.readyBudgetMs) { throw new Error( - `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} not answering within ${d.readyBudgetMs}ms`, + `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} not answering within ${d.readyBudgetMs}ms (${gate})`, ); } - await d.sleep(READY_POLL_MS); + } + } + + /** + * Hand both children's output to the daemon log before the refusal's + * teardown reaps the dir that holds it. Each log keeps its last + * {@link REFUSAL_TAIL_LINES} lines; an absent or empty file says nothing, + * literally - a child that wrote no words is not worth a journal line, and + * a launch refused before the spawns reads no files at all. + */ + private logRefusalTails(paneDir: string, display: string, paneId: string): void { + for (const name of ["Xvfb.log", "x11vnc.log"] as const) { + const tail = readTail(join(paneDir, name)); + if (tail) this.say(`preview display ${display} for pane ${paneId}: ${name} tail:\n${tail}`); } } @@ -753,6 +826,35 @@ function writeRecord(dir: string, rec: Record<string, unknown>): void { chmodSync(file, 0o600); // the mode is create-only; every rewrite re-pins it } +/** + * The last {@link REFUSAL_TAIL_LINES} non-empty lines of a file, or `""` for + * one that is absent, unreadable, or holds nothing but whitespace. The + * refusal-diagnosis path reads a child's own log off disk seconds before it + * is reaped, so a missing or empty file must be a quiet no, never a throw. + */ +function readTail(path: string): string { + let handle = -1; + try { + // The tail, not the file. These logs are seconds old in practice, but a + // chatty x11vnc repeating a rejected client can grow one, and a refusal is + // the wrong moment to allocate a stranger's entire output in the daemon. + handle = openSync(path, "r"); + const size = fstatSync(handle).size; + const start = Math.max(0, size - REFUSAL_TAIL_BYTES); + const buffer = new Uint8Array(size - start); + const bytesRead = readSync(handle, buffer, 0, buffer.length, start); + const lines = new TextDecoder() + .decode(buffer.subarray(0, bytesRead)) + .split("\n") + .filter((line) => line.trim().length > 0); + return lines.slice(-REFUSAL_TAIL_LINES).join("\n"); + } catch { + return ""; + } finally { + if (handle >= 0) closeSync(handle); + } +} + function readRecord(dir: string): DisplayRecord | null { try { const parsed = JSON.parse(readFileSync(join(dir, "display.json"), "utf8")) as unknown; diff --git a/packages/pane-runtime/src/ssh/__tests__/ssh-spawn.test.ts b/packages/pane-runtime/src/ssh/__tests__/ssh-spawn.test.ts new file mode 100644 index 000000000..5d28e66bc --- /dev/null +++ b/packages/pane-runtime/src/ssh/__tests__/ssh-spawn.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { runSshProcess } from "../ssh-spawn.js"; + +test("secret-bearing stdin reaches the child without entering its process arguments", async () => { + const secret = "nsk_private_script_payload"; + const result = await runSshProcess( + ["/bin/sh", "-c", 'printf "args:%s\\n" "$*"; cat'], + { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + 5_000, + `${secret}\n`, + ); + expect(result.code).toBe(0); + expect(result.stdout).toBe(`args:\n${secret}\n`); + expect(result.timedOut).toBe(false); +}); + +test("ordinary SSH probes still receive EOF on stdin", async () => { + const result = await runSshProcess(["/bin/sh", "-c", "cat"], { PATH: "/usr/bin:/bin" }, 5_000); + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); +}); diff --git a/packages/pane-runtime/src/ssh/relay/exec.ts b/packages/pane-runtime/src/ssh/relay/exec.ts index 41575936a..e94ec5638 100644 --- a/packages/pane-runtime/src/ssh/relay/exec.ts +++ b/packages/pane-runtime/src/ssh/relay/exec.ts @@ -17,9 +17,9 @@ import type { MachineSshResult } from "./result.js"; /** * The `ssh_exec` non-interactive run (spec 2026-10-08 §7, Task 14): the - * "Set up Subshell here" act's SEPARATE short-lived `ssh D '<install>'` on + * "Set up Subshell here" act's separate short-lived `ssh D bash -s` on * this machine - never typed into the interactive pane, so the minted setup - * key in the command string reaches neither the pane screen nor the pane log. + * key travels on stdin, not in process arguments, the pane screen, or its log. * The rules, each load-bearing: * * - **The gate and maintenance speak first**, like every SSH arm and the @@ -147,7 +147,12 @@ export async function machineSshExec( runs.set(cmd.execId, entry); // OFF the chain by construction: the run's promise is carried, never // awaited; the finish handler redacts, retains, stores, and sweeps files. - void (seams?.runProcess ?? runSshProcess)([sshBin, ...cmd.presetFlags, cmd.command], env, cmd.timeoutMs).then( + void (seams?.runProcess ?? runSshProcess)( + [sshBin, ...cmd.presetFlags, "bash -s"], + env, + cmd.timeoutMs, + `${cmd.command}\n`, + ).then( (r: SshProcessResult) => { entry.done = { code: r.spawnError ? null : r.code, diff --git a/packages/pane-runtime/src/ssh/ssh-spawn.ts b/packages/pane-runtime/src/ssh/ssh-spawn.ts index b2e394dcd..948122a20 100644 --- a/packages/pane-runtime/src/ssh/ssh-spawn.ts +++ b/packages/pane-runtime/src/ssh/ssh-spawn.ts @@ -41,16 +41,18 @@ export interface SshProcessResult { * consults PATH to pick an executable (the explicit-argv rule of this engine's contract). * @param env - the COMPLETE child environment; no inheritance, by design * @param timeoutMs - hard deadline; the child is terminated at it + * @param input - optional script sent over stdin, never placed in process arguments */ export async function runSshProcess( argv: readonly string[], env: Record<string, string>, timeoutMs: number, + input?: string, ): Promise<SshProcessResult> { let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; try { proc = Bun.spawn([...argv], { - stdin: "ignore", + stdin: input === undefined ? "ignore" : new TextEncoder().encode(input), stdout: "pipe", stderr: "pipe", env, diff --git a/packages/pane-runtime/src/tmux-runner.ts b/packages/pane-runtime/src/tmux-runner.ts index 60e99a3d4..ef00d1e5c 100644 --- a/packages/pane-runtime/src/tmux-runner.ts +++ b/packages/pane-runtime/src/tmux-runner.ts @@ -559,6 +559,17 @@ export class TmuxRunner { subshellName: string, outputFile: string, child?: { command: string; args: string[] }, + /** + * `replace: true` re-issues WITHOUT `-o`, which REPLACES the running + * capture child (the exit watcher's re-arm). With `-o` tmux treats a + * configured pipe as an already-on one, so re-issuing the launch command + * through `-o` can no-op on a pipe whose child has died — and the + * `#{pane_pipe}` flag cannot be consulted to decide (it has been measured + * reading both 0 and 1 for a dead writer on the same tmux build). The + * replacing form re-arms under either behavior; that is why the guard + * uses it rather than probing the flag first. + */ + opts: { replace?: boolean } = {}, ): void { // The command runs in tmux's shell (`sh -c`), so the path must be // POSIX-single-quoted. The previous JSON.stringify-based escaping did @@ -574,7 +585,7 @@ export class TmuxRunner { child === undefined ? `(umask 077; cat >> ${shellQuote(outputFile)})` : `(umask 077; exec ${[child.command, ...child.args].map(shellQuote).join(" ")} ${PANE_LOG_FILE_FLAG} ${shellQuote(outputFile)})`; - this.run(["-L", socket, "pipe-pane", "-t", subshellName, "-o", append], {}); + this.run(["-L", socket, "pipe-pane", "-t", subshellName, ...(opts.replace ? [] : ["-o"]), append], {}); } /** @@ -648,11 +659,14 @@ export class TmuxRunner { const out = ( await this.runAsync(["-L", socket, "display-message", "-t", subshellName, "-p", "#{cursor_x}:#{cursor_y}"], {}) ).stdout.trim(); + // Both fields must be real digits. tmux can print `":"` for a pane + // caught mid-redraw (measured 2026-10-27), and `Number("")` is 0 — a + // lenient parse would report the top-left cell as the pane's cursor, + // anchoring every later relative-positioned frame the wrong distance + // off. This read answers the pane's real position or nothing at all. + if (!/^\d+:\d+$/.test(out)) return null; const [rawX, rawY] = out.split(":"); - const x = Number(rawX); - const y = Number(rawY); - if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0) return null; - return { x, y }; + return { x: Number(rawX), y: Number(rawY) }; } catch { return null; } diff --git a/packages/subshell-protocol/src/__tests__/display-providers.test.ts b/packages/subshell-protocol/src/__tests__/display-providers.test.ts index 924b6c4dd..67fc69246 100644 --- a/packages/subshell-protocol/src/__tests__/display-providers.test.ts +++ b/packages/subshell-protocol/src/__tests__/display-providers.test.ts @@ -1,7 +1,66 @@ import { expect, it } from "bun:test"; -import { parseDisplayProviderIds, parseVirtualDisplayRequest, storedDisplayProviderIds } from "../display-providers.js"; +import { + DEFAULT_VIRTUAL_DISPLAY_PROVIDER, + displayOsAnswered, + displayProviderRunsOnOs, + displayProvidersForOs, + NODE_OS_UNKNOWN, + parseDisplayProviderIds, + parseVirtualDisplayRequest, + storedDisplayProviderIds, +} from "../display-providers.js"; import { parseNodeCommandBody, parseNodePreviewEnabled } from "../node-frames.js"; +it("names one OS per bundled provider, and the pickers ask that OS", () => { + // A provider with no platform here is offered everywhere, which is the + // defect: the launch dialog showed Xvfb + VNC on a Mac node, and the person + // found out at launch. Adding a provider means saying what it runs on. + // No loop over `platform` here: the catalog is declared + // `as const satisfies readonly DisplayProviderCatalogEntry[]`, so an entry + // without a platform, or with one outside `DisplayProviderPlatform`, is a + // compile error before any test can see it (docs/testing.md: don't test what + // TypeScript already checks). + expect(displayProvidersForOs("linux").map((p) => p.id)).toEqual(["xvfb-x11vnc"]); + expect(displayProvidersForOs("darwin").map((p) => p.id)).toEqual(["betterdisplay"]); + // A named platform nothing supports is "no choice", which is what stops the + // launch dialog offering Xvfb + VNC on a Mac. + expect(displayProvidersForOs("windows")).toEqual([]); + // An OS that has not answered is not a verdict, and that has two shapes: no + // value at all, and the protocol's own "unknown", which is what an agent + // reports when it is neither Linux nor macOS. Refusing on either would empty + // the owner's provider card on a node that enrolled and never came ready, + // leaving no way to enable anything. It is not a promise that such a machine + // can run the provider: the node answers that itself, and refuses Xvfb on any + // non-Linux platform by name. + expect(displayProvidersForOs(null).map((p) => p.id)).toEqual(["xvfb-x11vnc", "betterdisplay"]); + expect(displayProvidersForOs(undefined).map((p) => p.id)).toEqual(["xvfb-x11vnc", "betterdisplay"]); + expect(displayProvidersForOs("unknown").map((p) => p.id)).toEqual(["xvfb-x11vnc", "betterdisplay"]); + expect(NODE_OS_UNKNOWN).toBe("unknown"); + expect(displayProviderRunsOnOs("xvfb-x11vnc", "unknown")).toBe(true); + expect(displayProviderRunsOnOs("betterdisplay", "unknown")).toBe(true); + expect(displayProviderRunsOnOs("xvfb-x11vnc", null)).toBe(true); + expect(displayProviderRunsOnOs("xvfb-x11vnc", "windows")).toBe(false); + // A stored list is the owner's, so filtering it keeps what the OS runs and + // keeps an id this package does not ship: a provider the node installed out + // of band has no platform fact here, and its own readiness answer rules. + expect(displayProviderRunsOnOs("xvfb-x11vnc", "darwin")).toBe(false); + expect(displayProviderRunsOnOs("betterdisplay", "darwin")).toBe(true); + expect(displayProviderRunsOnOs("native-example", "darwin")).toBe(true); + expect(displayProviderRunsOnOs("native-example", null)).toBe(true); + expect(DEFAULT_VIRTUAL_DISPLAY_PROVIDER).toBe("xvfb-x11vnc"); +}); + +it("calls only a named platform an answer", () => { + // The one predicate every surface defers to. A reader that reimplemented this + // list would drift, and "unknown" is the value that drifts silently: it is a + // real string in the column, and the only reading that must not be a verdict. + expect(displayOsAnswered("linux")).toBe(true); + expect(displayOsAnswered("windows")).toBe(true); + expect(displayOsAnswered(NODE_OS_UNKNOWN)).toBe(false); + expect(displayOsAnswered(null)).toBe(false); + expect(displayOsAnswered(undefined)).toBe(false); +}); + it("preserves explicit providers on readiness commands and signed policy", () => { const check = { type: "preview_display_check", provider: "native-example" } as const; expect(parseNodeCommandBody(check)).toEqual(check); diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index a707c823b..222cf0581 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -418,7 +418,7 @@ describe("parseNodeCommandBody", () => { // mirrors a per-machine owner switch, and a launch may carry a `screen` // request. A pre-23 agent understands none of it, and the exact-match gate // refuses pairing before any of it is sent. - expect(NODE_PROTOCOL_VERSION).toBe(24); + expect(NODE_PROTOCOL_VERSION).toBe(25); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -1147,6 +1147,7 @@ describe("ssh command arms and the launch ssh block", () => { "ssh_discover_aliases", "ssh_exec", "ssh_exec_status", + "ssh_exec_stdin", "ssh_host_key", "ssh_key_bridge", "ssh_machine_pin_repair", @@ -1155,7 +1156,7 @@ describe("ssh command arms and the launch ssh block", () => { "ssh_relay_open", "ssh_resolve_config", ]); - expect(SSH_COMMAND_TYPES).toHaveLength(11); + expect(SSH_COMMAND_TYPES).toHaveLength(12); // Every census type is a type the dispatcher actually narrows. for (const t of SSH_COMMAND_TYPES) { const body = @@ -1169,8 +1170,8 @@ describe("ssh command arms and the launch ssh block", () => { ? { type: t, ref: "r-1", reason: "lifetime-expiry" } : t === "ssh_host_key" ? { type: t, host: "git.example.test", port: 22, user: null } - : t === "ssh_exec" - ? execCmdFixture() + : t === "ssh_exec" || t === "ssh_exec_stdin" + ? { ...execCmdFixture(), type: t } : t === "ssh_exec_status" ? { type: t, execId: EXEC_ID } : t === "ssh_machine_pin_repair" @@ -1566,6 +1567,13 @@ const execCmdFixture = (): SshExecCommand => ({ }); describe("ssh_exec / ssh_exec_status arms (spec 2026-10-08 §7, Task 14)", () => { + it("keeps the stdin-only command distinct from legacy executors and applies the same bounds", () => { + const command: SshExecCommand = { ...execCmdFixture(), type: "ssh_exec_stdin" }; + expect(parseNodeCommandBody(command)).toEqual(command); + expect(parseNodeCommandBody({ ...command, command: "x".repeat(SSH_EXEC_COMMAND_MAX_CHARS + 1) })).toBeNull(); + expect(parseNodeCommandBody({ ...command, command: "first\nsecond" })).toBeNull(); + }); + it("narrows a complete kick, every field carried through", () => { expect(parseNodeCommandBody(structuredClone(execCmdFixture()))).toEqual(execCmdFixture()); // Direct mode: no relay, the snapshot's own agent socket rides (or null). diff --git a/packages/subshell-protocol/src/display-providers.ts b/packages/subshell-protocol/src/display-providers.ts index ab86e4ccb..ddde7d03c 100644 --- a/packages/subshell-protocol/src/display-providers.ts +++ b/packages/subshell-protocol/src/display-providers.ts @@ -1,14 +1,91 @@ +/** + * The operating system one bundled provider can drive. It sits in the catalog + * rather than in each surface that asks, because the answer is a fact about the + * provider: Xvfb is Linux's display server and BetterDisplay is macOS's, and a + * picker that offers the other one is offering a choice the machine refuses. The + * catalog `satisfies` it, so a provider added without a platform is a compile + * error rather than an entry that silently matches no OS. + */ +export type DisplayProviderPlatform = "linux" | "darwin"; + +/** One row of the catalog. `platform` is the type's whole point: see above. */ +export interface DisplayProviderCatalogEntry { + id: string; + name: string; + description: string; + platform: DisplayProviderPlatform; +} + /** Bundled display choices. Add implementations to pane-runtime's registry too. */ export const VIRTUAL_DISPLAY_PROVIDERS = [ - { id: "xvfb-x11vnc", name: "Xvfb + VNC", description: "Private Linux displays using Xvfb and x11vnc." }, + { + id: "xvfb-x11vnc", + name: "Xvfb + VNC", + description: "Private Linux displays using Xvfb and x11vnc.", + platform: "linux", + }, { id: "betterdisplay", name: "BetterDisplay", description: "macOS virtual screens using BetterDisplay and the opt-in Subshell Display Helper.", + platform: "darwin", }, -] as const; +] as const satisfies readonly DisplayProviderCatalogEntry[]; export const DEFAULT_VIRTUAL_DISPLAY_PROVIDER = "xvfb-x11vnc"; +/** + * What a node's `os` field carries when nothing has classified the machine: an + * agent on a platform outside `linux|darwin` says this (`mapOs` in + * `apps/node/agent/src/enroll.ts`), and so does a row that enrolled before the + * field was reported. It is not a platform, and reading it as one would have the + * plane guess at a machine it holds no fact about. Every reader here treats it as + * the absence of an answer, which is also what a null means. The node is the + * authority on what runs there: it looks for the programs, and it refuses a + * provider it cannot drive, by name (`preview-display-unsupported`, from + * `xvfbDisplayReadiness` and the provider's own platform check, which is stricter + * than anything the catalog could say from an unclassified `os`). What + * no-verdict preserves is the owner's path on a node that enrolled and never came + * ready: the provider rows stay visible so they can be enabled at all. + */ +export const NODE_OS_UNKNOWN = "unknown"; + +/** + * Whether an `os` value says anything about what can run on this machine. + * See {@link NODE_OS_UNKNOWN} for why the protocol's own `unknown` is not an answer. + */ +export function displayOsAnswered(os: string | null | undefined): boolean { + return os !== null && os !== undefined && os !== NODE_OS_UNKNOWN; +} + +/** + * The bundled providers a machine may be offered. + * + * A named platform that no provider supports matches NOTHING: the honest answer + * there is no choice rather than the Linux default handed to a Mac. A machine + * that has not named its platform gets the whole catalog, because nothing has + * ruled any of it out, and an owner must still be able to pre-enable a provider + * on a node that enrolled and never came ready. + */ +export function displayProvidersForOs( + os: string | null | undefined, +): readonly (typeof VIRTUAL_DISPLAY_PROVIDERS)[number][] { + if (!displayOsAnswered(os)) return VIRTUAL_DISPLAY_PROVIDERS; + return VIRTUAL_DISPLAY_PROVIDERS.filter((provider) => provider.platform === os); +} + +/** + * Whether one id from a node's stored provider list may be offered for that OS. + * A bundled provider answers from the catalog. An id this package does not ship + * belongs to the node itself, and no surface here has a fact about its platform, + * so it stays on the list and the node's own readiness answer rules it in or + * out: refusing what we cannot place would hide a provider the machine has. + */ +export function displayProviderRunsOnOs(id: string, os: string | null | undefined): boolean { + const bundled = VIRTUAL_DISPLAY_PROVIDERS.find((provider) => provider.id === id); + if (!bundled) return true; + return displayOsAnswered(os) ? bundled.platform === os : true; +} + /** Identifiers are bounded on the wire so older nodes can refuse unknown providers. */ export function isDisplayProviderId(value: unknown): value is string { return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value); diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index bb756a3fa..630d72e7e 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -225,8 +225,12 @@ import { * **23 -> 24:** forward lanes may carry an explicit saved HTTP(S) destination. * Older nodes would silently discard it and dial plaintext loopback, so the * exact-match handshake must reject them before any service is opened. + * + * **24 -> 25:** ssh_exec_stdin requires setup scripts to travel on stdin to + * a fixed shell command. Older executors put the setup key in process argv; + * the exact-match handshake rejects them before a setup key is sent. */ -export const NODE_PROTOCOL_VERSION = 24; +export const NODE_PROTOCOL_VERSION = 25; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -2332,6 +2336,7 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { case "ssh_relay_open": case "ssh_relay_close": case "ssh_exec": + case "ssh_exec_stdin": case "ssh_exec_status": case "ssh_machine_pin_repair": // Delegation, not a second parser: the SSH grammar (all arms, one diff --git a/packages/subshell-protocol/src/ssh-frames.ts b/packages/subshell-protocol/src/ssh-frames.ts index acb1f951b..0d7e586ed 100644 --- a/packages/subshell-protocol/src/ssh-frames.ts +++ b/packages/subshell-protocol/src/ssh-frames.ts @@ -330,8 +330,7 @@ export interface SshRelayCloseCommand { * the snapshot's own agent socket rides as `agentSocketPath` (the same scoped * env the direct pane launch carries) or nothing does. */ -export interface SshExecCommand { - type: "ssh_exec"; +export type SshExecCommand = { /** The ephemeral act id (path-composition shape): names `<dataDir>/ssh/<execId>/`. */ execId: string; /** The rendered-config path the plane derived; the node byte-checks its own. */ @@ -348,7 +347,7 @@ export interface SshExecCommand { agentSocketPath: string | null; /** The node-side hard deadline for the run; the plane's RPC deadline rides past it. */ timeoutMs: number; -} +} & ({ type: "ssh_exec" } | { type: "ssh_exec_stdin" }); /** Ask one machine for the state of a kicked `ssh_exec` (running, or done with its captured answer). */ export interface SshExecStatusCommand { @@ -382,6 +381,7 @@ export const SSH_COMMAND_TYPES = [ "ssh_relay_open", "ssh_relay_close", "ssh_exec", + "ssh_exec_stdin", "ssh_exec_status", "ssh_machine_pin_repair", ] as const; @@ -619,7 +619,8 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu if (!(SSH_RELAY_CLOSE_REASONS as readonly string[]).includes(value.reason)) return null; return { type: "ssh_relay_close", ref: value.ref, reason: value.reason as SshRelayCloseReason }; } - case "ssh_exec": { + case "ssh_exec": + case "ssh_exec_stdin": { // Every field is required (the §7 redaction rule has no omitted half // to paper over): the exec id takes the SAME path-composition shape // the relay's paneId takes, the config member takes the launch frame's @@ -653,7 +654,7 @@ export function parseSshNodeCommandBody(value: unknown): SshNodeCommandBody | nu if (!isInt(value.timeoutMs) || (value.timeoutMs as number) < 1) return null; if ((value.timeoutMs as number) > SSH_EXEC_TIMEOUT_MAX_MS) return null; return { - type: "ssh_exec", + type: value.type, execId: value.execId, configPath: value.configPath, fileContent: value.fileContent, diff --git a/scripts/__tests__/design-tokens.test.ts b/scripts/__tests__/design-tokens.test.ts index 6aae8d2f3..d01d32251 100644 --- a/scripts/__tests__/design-tokens.test.ts +++ b/scripts/__tests__/design-tokens.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { agreementProblems, + allAgreement, COLOR_ROLES, + CSS_SURFACES, contrastProblems, contrastRatio, cssColorAgreement, @@ -12,6 +16,12 @@ import { oklchToSrgb, parseCssTokens, parseOklch, + REPO_ROOT, + SCAN_ROOTS, + SHARED_TOKENS, + SURFACE_GLOBS, + sharedImportProblems, + sharedRedeclarationProblems, TYPE_ROLES, WEB_SCALE, } from "../design-tokens"; @@ -389,3 +399,130 @@ describe("contrastProblems", () => { expect(problems[0]).toContain("4.5"); }); }); + +describe("the shared token file", () => { + const shared = readFileSync(join(REPO_ROOT, SHARED_TOKENS), "utf8"); + + test("every colour role the system requires is declared once, in the shared file", () => { + const t = parseCssTokens(shared); + for (const role of COLOR_ROLES) expect(t.colors[role]).toBeDefined(); + // scrim is optional per surface but must have one value to be optional against. + expect(t.colors.scrim).toBe("#000000b3"); + }); + + test("names a surface that re-declares a shared colour with a near-miss value", () => { + // The assistant once carried six "near-Dreamframe" values under Dreamframe's + // names; comparing names only called that agreement. Same guard, now that + // the colours live in one file. + const tuned = ":root {\n --warning: oklch(0.828 0.189 90);\n}"; + expect(sharedRedeclarationProblems("assistant", shared, tuned)).toEqual([ + expect.stringContaining("assistant: --warning is oklch(0.828 0.189 90)"), + ]); + }); + + test("tolerates a surface restating a shared value identically", () => { + // Tailwind's @theme block re-states the type roles literally in several + // files; that is a build convention, and var() indirection does not work + // there. Only a different value is drift. + const t = parseCssTokens(shared); + const same = `:root {\n --warning: ${t.colors.warning};\n --text-label: 15px;\n}`; + expect(sharedRedeclarationProblems("client", shared, same)).toEqual([]); + }); + + test("names a type role or weight a surface restates at a different value", () => { + expect(sharedRedeclarationProblems("spa", shared, ":root {\n --text-detail: 12px;\n}")).toEqual([ + expect.stringContaining("--text-detail is 12px"), + ]); + expect(sharedRedeclarationProblems("spa", shared, ":root {\n --font-weight-strong: 700;\n}")).toEqual([ + expect.stringContaining("--font-weight-strong is 700"), + ]); + }); + + test("the three real surfaces still resolve a complete palette through the import", () => { + // The refactor's risk is a role that existed only in a surface file and now + // exists nowhere: each surface read as shared-plus-own must still satisfy + // every required role and clear contrast. + for (const surface of ["spa", "client", "assistant"] as const) { + const merged = `${shared}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES[surface]), "utf8")}`; + expect(agreementProblems(surface, parseCssTokens(merged), WEB_SCALE)).toEqual([]); + } + expect(contrastProblems(`${shared}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")}`)).toEqual([]); + }); +}); + +describe("the shared palette as a build input", () => { + test("turbo treats it as a global dependency", () => { + // Every CSS surface imports `packages/assistant/tokens.css`, and the macOS + // helper panel ships the built result inside a signed bundle. Without this, + // a commit that edits only the palette hits a warm cache, serves the old + // inlined stylesheet, and `bun run lint:design` still passes: the checker + // reads sources, and the shipped bytes are whatever the cache held. + const turbo = JSON.parse(readFileSync(join(REPO_ROOT, "turbo.json"), "utf8")) as { + globalDependencies?: string[]; + }; + expect(turbo.globalDependencies).toContain(SHARED_TOKENS); + }); +}); + +describe("a package page that joins a surface", () => { + test("the helper panel is walked under the assistant rules, by name", () => { + // Nothing else checks the registration itself. A scan root pointing at a + // moved directory, or a stylesheet whose name the surface's include glob + // never matches, leaves that page unswept while `lint:design` still prints + // its green tick, which is how "extracted into a package" has historically + // meant "escaped the role system". + expect(SCAN_ROOTS.assistant).toContain("packages/helper-panel/src"); + expect(existsSync(join(REPO_ROOT, "packages/helper-panel/src/styles.css"))).toBe(true); + expect(SURFACE_GLOBS.assistant.include.test("styles.css")).toBe(true); + }); +}); + +test("every shipped CSS surface imports the shared palette and the agreement pipeline checks it", async () => { + for (const surface of Object.keys(CSS_SURFACES) as (keyof typeof CSS_SURFACES)[]) { + const css = readFileSync(join(REPO_ROOT, CSS_SURFACES[surface]), "utf8"); + expect(sharedImportProblems(surface, css)).toEqual([]); + expect(sharedImportProblems(surface, css.replace(/^@import[^;]*tokens\.css[^;]*;/m, ""))).toHaveLength(1); + } + const panel = readFileSync(join(REPO_ROOT, "packages/helper-panel/src/styles.css"), "utf8"); + expect(panel).toContain('@import "../../assistant/tokens.css";'); + expect(await allAgreement()).toEqual([]); +}); + +test("the agreement pipeline rejects missing imports and local palette overrides", async () => { + const fixture = mkdtempSync("/tmp/subshell-tokens-"); + const files = [...Object.values(CSS_SURFACES), SHARED_TOKENS, "packages/helper-panel/src/styles.css"]; + try { + for (const file of files) { + const destination = join(fixture, file); + mkdirSync(destination.slice(0, destination.lastIndexOf("/")), { recursive: true }); + writeFileSync(destination, readFileSync(join(REPO_ROOT, file), "utf8")); + } + const assistant = join(fixture, CSS_SURFACES.assistant); + const original = readFileSync(assistant, "utf8"); + writeFileSync(assistant, original.replace(/^@import[^;]*tokens\.css[^;]*;/m, "")); + expect(await allAgreement("assistant", fixture)).toContain( + `assistant: import ${SHARED_TOKENS} before declaring local tokens`, + ); + writeFileSync(assistant, `${original}\n:root {\n --primary: red;\n}\n`); + expect( + (await allAgreement("assistant", fixture)).some((line) => + line.includes("--primary is red, but packages/assistant/tokens.css sets"), + ), + ).toBe(true); + writeFileSync(assistant, original); + const panel = join(fixture, "packages/helper-panel/src/styles.css"); + const originalPanel = readFileSync(panel, "utf8"); + writeFileSync(panel, originalPanel.replace(/^@import[^;]*tokens\.css[^;]*;/m, "")); + expect(await allAgreement("assistant", fixture)).toContain( + `helper-panel: import ${SHARED_TOKENS} before declaring local tokens`, + ); + writeFileSync(panel, `${originalPanel}\n:root {\n --primary: red;\n}\n`); + expect( + (await allAgreement("assistant", fixture)).some((line) => + line.includes("helper-panel: --primary is red, but packages/assistant/tokens.css sets"), + ), + ).toBe(true); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/scripts/design-tokens.ts b/scripts/design-tokens.ts index 5e711dcbf..4cb82e5c2 100644 --- a/scripts/design-tokens.ts +++ b/scripts/design-tokens.ts @@ -20,7 +20,7 @@ * Spec: docs/superpowers/specs/2026-09-14-design-system-design.md § 6. */ import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; export const TYPE_ROLES = ["display", "heading", "label", "body", "detail"] as const; export type TypeRole = (typeof TYPE_ROLES)[number]; @@ -215,7 +215,7 @@ export function contrastRatio(a: [number, number, number], b: [number, number, n } /** Mobile's camelCase keys → the CSS roles they must equal. The terminal trio is not compared (mobile's termFg has no CSS token). */ -export const MOBILE_COLOR_MAP: Record<string, ColorRole> = { +export const MOBILE_COLOR_MAP: Record<string, ColorRole | (typeof OPTIONAL_COLOR_ROLES)[number]> = { bg: "background", card: "card", border: "border", @@ -437,9 +437,17 @@ export const SURFACE_GLOBS: Record<Surface, { root: string; include: RegExp; exc * identical surface. It is not a fifth `CSS_SURFACES` entry (nothing compares * its values cross-surface; it simply must not escape the role system either), * which is why it joins `spa` as a scan root rather than as a new surface. + * + * `packages/helper-panel` is the macOS display helper's settings page. It is + * bundled into the helper's own `.app`, so it sits under no `apps/` directory, + * and a page nobody scans is a page that drifts. It answers to the assistant + * rules because it imports that surface's tokens and renders on the same + * plain-CSS roles, which is the point of moving the palette to + * `packages/assistant/tokens.css` in the first place. */ -const SCAN_ROOTS: Partial<Record<Surface, string[]>> = { +export const SCAN_ROOTS: Partial<Record<Surface, string[]>> = { spa: ["packages/node-admin/src", "apps/node/web/src"], + assistant: ["packages/helper-panel/src"], }; // --------------------------------------------------------------------------- @@ -489,6 +497,76 @@ export const CSS_SURFACES: Record<Exclude<Surface, "mobile">, string> = { export const MOBILE_TOKENS = "apps/client/mobile/src/lib/tokens.ts"; +/** + * The palette and type scale, one file, imported by every CSS surface. + * + * It lives in `packages/` (Apache-2.0) so the surfaces that may import it are + * not limited by the `apps/server/` licence line — the macOS display helper's + * panel is Apache and renders the same design system. The surfaces read it + * with a CSS `@import`; the checker reads it here and merges, so a role can + * be declared exactly once and a surface that re-declares it differently is + * reported rather than silently winning on cascade order. + */ +export const SHARED_TOKENS = "packages/assistant/tokens.css"; + +/** Refuse a missing shared import before crediting that file's tokens. */ +export function sharedImportProblems( + surface: string, + ownCss: string, + repoRoot = REPO_ROOT, + sourcePath = CSS_SURFACES[surface as Exclude<Surface, "mobile">], +): string[] { + const surfacePath = join(repoRoot, sourcePath); + const imports = [...ownCss.matchAll(/^\s*@import\s+["']([^"']+)["']\s*;/gm)]; + return imports.some((match) => resolve(dirname(surfacePath), match[1]) === join(repoRoot, SHARED_TOKENS)) + ? [] + : [`${surface}: import ${SHARED_TOKENS} before declaring local tokens`]; +} + +/** + * A surface re-declaring something the shared file already sets, with a + * different value. + * + * Identical duplicates are not reported: Tailwind's `@theme` block re-states + * the type roles in several files, and that is a build convention, not drift. + * A different value is the thing that must never happen — it means one screen + * renders the brand and the next renders a near-miss of it, which is the + * failure the whole token system exists to end. + */ +export function sharedRedeclarationProblems(surface: string, sharedCss: string, ownCss: string): string[] { + const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\(\s/g, "(").replace(/\s\)/g, ")").trim(); + const shared = parseCssTokens(sharedCss); + const own = parseCssTokens(ownCss); + const problems: string[] = []; + for (const [role, value] of Object.entries(own.colors)) { + const baseline = shared.colors[role]; + if (baseline !== undefined && norm(baseline) !== norm(value)) { + problems.push(`${surface}: --${role} is ${norm(value)}, but ${SHARED_TOKENS} sets ${norm(baseline)}`); + } + } + for (const role of TYPE_ROLES) { + const want = shared.text[role]; + const have = own.text[role]; + if (!want || !have) continue; + if (Number.isFinite(want.size) && Number.isFinite(have.size) && have.size !== want.size) { + problems.push(`${surface}: --text-${role} is ${have.size}px, but ${SHARED_TOKENS} sets ${want.size}px`); + } + if (want.lineHeight !== undefined && have.lineHeight !== undefined && have.lineHeight !== want.lineHeight) { + problems.push( + `${surface}: --text-${role}--line-height is ${have.lineHeight}, but ${SHARED_TOKENS} sets ${want.lineHeight}`, + ); + } + } + for (const key of ["strong", "regular"] as const) { + const want = shared.weights[key]; + const have = own.weights[key]; + if (want !== undefined && have !== undefined && have !== want) { + problems.push(`${surface}: --font-weight-${key} is ${have}, but ${SHARED_TOKENS} sets ${want}`); + } + } + return problems; +} + /** The agreement half of the check, all four surfaces. */ /** * The three CSS surfaces must agree on colour VALUES, not only names. @@ -518,17 +596,34 @@ export function cssColorAgreement(spaCss: string, others: Record<string, string> return problems; } -export async function allAgreement(only?: Surface): Promise<string[]> { +export async function allAgreement(only?: Surface, repoRoot = REPO_ROOT): Promise<string[]> { const problems: string[] = []; - const css = Object.fromEntries( - (Object.entries(CSS_SURFACES) as [Exclude<Surface, "mobile">, string][]).map(([surface, rel]) => [ + const sharedCss = readFileSync(join(repoRoot, SHARED_TOKENS), "utf8"); + const own = Object.fromEntries( + (Object.keys(CSS_SURFACES) as Exclude<Surface, "mobile">[]).map((surface) => [ surface, - readFileSync(join(REPO_ROOT, rel), "utf8"), + readFileSync(join(repoRoot, CSS_SURFACES[surface]), "utf8"), ]), ) as Record<Exclude<Surface, "mobile">, string>; + // Every surface is read as the shared file plus its own: a role declared + // once in the palette counts as declared by all three. + const css = Object.fromEntries( + (Object.keys(own) as Exclude<Surface, "mobile">[]).map((surface) => [surface, `${sharedCss}\n${own[surface]}`]), + ) as Record<Exclude<Surface, "mobile">, string>; for (const surface of Object.keys(css) as Exclude<Surface, "mobile">[]) { if (only && only !== surface) continue; + problems.push(...sharedImportProblems(surface, own[surface], repoRoot)); problems.push(...agreementProblems(surface, parseCssTokens(css[surface]), WEB_SCALE)); + // A surface that re-declares a shared role with a different value wins on + // cascade order and quietly changes what that screen looks like; that must + // be a red run, not a design review finding. + problems.push(...sharedRedeclarationProblems(surface, sharedCss, own[surface])); + } + if (!only || only === "assistant") { + const panelPath = "packages/helper-panel/src/styles.css"; + const panel = readFileSync(join(repoRoot, panelPath), "utf8"); + problems.push(...sharedImportProblems("helper-panel", panel, repoRoot, panelPath)); + problems.push(...sharedRedeclarationProblems("helper-panel", sharedCss, panel)); } // Values, not just names: the SPA is the source and the other two must // match it. Scoped by --only so a single-surface run reports only its own. @@ -541,11 +636,11 @@ export async function allAgreement(only?: Surface): Promise<string[]> { // The repo's no-dynamic-imports rule does not bind here: this is a dev // tool under scripts/, never compiled into a binary, and the path is a // fixed constant rather than a runtime-discovered module. - const mod = (await import(join(REPO_ROOT, MOBILE_TOKENS))) as { + const mod = (await import(join(repoRoot, MOBILE_TOKENS))) as { colors: Record<string, string>; type: Record<string, { size: number; weight: string }>; }; - problems.push(...mobileAgreement(readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8"), mod)); + problems.push(...mobileAgreement(css.spa, mod)); } return problems; } @@ -594,7 +689,11 @@ if (import.meta.main) { const agreement = await allAgreement(only); const escapes = allEscapes(only); const contrast = - only && only !== "spa" ? [] : contrastProblems(readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")); + only && only !== "spa" + ? [] + : contrastProblems( + `${readFileSync(join(REPO_ROOT, SHARED_TOKENS), "utf8")}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")}`, + ); const total = agreement.length + escapes.length + contrast.length; if (total === 0) { console.log("✓ design system: tokens agree, no escapes, contrast clears AA"); diff --git a/scripts/prose-dashes.ts b/scripts/prose-dashes.ts index 16d91dda0..06f2a4879 100644 --- a/scripts/prose-dashes.ts +++ b/scripts/prose-dashes.ts @@ -96,6 +96,10 @@ export function proseFiles(cwd: string): string[] { "apps/website/app", "apps/website/components", "apps/website/lib", + // The display helper's consent copy lives in a package because the + // helper bundle is not under any apps/ directory. Its string literals + // are visitor-facing like the website's, so they carry the same rule. + "packages/helper-panel/src", ], { cwd }, ); diff --git a/scripts/rust-check.sh b/scripts/rust-check.sh index f79c61cb4..425edb4b2 100755 --- a/scripts/rust-check.sh +++ b/scripts/rust-check.sh @@ -32,6 +32,16 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" env -u SHELLOPTS -u BASHOPTS bash "$ROOT/native/display-helper/test.sh" +# The helper bundle carries the panel page (see native/display-helper/build_support.rs). +# A debug build tolerates its absence with a placeholder that names this very +# command, because the CI Rust lanes run with no node_modules at all; when the +# workspace IS installed, build it, so clippy and any local `cargo build` +# bundle the real page rather than the placeholder. +if [ -d "$ROOT/node_modules" ]; then + echo "==> packages/helper-panel" + (cd "$ROOT" && bunx turbo run build --filter=@internal/helper-panel) +fi + TRIPLE="$(rustc --print host-tuple)" # dir : sidecar stem. Each app's tauri.conf.json carries the same stem; the stem names the binary the app WRAPS, not the app. diff --git a/turbo.json b/turbo.json index 9bad5da4a..c9153014b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,9 @@ { "$schema": "https://turbo.build/schema.json", "ui": "stream", + "globalDependencies": [ + "packages/assistant/tokens.css" + ], "tasks": { "clean": { "cache": false