From 062c4ae58c46a356af49ee3d450e09f210e779b1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:07:11 -0700 Subject: [PATCH 01/17] =?UTF-8?q?docs(specs):=20the=20house=20form=20for?= =?UTF-8?q?=20rules=20=E2=80=94=20compression=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions ahead of the corpus compression pass: rules lead with a bolded imperative and at most one clause of why (the bold replaces the rhetorical scaffolding); rule lists, ladders, and flows render as bullets or tables rather than narrative; single-module mechanism moves to a comment at that code while cross-file mechanism stays in the spec. Plus the front-matter norm: a spec's opening blockquotes state ownership once, and the glossary callout licenses bare use of glossary terms. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d54d2de0..6048126b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,9 +72,11 @@ When updating code covered by a spec, update the spec to match. When the two spe Rationale entries are keyed by the spec's own headings: each `## X` in `.rationale.md` is a heading that exists in `.md`. In the spec, a rule whose evidence moved gets a trailing `(rationale)` marker — a hint, not a link; the pairing is by heading. Rationale files are informative, not normative: no `## Future`, no `Reserved:`, no `Source of truth:` obligations, and measurements should be dated (`measured in Safari 26.5, 2026-08`) so later pruning is safe. Within spec paragraphs, the normative statement leads and any remaining inline why follows — rule first, rationale after (applied opportunistically, not as a corpus rewrite). +**House form for rules.** A rule leads with a bolded imperative — **Never …**, **Must …**, **May …** — and at most one clause of why; the bold carries the emphasis, so scaffolding words ("deliberately", "note that", "it is worth stating") are deleted rather than kept. A section that is really a rule list, precedence ladder, or flow renders as invariant bullets or a table, not narrative prose; number rules only where another spec cites them. Mechanism that constrains a single module lives as a comment at that code, with the spec keeping the one-line rule and a `Source of truth:` pointer; mechanism that constrains editors of *other* files stays in the spec. Consolidate `Source of truth:` pointers at the end of a section rather than per paragraph. + When editing specs, keep them concise but do not replace invariants or edge cases with only a code pointer. Use `Source of truth:` for implementation references, and include direction/scope for protocols, command orchestration, and cross-package boundaries. For docs-only compression, spot-check referenced symbols, message directions, and root-vs-package script ownership against code before committing. -Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit. +Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit. The callout licenses using glossary terms bare — do not re-explain them locally. A spec's opening blockquotes are its front-matter: the callout plus, where useful, one line each for what the spec owns, what it defers and to whom, and what to read first; ownership is stated there once, not re-disclaimed per section. ### Spec lifecycle From 330bfe19e1136ae238f841e47be0be70ab01e256 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:08:29 -0700 Subject: [PATCH 02/17] docs(specs): pilot the house form on transport.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bold imperatives absorb the rhetorical scaffolding (must preserve / must mirror / must not require), two restating sentences deleted, and one production-cost clause moved to the rationale file. transport.md was already near house form after the rationale split, so the shed is small by design — the pilot demonstrates the marker style for the corpus pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/transport.md | 8 ++++---- docs/specs/transport.rationale.md | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index b9bf5070..6182b0b0 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -14,7 +14,7 @@ Each platform adapter wraps a PTY-spawning runtime and a transport channel betwe | Pocket (`RemotePtyAdapter`) | the paired laptop's Host | remote protocol-v1 over the relay (`docs/specs/remote-api.md`) | | Fake (tests, playground) | in-process | direct function calls / event emitter | -`RemotePtyAdapter` implements only the PTY core (list/data/write/resize/exit) and no-ops or omits the rest — the interface is built for capability degradation, so a host that cannot do something says so by absence rather than by the UI branching on which host it is. +`RemotePtyAdapter` implements only the PTY core (list/data/write/resize/exit) and no-ops or omits the rest — the interface is built for capability degradation: a host that cannot do something says so by absence, never by the UI branching on host identity. Three optional members are plain booleans rather than methods: @@ -41,7 +41,7 @@ The gate runs before routing and before any body read, and an unauthorized calle The remote Host rides the same shim: `remote_host_command` is one more fire-and-forget send that writes `remoteHost:command` to the sidecar, and the sidecar's `remoteHost:*` events arrive on the SSE stream, so the harness runs a real Host against a per-run temp state directory (`docs/specs/standalone.md` → "Remote Host service"). -The harness may omit native-only desktop chrome such as window controls and update checks, but it must preserve the `PlatformAdapter` PTY, control-request, clipboard, iframe-proxy, remote-Host, and agent-browser contracts used by the app. It also mirrors standalone's Session-persistence answer rather than choosing its own: `BrowserSidecarAdapter` carries the same `PERSIST_SESSION = false` gate as `TauriAdapter`, reports `persistsSession: false`, and deletes any pre-gate `localStorage` blob on `init()` (`docs/specs/standalone.md` → "Standalone persists no Session state"). Persisting here would restore panes across a reload that the real app drops, and would run the record build and its per-pane `getCwd` round trip on a path production never takes. Tauri APIs must not be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because the page is loaded by a normal browser rather than the Tauri WebView. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, remote-Host, agent-browser. It **must mirror** standalone's Session-persistence answer: `BrowserSidecarAdapter` carries the same `PERSIST_SESSION = false` gate as `TauriAdapter`, reports `persistsSession: false`, and deletes any pre-gate `localStorage` blob on `init()` — persisting here would restore panes across a reload the real app drops (`docs/specs/standalone.md` → "Standalone persists no Session state"; rationale). Tauri APIs **must not** be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because a normal browser loads the page, not the Tauri WebView. ## PTY lifecycle @@ -113,7 +113,7 @@ Source of truth: The schema is exhaustive there; what follows is only the contracts that are not obvious from the type. -**Sender authenticity is the adapter's job, not the protocol's.** The schema says what a message means, never that it came from the host. Each adapter must establish that on its own transport before dispatching: the Tauri and browser-dev adapters inherit it from a private IPC channel and a host-owned socket, while the VS Code adapter shares its `window` inbox with framed surfaces and so requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). An adapter whose transport is reachable by page content must authenticate before it branches on `type`. +**Sender authenticity is the adapter's job, not the protocol's.** Each adapter must establish it on its own transport before dispatching: the Tauri and browser-dev adapters inherit it from a private IPC channel and a host-owned socket, while the VS Code adapter shares its `window` inbox with framed surfaces and so requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). An adapter whose transport is reachable by page content must authenticate before it branches on `type`. VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from webview to host. The host validates the requested command against the allowlist in `lib/src/lib/vscode-keybindings.ts` before calling `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. @@ -145,7 +145,7 @@ Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone c | Host → webview | `pty:replay` | Buffered raw output since spawn; the webview parses semantic OSCs during replay reconstruction without triggering alerts. | | Host → webview | `dormouse:newTerminal` | Payload may include `shell`, `args`, display `name`, `replaceUntouched`, and `announce`; the webview replaces the selected untouched terminal in-place only when `replaceUntouched` is true, otherwise it spawns a new pane. | -Both settings messages carry renderer-supplied numbers that become host timers, so the host **must** revalidate rather than trust them: `AlertSettingsHost` runs every inbound blob through `normalizeAlertSettings`, which drops unknown keys, defaults missing ones, and clamps each delay into range. A webview cannot install a `NaN` or absurd attention window. The two directions share one adapter method — `alertPublishSettings(settings, { seed })` — because the seed/replace distinction only picks a message type; it is not a different payload. +Both settings messages carry renderer-supplied numbers that become host timers, so the host **must revalidate** rather than trust them: `AlertSettingsHost` runs every inbound blob through `normalizeAlertSettings`, which drops unknown keys, defaults missing ones, and clamps each delay into range. The two directions share one adapter method — `alertPublishSettings(settings, { seed })` — because the seed/replace distinction only picks a message type; it is not a different payload. **Do not add a third app-global store on this pattern.** Two (WATCHING rules, alarm settings) is worth the directness; a third collapses them into one keyed channel with a host-side key→normalizer registry instead of paying the per-store tax again (rationale). diff --git a/docs/specs/transport.rationale.md b/docs/specs/transport.rationale.md index 926d6b2c..319eae53 100644 --- a/docs/specs/transport.rationale.md +++ b/docs/specs/transport.rationale.md @@ -8,6 +8,8 @@ **Why both loopback spellings are echoed.** `127.0.0.1:` and `localhost:` are the same dev page, and pinning one rejects a developer who typed the other with symptoms — blank terminal, console CORS errors — that do not point at the token gate as the cause. +**The second cost of persisting in the harness.** Besides restoring panes production drops, a harness that persisted would run the record build — and its per-pane `getCwd` round trip — on a path production never takes. + **Agent workflows were unaffected by the gate.** The token reaches the page through the `VITE_DORMOUSE_BROWSER_DEV_HOST` env var the harness already sets, and `agent-browser` drives the Vite origin, never the bridge — so adding authentication cost the agent harness nothing. ## Reconnection protocol From f952c64200a28cac8dfcccace072882b5bafda9a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:17:13 -0700 Subject: [PATCH 03/17] docs(specs): house-form compression for terminal-escapes.md Bold imperatives absorb the scaffolding (must ship / must revalidate / never advertise / must fail inertly), two cross-section dedupes point at the surviving copy, Source of truth lines consolidate at section end, and the strip-only remote parser paragraph sheds prose already carried verbatim by pty-strip.ts's own comments. Registry tables untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/terminal-escapes.md | 50 +++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index fcc5ed6d..8514510b 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -17,9 +17,9 @@ State-driving and security-sensitive OSCs — plus the `CSI > q` query — are p - VS Code: in the extension host, in `vscode-ext/src/message-router.ts` (a parser per PTY, fed from `ptyManager.addCallbacks`), before `pty:data` is forwarded to any webview. - Standalone, browser-sidecar and fake adapters: in the frontend adapter, before xterm.js sees the bytes. -An unterminated OSC is buffered across chunks up to `OSC_INCOMPLETE_LIMIT` (16 KiB); past that the held bytes are dropped rather than buffered without bound, so a hostile never-terminated OSC cannot grow the parser's state. +An unterminated OSC is buffered across chunks up to `OSC_INCOMPLETE_LIMIT` (16 KiB); past that the held bytes are **dropped, never buffered without bound**, so a hostile never-terminated OSC cannot grow the parser's state. -There is one further parse site, and it is **strip-only**: the remote Host in the Tauri sidecar runs a second parser over each PTY it streams to a phone (`lib/src/host/remote/pty-strip.ts`, tapped in `lib/src/host/remote/sidecar-entry.ts`). The phone must see what the laptop's own xterm sees, and in standalone the stripping happens in the frontend adapter, which the sidecar's stream never passes through. Every event that parser produces is discarded — **responses included**: the webview that owns the terminal already answers, and a second answer would write duplicate bytes into the PTY's input. That is why it is constructed with a constant color provider: a query the parser *declines* stays in `visibleData` and reaches the phone's xterm, which answers it, so OSC 10/11/12 queries must be consumed here even though the reply is thrown away. The VS Code Host needs no such parser — the extension host already parsed the chunk once and streams the processed output (see [vscode.md](vscode.md)). +One further parse site is **strip-only**: the remote Host in the Tauri sidecar runs a second parser over each PTY it streams to a phone. The phone must see what the laptop's own xterm sees, and in standalone the stripping happens in the frontend adapter, which the sidecar's stream never passes through. **Every event that parser produces must be discarded — responses included**: the webview that owns the terminal already answers, and a second answer would write duplicate bytes into the PTY's input. **OSC 10/11/12 queries must still be consumed there**, reply thrown away — a query the parser *declines* stays in `visibleData` and the phone's xterm answers it. The VS Code Host needs no such parser: the extension host already parsed the chunk once and streams the processed output ([vscode.md](vscode.md)). Source of truth: `lib/src/host/remote/pty-strip.ts`, tapped in `lib/src/host/remote/sidecar-entry.ts`. ### `pty:data` strip semantics @@ -32,9 +32,9 @@ The platform then sends two streams to the webview: Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`. -The parser also classifies each chunk for quiesce-detector purposes: the activity monitor's `onData()` is called only when `visibleData` is non-empty, so a chunk of nothing but notification/progress OSCs does not count as meaningful output, while visible output alongside them does. +The parser also classifies each chunk for the quiesce detector: the activity monitor's `onData()` fires only when `visibleData` is non-empty, so a chunk of nothing but notification/progress OSCs is not meaningful output, while visible output alongside them is. -For replay (`pty:replay`), the frontend re-parses the buffered raw stream during reconstruction so semantic state repopulates and OSCs are stripped before xterm sees them. Replay must not re-fire alerts, quiesce events, protocol notifications, or query responses: the replay path applies only the semantic events and drops the rest, so a resumed Session does not re-ring or answer a long-dead query on every reload. +For replay (`pty:replay`) the frontend re-parses the buffered raw stream during reconstruction, so semantic state repopulates and OSCs are stripped before xterm sees them. Replay **must not re-fire** alerts, quiesce events, protocol notifications, or query responses — it applies the semantic events and drops the rest, so a resumed Session does not re-ring or answer a long-dead query on every reload. ## Supported OSCs @@ -59,27 +59,27 @@ For replay (`pty:replay`), the frontend re-parses the buffered raw stream during (`BEL` is not itself an OSC; it has a row because a standalone `BEL` is parsed and stripped at the same boundary as the OSCs. A `BEL` that terminates an OSC is part of that sequence, never a bell.) -Some sequences are dual-purpose. The notification rows for `OSC 9 ; `, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in [terminal-state.md](terminal-state.md#supported-osc-inputs). Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored only for the diagnostic title-candidates table in the header context menu. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate. +Some sequences are dual-purpose: the notification rows for `OSC 9 ; `, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in [terminal-state.md](terminal-state.md#supported-osc-inputs). Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored only for the diagnostic title-candidates table in the header context menu, and the OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes one. #### OSC color queries on Windows require the bundled ConPTY -`pty-core.js` spawns with `useConptyDll: true` on Windows: the in-box `CreatePseudoConsole` silently swallows color queries, so nothing can answer and TUIs fall back to a dark background, while node-pty's bundled OpenConsole (`conpty.dll`) forwards them (rationale). That requires `node-pty/prebuilds//conpty.node` plus its sibling `conpty/{conpty.dll,OpenConsole.exe}` to ship: standalone bundles them via the Tauri `resources: ["../sidecar/**/*"]` glob; the VS Code extension via `cp -RL node_modules/node-pty dist/node-pty`. macOS/Linux PTYs forward queries natively, so the flag is Windows-only. It also has an installer consequence on Windows — see [auto-update.md](auto-update.md#sidecar-teardown-on-windows). +`pty-core.js` spawns with `useConptyDll: true` on Windows — the in-box `CreatePseudoConsole` silently swallows color queries, while node-pty's bundled OpenConsole (`conpty.dll`) forwards them (rationale). Both distributions **must ship** `node-pty/prebuilds//conpty.node` plus its sibling `conpty/{conpty.dll,OpenConsole.exe}`: standalone via the Tauri `resources: ["../sidecar/**/*"]` glob, the VS Code extension via `cp -RL node_modules/node-pty dist/node-pty`. macOS/Linux PTYs forward queries natively, so the flag is Windows-only. It also has an installer consequence on Windows — see [auto-update.md](auto-update.md#sidecar-teardown-on-windows). ### OSC 8 hyperlinks -`OSC 8 ; ; ST` starts a hyperlink region and `OSC 8 ; ; ST` closes it. Dormouse parses neither `params` nor URI at the PTY boundary; it passes the sequence through to xterm.js. +Dormouse parses neither `params` nor the URI at the PTY boundary; the sequence passes through to xterm.js. -`terminal-lifecycle.ts` sets xterm.js's `linkHandler` so activation never opens directly — every click routes to Dormouse's external-link confirmation dialog with the URI *and* the link's rendered display text, read back out of the buffer range xterm supplies. The dialog shows the full target, and picks one of three states: +**Activation never opens directly**: `terminal-lifecycle.ts` sets xterm.js's `linkHandler` so every click routes to Dormouse's external-link confirmation dialog with the URI *and* the link's rendered display text, read back out of the buffer range xterm supplies. The dialog shows the full target, and picks one of three states: - **Openable** — cancel plus a primary open action, labelled by scheme (`Open URL` / `Open file` / `Open email` / the raw `scheme:`). Openable means any absolute URI with a scheme: `http:`, `https:`, `mailto:`, `file:`, custom app schemes such as `vscode:`. - **Deceptive** — the display text is URL-shaped (a full URL or a bare domain) but resolves to a different host than the target. **There is no open action at all**: the only actions are close and "Copy deceptive URL to clipboard", and the copy button takes initial focus so a reflexive Enter cannot open anything. A label that merely differs from the URL (a human phrase, or a sibling URL on the same host) is *plain*, not deceptive, and stays openable. - **Blocked** — malformed URIs, control-character-bearing targets, and browser-executable or opaque pseudo-schemes (`javascript:`, `data:`, `blob:`, `about:`). Not silently dropped: the dialog still opens, showing the full target and the reason, with close as the only action. -Cancel/close is the safe default everywhere else, and long targets wrap and scroll rather than truncate so a deceptive target cannot hide past the fold. Every adapter revalidates through `normalizeExternalUri` before opening (VS Code in the extension host before `vscode.env.openExternal`); the dialog is a user-consent affordance, not the security boundary. Source of truth: `lib/src/lib/external-links.ts`, `lib/src/lib/external-link-confirmation.ts`, `lib/src/components/ExternalLinkModal.tsx`. +Cancel/close is the safe default everywhere else, and long targets wrap and scroll rather than truncate so a deceptive target cannot hide past the fold. **Every adapter must revalidate** through `normalizeExternalUri` before opening (VS Code in the extension host, before `vscode.env.openExternal`) — the dialog is a user-consent affordance, not the security boundary. Source of truth: `lib/src/lib/external-links.ts`, `lib/src/lib/external-link-confirmation.ts`, `lib/src/components/ExternalLinkModal.tsx`. ## Supported CSI -The vast majority of CSI handling is delegated to xterm.js. Dormouse only intervenes in the cases below — to answer a query itself, to observe a state change xterm.js processes, to enable an xterm.js feature, or to filter replay output. +Dormouse intervenes only in the cases below — to answer a query itself, to observe a state change xterm.js processes, to enable an xterm.js feature, or to filter replay output. | Sequence | Role | Disposition | Where | |---|---|---|---| @@ -90,27 +90,27 @@ The vast majority of CSI handling is delegated to xterm.js. Dormouse only interv ### Report filtering on the input side -Everything xterm.js emits on `onData` is candidate PTY input, including *replies* it generates itself. Three filters in `lib/src/lib/terminal-report-filter.ts` sit on that path. The two classifiers tokenize the chunk and require **every** token to match, so a report glued onto real keystrokes is never mistaken for one. +Everything xterm.js emits on `onData` is candidate PTY input, *replies* it generates itself included. Three filters sit on that path; the two classifiers tokenize the chunk and require **every** token to match, so a report glued onto real keystrokes is never mistaken for one. - **`inputIsReplayTerminalReport`** — dropped outright while `isReplaying`. Replayed scrollback often contains terminal-generated replies from a long-dead app; routing them into the freshly spawned shell would corrupt its input buffer. Shapes: cursor-position / device-status (`CSI [?] R` / `n`), device attributes (`CSI [?>=] c`), window-manipulation reports (`CSI t` / `x`), DECRQSS reports (`CSI [?] $y`), focus in/out (`CSI I` / `CSI O`), and OSC or DCS replies of any shape. It also gates the untouched-session flag ([layout.md](layout.md)). - **`inputIsSyntheticTerminalReport`** — the broader "this chunk is machine-generated" check (any chunk built only of CSI, SS3 `ESC O `, or OSC tokens). Not dropped; it suppresses input recording and alert attention for that chunk. - **`stripMouseReportsFromInput`** — removes X10 (`CSI M <3 bytes>`), SGR (`CSI < b;x;y M/m`) and urxvt (`CSI b;x;y M`) mouse reports while a mouse-mode override is active, so a report that slips past the DOM-level intercept never reaches the PTY ([mouse-and-clipboard.md](mouse-and-clipboard.md)). -User keyboard escape sequences — arrows, function keys, bracketed paste, kitty modified-key reports, and win32-input-mode key records (`CSI …_`) — must not be swallowed by any of these. +**No filter may swallow user keyboard escape sequences** — arrows, function keys, bracketed paste, kitty modified-key reports, win32-input-mode key records (`CSI …_`). Source of truth: `lib/src/lib/terminal-report-filter.ts`. ### Replay-time mode-reset tail (Dormouse-emitted) -Saved scrollback can end mid-TUI with private modes still latched. Replaying it verbatim re-applies those DECSETs with no process alive to ever DECRST them, so a restored pane can be stuck in mouse-tracking mode, the alt-screen, or with the cursor hidden. After a **dead** session's scrollback replays, Dormouse writes a fixed reset tail (`REPLAY_MODE_RESET`): exit alt-screen (`CSI ? 1049/47/1047 l`), disable mouse tracking (`CSI ? 9/1000/1002/1003 l`), disable mouse encodings (`CSI ? 1005/1006/1015 l`), focus reporting off (`CSI ? 1004 l`), bracketed paste off (`CSI ? 2004 l` — the new shell re-enables it at its prompt), show cursor (`CSI ? 25 h`), application cursor keys off (`CSI ? 1 l`), and `SGR 0`. The only DECSET in the tail is show-cursor; everything else is a DECRST or SGR reset. +Saved scrollback can end mid-TUI with private modes still latched, and replaying it verbatim re-applies those DECSETs with no process alive to ever DECRST them — a restored pane stuck in mouse-tracking mode, the alt-screen, or with the cursor hidden. After a **dead** session's scrollback replays, Dormouse writes a fixed reset tail (`REPLAY_MODE_RESET`): exit alt-screen (`CSI ? 1049/47/1047 l`), disable mouse tracking (`CSI ? 9/1000/1002/1003 l`), disable mouse encodings (`CSI ? 1005/1006/1015 l`), focus reporting off (`CSI ? 1004 l`), bracketed paste off (`CSI ? 2004 l` — the new shell re-enables it at its prompt), show cursor (`CSI ? 25 h`), application cursor keys off (`CSI ? 1 l`), and `SGR 0`. The only DECSET in the tail is show-cursor; everything else is a DECRST or SGR reset. -The tail rides along with a replay, so it is emitted from exactly one place: `resumeTerminal` when `exitInfo.alive` is false. It is **never** emitted on a live resume (a VS Code webview reattaching to a still-running PTY), where the running process legitimately owns its modes; and a cold `restoreTerminal` needs none, because scrollback is not persisted and nothing is replayed there at all. It is written inside `writeReplay`, so `isReplaying` covers it and the replay filter above drops any report it provokes; the mouse-mode observer's parser hooks fire on the DECRSTs and re-sync the mouse-selection store to `none`. Source of truth: `lib/src/lib/terminal-report-filter.ts` (`REPLAY_MODE_RESET`), applied in `lib/src/lib/terminal-lifecycle.ts`. +The tail rides along with a replay, so it is emitted from exactly one place: `resumeTerminal` when `exitInfo.alive` is false. **Never on a live resume** (a VS Code webview reattaching to a still-running PTY), where the running process legitimately owns its modes; a cold `restoreTerminal` needs none, because scrollback is not persisted and nothing is replayed there at all. It is written inside `writeReplay`, so `isReplaying` covers it and the replay filter above drops any report it provokes; the mouse-mode observer's parser hooks fire on the DECRSTs and re-sync the mouse-selection store to `none`. Source of truth: `REPLAY_MODE_RESET` in `lib/src/lib/terminal-report-filter.ts`, applied in `lib/src/lib/terminal-lifecycle.ts`. ### Pass-through and fail-inertly -Unknown CSI sequences pass through to xterm.js so it can handle standard terminal behavior Dormouse does not model. The same fail-inertly rule that applies to OSCs (see [iTerm2 identity](#iterm2-identity)) applies to CSIs: any sequence that xterm.js does not recognize must be consumed silently — no visible terminal garbage, no clipboard or file access, no focus changes, no other side effects. +Unknown CSI sequences pass through to xterm.js, like unknown OSC families. **Any sequence xterm.js does not recognize must be consumed silently** — no visible terminal garbage, no clipboard or file access, no focus changes, no other side effects — the same fail-inertly rule the OSC side carries ([iTerm2 identity](#iterm2-identity)). ## iTerm2 identity -Dormouse reports an iTerm2-compatible identity so that tools (shells, build systems, agent clients) emit the iTerm2-style escape codes this spec set supports. One compatibility version is used across env and device responses: `ITERM2_COMPAT_VERSION`, currently `3.5.0`, defined twice — in `standalone/sidecar/pty-core.js` and `lib/src/lib/terminal-protocol.ts` — pinned together by `lib/src/lib/mirrored-constants.test.ts`. +Dormouse reports an iTerm2-compatible identity so that tools (shells, build systems, agent clients) emit the iTerm2-style escape codes this spec set supports. One compatibility version spans env and device responses: `ITERM2_COMPAT_VERSION`, currently `3.5.0`, defined twice — in `standalone/sidecar/pty-core.js` and `lib/src/lib/terminal-protocol.ts` — pinned together by `lib/src/lib/mirrored-constants.test.ts`. Environment for spawned PTYs: @@ -122,15 +122,15 @@ Environment for spawned PTYs: | `LC_TERMINAL_VERSION` | the same compatibility version | | `COLORTERM` | `truecolor`. The PTY is spawned as `xterm-256color` with no other depth hint, so env-sniffing tools (e.g. `supports-color`) would otherwise quantize RGB output to the nearest palette entry. This is a color-*depth* signal, **independent** of the light/dark *background* detection driven by the OSC color queries above, and not iTerm2-specific. | -On `CSI > q`, Dormouse responds with `DCS > | iTerm2 ST`, matching iTerm2's extended device-attributes shape. Feature-specific support is never advertised until the behavior exists. +The `CSI > q` answer is `DCS > | iTerm2 ST`, matching iTerm2's extended device-attributes shape. **Never advertise** feature-specific support before the behavior exists. -Because this identity can cause tools to emit more iTerm2 escape codes than Dormouse implements, **unsupported escape codes must fail inertly**: consume or ignore them without visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. This applies to both OSC and CSI (see [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) and [Pass-through and fail-inertly](#pass-through-and-fail-inertly)). +The identity provokes more iTerm2 escape codes than Dormouse implements, so **unsupported escape codes must fail inertly**: consumed or ignored, with no visible terminal garbage, privilege escalation, clipboard access, file access, or focus stealing. Applies to OSC and CSI alike (see [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) and [Pass-through and fail-inertly](#pass-through-and-fail-inertly)). ## Shell-integration injection -The iTerm2 identity makes well-behaved tools emit OSC 633/133 *if their own shell integration is loaded* — but most shells don't emit prompt/command boundaries on their own. So Dormouse injects its own integration when it spawns a shell, making the shell emit the `OSC 633` family (`A`/`B` prompt boundaries, `C` command start, `D;` command finish, `E;`, `P;Cwd=`) that the parser above already consumes. This is the *emit* side of OSC 633; the parser is the *consume* side. +The iTerm2 identity gets OSC 633/133 out of tools only *if their own shell integration is loaded*, and most shells emit no prompt/command boundaries on their own. So Dormouse injects its own integration when it spawns a shell, making the shell emit the `OSC 633` family (`A`/`B` prompt boundaries, `C` command start, `D;` command finish, `E;`, `P;Cwd=`) the parser above consumes. This is the *emit* side of OSC 633; the parser is the *consume* side. -A binary on `PATH` only has to be **found**, so it injects via one env var (`DORMOUSE_CLI_BIN` → `PATH`). OSC 633 is different: the shell must **run hook code on every prompt**, which no single env var enables. The reliable per-shell mechanism therefore differs by shell: +A binary on `PATH` only has to be **found**, so one env var injects it (`DORMOUSE_CLI_BIN` → `PATH`). OSC 633 needs the shell to **run hook code on every prompt**, which no single env var enables, so the reliable mechanism differs per shell: | Shell | Mechanism | Channel | Notes | |---|---|---|---| @@ -140,24 +140,24 @@ A binary on `PATH` only has to be **found**, so it injects via one env var (`DOR | WSL | `wsl.exe -d -- sh -c ` → the detector execs the distro's bash with our `--init-file` (the Windows bash script, referenced via its `/mnt/...` path) | shellArgs | Windows-side injection can't reach the Linux shell, so we append a command. The detector reads the login shell from `/etc/passwd`: it steps aside for an explicit zsh/fish login shell, execs bash+integration when bash exists (covering bash and an empty detection — the safe default), and falls back to the login shell only when bash is absent (e.g. Alpine). bash is the only WSL shell integrated for now. Assumes the default `/mnt` automount root. | | cmd.exe | no per-command hook exists | — | Never gets real OSC 633; always uses the keystroke fallback below. | -Injection is wired in `applyShellIntegration`, called from `resolveSpawnConfig` (`standalone/sidecar/pty-core.js`), so it applies to both distributions — the standalone sidecar and the VS Code pty-host both spawn through it. The integration scripts are static files under `standalone/sidecar/shell-integration/`; the directory is resolved from `DORMOUSE_SHELL_INTEGRATION_DIR` (set by the host, mirroring `DORMOUSE_CLI_BIN`) and falls back to the sidecar's own directory. Standalone ships them via the tauri `../sidecar/**/*` resources glob; the VS Code build copies them into `dist/shell-integration`. If the scripts are missing, injection is skipped and the shell spawns exactly as before — injection is fail-safe. +Injection is wired in `applyShellIntegration`, called from `resolveSpawnConfig` (`standalone/sidecar/pty-core.js`), so it applies to both distributions — the standalone sidecar and the VS Code pty-host both spawn through it. The integration scripts are static files under `standalone/sidecar/shell-integration/`; the directory is resolved from `DORMOUSE_SHELL_INTEGRATION_DIR` (set by the host, mirroring `DORMOUSE_CLI_BIN`) and falls back to the sidecar's own directory. Standalone ships them via the tauri `../sidecar/**/*` resources glob; the VS Code build copies them into `dist/shell-integration`. **Injection is fail-safe**: if the scripts are missing it is skipped and the shell spawns exactly as before. -**Emitted fields are filtered before they are written, and that is a security boundary, not tidiness.** A POSIX path component may hold any byte but `/` and NUL, and a command line may hold anything at all, so an attacker-chosen directory name or command can carry an OSC terminator — BEL, `ESC \`, or the C1 ST `U+009C` (all three are what `findOscTerminator` scans for) — ending the `633` sequence early, so the remainder arrives as a fresh, fully-trusted OSC that forges a notification, command line, or title in the shell's own voice. The parser cannot defend against this, because its terminator scan runs on raw bytes: by the time the parser sees them the sequence is already over. The boundary therefore has to be on the *emit* side, in the scripts Dormouse ships (rationale): +**Emitted fields must be filtered before they are written — a security boundary, not tidiness.** A POSIX path component may hold any byte but `/` and NUL, and a command line anything at all, so an attacker-chosen directory name or command can carry an OSC terminator (BEL, `ESC \`, or the C1 ST `U+009C` — all three are what `findOscTerminator` scans for), ending the `633` sequence early so the remainder arrives as a fresh, fully-trusted OSC that forges a notification, command line, or title in the shell's own voice. The parser cannot defend against this: its terminator scan runs on raw bytes, so by the time it sees them the sequence is already over. The boundary has to be on the *emit* side, in the scripts Dormouse ships (rationale): - **`E` (command line)** is escaped by `__dormouse_633_escape`, which covers BEL, ESC and the C1 ST alongside `\`, `;`, LF and CR. Escaping costs nothing here because the parser decodes `\xNN` back, so the command line still reports verbatim. -- **`Cwd=`** cannot be escaped — the parser reads it verbatim, with no `\xNN` decoding, precisely so a Windows path's backslashes arrive intact. `__dormouse_633_safe_cwd` therefore *removes* control characters rather than escaping them. Backslashes and semicolons are deliberately preserved. Under `LC_ALL=C` the C1 ST is two ordinary bytes that `[[:cntrl:]]` does not match, so the shell scripts strip it explicitly first. +- **`Cwd=`** cannot be escaped — the parser reads it verbatim, with no `\xNN` decoding, precisely so a Windows path's backslashes arrive intact. `__dormouse_633_safe_cwd` therefore *removes* control characters rather than escaping them; backslashes and semicolons are preserved. Under `LC_ALL=C` the C1 ST is two ordinary bytes that `[[:cntrl:]]` does not match, so the shell scripts strip it explicitly first. `Source of truth:` `__dormouse_633_escape` and `__dormouse_633_safe_cwd` in each of `standalone/sidecar/shell-integration/bash/shellIntegration.bash`, `standalone/sidecar/shell-integration/zsh/.zshrc`, and `standalone/sidecar/shell-integration/pwsh/shellIntegration.ps1`. Because the injection is emit-side, the tests spawn the real shells rather than mocking them: `standalone/sidecar/shell-integration.test.js` (bash and zsh; it hard-fails if bash is absent and names any uncovered shell out loud). ### Keystroke fallback -When injection isn't possible (cmd.exe, an unknown shell, or scripts not present) or simply doesn't take, Dormouse falls back to its keystroke heuristic: it reads the submitted command off the rendered prompt line and synthesizes `commandStart{source:'user_input'}`. This fallback has no real exit codes and only a best-effort idle transition. The fallback rules — prompt-shape learning, submit parsing, and the per-pane promotion that retires the heuristic on the first authentic OSC boundary (which is what makes it fire "only if injection fails") — are owned by [terminal-state.md](terminal-state.md#keystroke-fallback). +When injection isn't possible (cmd.exe, an unknown shell, scripts not present) or simply doesn't take, Dormouse falls back to its keystroke heuristic: it reads the submitted command off the rendered prompt line and synthesizes `commandStart{source:'user_input'}`, with no real exit codes and only a best-effort idle transition. The fallback rules — prompt-shape learning, submit parsing, and the per-pane promotion that retires the heuristic on the first authentic OSC boundary — are owned by [terminal-state.md](terminal-state.md#keystroke-fallback). > Packaging caveat: the zsh scripts are dotfiles (`.zshrc`, `.zshenv`, `.zprofile`). Confirm the VS Code `.vsix` actually includes `dist/shell-integration/.z*` — if a packaging step strips dotfiles, VS Code silently degrades to the keystroke fallback. ## Known-unimplemented iTerm2 and clipboard-capable sequences -Dormouse intentionally does not implement the following. They are mostly iTerm2-proprietary; `OSC 50` (font) and `OSC 52` (clipboard) are standard xterm extensions included here because the iTerm2 identity prompts tools to emit them and they have security implications. All of them fail inertly per the rule above — consumed and ignored rather than forwarded to xterm.js. +Dormouse does not implement the following, and all of them **fail inertly** per the rule above — consumed and ignored, never forwarded to xterm.js. They are mostly iTerm2-proprietary; `OSC 50` (font) and `OSC 52` (clipboard) are standard xterm extensions listed here because the iTerm2 identity prompts tools to emit them and they have security implications. | Sequence | Purpose | Reason for non-support | |---|---|---| From b91713a152a19f2a7229b87ae8979c3f1b7f126b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:19:34 -0700 Subject: [PATCH 04/17] docs(specs): house-form compression for the three remote specs Light pass: scaffolding adverbs deleted, rules promoted to bold imperatives (never parse the user-agent string; every peer-supplied dimension passes clampTerminalDimension; no single layer is sufficient), the pairing ceremony rendered as a numbered list, and two cross-spec duplicates deduped to the owning spec. Every trust statement, ceremony step, wire shape, Reserved: paragraph, and the protocol-v1 scope block untouched; the same-origin deployment rule now leads its section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/pocket-app.md | 72 ++++++++++++------------- docs/specs/remote-api.md | 55 ++++++++++--------- docs/specs/remote-security-model.md | 84 +++++++++++++++-------------- 3 files changed, 105 insertions(+), 106 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 7928756f..f39ab6a3 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -43,10 +43,10 @@ is absent — the interface is designed for capability degradation (`getCwd` → null, shells/clipboard empty, alerts inert, `alertAwait` settling `cancelled` rather than handing back a promise that never resolves). -Pocket hides `MobileWall`'s local Kill affordance (`showKillButton={false}`): -remote panes are Host-owned, and v1 grants no phone-side kill/layout authority. -Closing a local xterm view without a Host-side close would leave the Host -attachment live and the phone view inconsistent. +**Pocket hides `MobileWall`'s local Kill affordance** (`showKillButton={false}`) +— remote panes are Host-owned, and v1 grants no phone-side kill/layout +authority. Closing a local xterm view without a Host-side close would leave the +Host attachment live and the phone view inconsistent. Adapter-specific extras (the same pattern as `FakePtyAdapter`'s scenario controls): the concrete `RemotePtyAdapter` exposes `setActivePane(id)` — the @@ -69,8 +69,8 @@ Three details the table above leaves implicit: the active pane (`attachableDirectoryEntries` in `lib/src/remote/pocket-app/wall-model.ts`). -Pocket's local "paired" host marker is optimistic cache, not authority. When a -connect denial reports an ACL miss (`passkey-not-paired`, +**Pocket's local "paired" host marker is optimistic cache, not authority.** +When a connect denial reports an ACL miss (`passkey-not-paired`, `device-not-paired`, or `pairing-mismatch`), Pocket clears that marker and shows Pair again so expected Host ACL resets, revocations, or browser device-key loss recover through the normal pairing ceremony. @@ -102,10 +102,10 @@ static serving of the built Pocket bundle. ## Design system and theming -Pocket is a product surface, not a marketing page: all of it — the auth screens -included — renders on the shared themeable design system (`--color-*` tokens -over `--vscode-*`; [theme.md](./theme.md), `DESIGN.md`), never the website's -separate "homepage" system (`website/src/index.css`). There is no +Pocket is a product surface, not a marketing page: **all of it — the auth +screens included — renders on the shared themeable design system** (`--color-*` +tokens over `--vscode-*`; [theme.md](./theme.md), `DESIGN.md`), never the +website's separate "homepage" system (`website/src/index.css`). There is no Pocket-specific palette; changing the theme re-skins the auth screens and the wall together. @@ -193,8 +193,8 @@ iOS, cookies, `localStorage`, and IndexedDB are not shared between Safari and a Home Screen web app, so the install generates its own device key and is a *different Client* than the same phone's Safari tab. -Exactly one consequence survives, and it is the security model working as -designed: the install needs **its own pairing approval** on each Host. Signing +**The install needs its own pairing approval on each Host** — the one +consequence that survives, and the security model working as designed: signing in is not enough to reach a machine, and a Client the user has not approved there must not inherit access ([remote-security-model.md](./remote-security-model.md)). @@ -202,10 +202,9 @@ there must not inherit access Signing in *is* enough to ask. `SigninFinishResponse` returns the asserted passkey's public key, which a Client needs to build pair and connect requests, so a profile that never performed the registration can still pair rather than -being pushed into a redundant second passkey — that limit was an artifact of the -wire, not a property of the trust model. The key is public: the Host is handed -it in every `ConnectionRequest` anyway, and holding it authorizes nothing. If -the cached copy disappears mid-session, Pocket directs the user to sign in +being pushed into a redundant second passkey. Holding that public key authorizes +nothing ([remote-security-model.md](./remote-security-model.md) -> Device Keys). +If the cached copy disappears mid-session, Pocket directs the user to sign in again, and the verified response restores it on any profile. Source of truth: `PASSKEY_UNAVAILABLE_MESSAGE` and `PocketClient.signin` in `lib/src/remote/client/pocket-client.ts`. @@ -231,8 +230,8 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` in - **Install is required** is the *presence* of `navigator.standalone`, even when it is `false`. The property is iOS/iPadOS Safari only and `undefined` everywhere else — including macOS Safari, where Web Push works in an ordinary - tab and an install prompt would be wrong. This deliberately avoids parsing a - user-agent string, which iPadOS makes unreliable by reporting as a Mac. + tab and an install prompt would be wrong. **Never parse the user-agent + string** here — iPadOS makes it unreliable by reporting as a Mac. - **A tab cannot see whether the app is also installed.** The two have separate storage and share no signal, so the install notice necessarily also shows to someone who installed it and opened the wrong window; the copy says so rather @@ -273,18 +272,18 @@ Which Hosts those are is read back from the Server when the Hosts view opens, not remembered locally, so a reload does not re-offer an action already taken and a row pruned after a 410 stops claiming alerts are on. The read is `GET /api/push/subscriptions`, which returns the **account's** registrations as -identities and is filtered to this device by `PocketClient` — deliberately not -parameterized by `devicePublicKey`, which would be an enumeration primitive over -an input the caller need not own, where the account's own rows are already its -to read (the same scoping `GET /api/hosts` uses). `POST /api/push/subscribe` -answers with the same thing — every Host this device is registered with after -the mutation — so both answers are complete and neither is a delta: nothing to -merge, only the question of which is newer. Pocket counts completed -registrations, captures that count when a read begins, and discards the read's -snapshot if a registration overtook it; it also clears the previous snapshot at -the start of a read, so a failed read re-offers an idempotent action instead of -preserving a stale **Alerts on** claim. Source of truth: `getPushAvailability` -in `lib/src/remote/client/push-subscribe.ts`, +identities and is filtered to this device by `PocketClient`. **Never +parameterize that read by `devicePublicKey`** — it would be an enumeration +primitive over an input the caller need not own, where the account's own rows +are already its to read (the same scoping `GET /api/hosts` uses). +`POST /api/push/subscribe` answers with the same thing — every Host this device +is registered with after the mutation — so both answers are complete and neither +is a delta: nothing to merge, only the question of which is newer. Pocket counts +completed registrations, captures that count when a read begins, and discards +the read's snapshot if a registration overtook it; it also clears the previous +snapshot at the start of a read, so a failed read re-offers an idempotent action +instead of preserving a stale **Alerts on** claim. Source of truth: +`getPushAvailability` in `lib/src/remote/client/push-subscribe.ts`, `PocketClient.listPushSubscribedHosts`, and the hosts-phase effect in `lib/src/remote/pocket-app/App.tsx`. @@ -408,9 +407,8 @@ line: the pairing ceremony verifies no assertion laptop's approval modal is the control — and the fingerprint that modal shows, of the key that is *asking*, is a value nobody can check unless the phone shows it too. Both ends call the same `pairingFingerprint` helper from -`server-lib-common` (an 8-character slice of the base64url public point, taken -past the two near-constant leading characters) so they cannot drift into showing -different slices of the same key. +`server-lib-common` — an 8-character slice of the base64url public point, taken +past the two near-constant leading characters. It renders whenever the key loads, paired or not, so it reads as a property of this browser rather than a step in a flow. A key that fails to load leaves it @@ -419,10 +417,10 @@ failure. Source of truth: `HostsView` in `lib/src/remote/pocket-app/App.tsx`. ## Deployment: same-origin, always -WebAuthn binds passkeys to the serving origin, and Chrome's Private Network -Access rules are progressively blocking public-site → private-network fetches. -Both point the same way: **the Pocket app is always served same-origin with -its API.** Pocket holds itself to it by construction — an empty API base and a +**The Pocket app is always served same-origin with its API.** WebAuthn binds +passkeys to the serving origin, and Chrome's Private Network Access rules are +progressively blocking public-site → private-network fetches — both point the +same way. Pocket holds itself to it by construction — an empty API base and a `wsBase` derived from `location.origin` — and the Server enforces it: a registration or assertion whose `clientDataJSON.origin` is not the configured `DORMOUSE_ORIGIN` is rejected, so a Pocket served anywhere else cannot sign in diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 2a1f2817..645e87c6 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -25,12 +25,12 @@ one protocol, not two: | Layout mutations | no | yes | | Input | to attached pane | to any surface | -Design principle, and a standing constraint on everything staged below: -**replicate state, don't stream a desktop.** Terminals are sent as PTY data and -rendered client-side; browser surfaces will be sent as per-surface screencasts. -This is what makes VR viable — each surface arrives as its own independently -placeable stream — and it makes the phone cheap: one attached surface, one -stream. +**Replicate state, don't stream a desktop** — the design principle, and a +standing constraint on everything staged below. Terminals are sent as PTY data +and rendered client-side; browser surfaces will be sent as per-surface +screencasts. This is what makes VR viable — each surface arrives as its own +independently placeable stream — and it makes the phone cheap: one attached +surface, one stream. ## v1 scope @@ -53,7 +53,7 @@ Everything else — including browser-surface remoting — is staged in ### The provider seam -The Host runs in the process that owns the PTYs, never a webview +**The Host runs in the process that owns the PTYs, never a webview** (`docs/specs/server.md` → "Host side"). Within it, `RemoteApiSession` speaks this protocol and nothing else: surface ids, PTY ids, sizes, and bytes. *Where* a named surface lives — this window's webviews, another window's, another @@ -73,8 +73,7 @@ attachment's stream or input to another window. ## Terminology -`docs/specs/glossary.md` is canonical for **Pane** and **Surface**, and the wire -shapes reuse the existing surface model (`dor/src/protocol.ts`, +The wire shapes reuse the existing surface model (`dor/src/protocol.ts`, `dor/src/commands/types.ts`): a Surface is named on the wire by `surfaceId`, and the picker lists Panes, so attaching to a Pane means attaching to its selected Surface. Remote-only vocabulary: @@ -118,7 +117,7 @@ a first data frame. The six methods and three events are named constants (`REMOTE_METHODS`, `REMOTE_EVENTS`); events are dispatched by name, so a future event lands additively and an old client ignores what it does not know. -Every peer-supplied `cols`/`rows` passes through `clampTerminalDimension` — +**Every peer-supplied `cols`/`rows` passes through `clampTerminalDimension`** — 1 … `MAX_TERMINAL_DIMENSION` (2000), falling back to the current size when absent or non-finite — on the Host, in the webview responder that drives the real xterm, and in the Client adapter. The upper bound is the security-relevant half: a local @@ -183,10 +182,10 @@ type DirectoryEvent = | { event: 'directory.snapshot'; data: { entries: DirectoryEntry[] } }; ``` -Snapshot-only, deliberately: a directory is dozens of entries at most, so on -any change the Host coalesces (150ms window, `DIRECTORY_DEBOUNCE_MS`) and -resends the whole thing. Delta events are a future optimization there is no -current reason to pay for. +Snapshot-only: a directory is dozens of entries at most, so on any change the +Host coalesces (150ms window, `DIRECTORY_DEBOUNCE_MS`) and resends the whole +thing. Delta events are a future optimization there is no current reason to +pay for. **One snapshot per collect.** The provider answers for every surface the Host can reach, so no subset is known sooner than the rest. A collect is dropped rather @@ -211,10 +210,10 @@ unconditionally. Both feed the same coalescer, which re-collects from every answerer before sending the replacement snapshot. The picker renders from titles, activity, and the `ringing`/`hasTODO` badges; -thumbnails are staged. Browser and iframe surfaces are neither listed nor -attachable — they never enter the xterm registry the directory is collected from, -so `surface.attach` cannot resolve them either (see [Future](#future) for browser -remoting; iframe surfaces are not on the critical path even there). +thumbnails are staged. **Browser and iframe surfaces are neither listed nor +attachable** — they never enter the xterm registry the directory is collected +from, so `surface.attach` cannot resolve them either (see [Future](#future) for +browser remoting; iframe surfaces are not on the critical path even there). `alive` is real PTY-process liveness. Dormouse keeps an exited pane open in the Host registry (rendering "[Process exited with code N]") until the user closes @@ -232,8 +231,8 @@ reporting `alive: false` may carry no `exitCode` at all. detach cannot kill a newer attachment; detaching anything that is not the current attachment is an idempotent no-op. One attachment per session (the phone's model); lifting that cap for VR is future work. Attachment is -view-state only with one deliberate exception: attaching to a terminal takes -size authority. +view-state only with one exception: attaching to a terminal takes size +authority. ### Terminal surfaces @@ -260,9 +259,9 @@ dimensions and there is no snapshot transfer: The bounce goes down, except from a 1-row surface, where `rows - 1` would itself be a no-op that fires no `SIGWINCH`. -Normal-screen history does not regenerate on resize; it is deliberately absent -from the shipped protocol (see [Future](#future): in-flight replay, then -semantic scrollback). +Normal-screen history does not regenerate on resize, and is absent from the +shipped protocol (see [Future](#future): in-flight replay, then semantic +scrollback). ```ts // client → host @@ -341,17 +340,17 @@ peer `subscribe` / `subscribed` frames in `vscode-ext/src/peer-link.ts`. A terminal has one size, and the most recent size writer owns it: attaching with dimensions and `terminal.resize` both take authority, and the Host user -interacting with the pane locally reclaims it. There is deliberately no remote -detach at the surface owner: the Host stops streaming on its side and the pane -keeps whatever size it was left at, which is what last-attach-wins means. The +interacting with the pane locally reclaims it. **There is no remote detach at +the surface owner** — the Host stops streaming on its side and the pane keeps +whatever size it was left at, which is what last-attach-wins means. The Host-side **"tethering to \"** display that greys out other displays of a tethered pane is staged — see [Future](#future); today the authority semantics hold at the PTY level without the dedicated display. ## Input authority and multiple viewers -Input authority is deliberately flat: selfhost is single-user, so every paired -session is the owner and gets full input (`grants: { input: true, layout: false }`), +**Input authority is flat**: selfhost is single-user, so every paired session +is the owner and gets full input (`grants: { input: true, layout: false }`), and no session gets layout operations. Concurrent sessions then need no special machinery: attach state is per-session, diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 2eb670b4..d61e0ef2 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -20,8 +20,7 @@ qualification). `SECURITY.md` -> "Remote Control" is this model's audited face: it names the subset of the properties below that are load-bearing enough to be checked nightly, and states plainly which risks are accepted (the setup password's -minimal hardening) and which gaps are open (revocation, the audit trail). This -spec is the design; that section is what a machine verifies about it. +minimal hardening) and which gaps are open (revocation, the audit trail). The primitives — assertion verification, device signatures, challenges, the ACL, the ceremony — live in `server-lib-common/src/security/`: runtime-agnostic @@ -77,7 +76,7 @@ Non-goals: ## Trust Model -Dormouse intentionally separates: +Dormouse separates: | Layer | Responsibility | | ---------- | -------------------------- | @@ -86,8 +85,8 @@ Dormouse intentionally separates: | Host ACL | Authorization | | Host | Final access decision | -No single layer is sufficient to gain access. A successful connection requires -all layers to agree. +**No single layer is sufficient to gain access** — a successful connection +requires all four to agree. ## Passkeys @@ -96,11 +95,10 @@ WebAuthn assertion, validated by **both** the Server and the Host. Passkeys are frequently *synchronized* credentials — iCloud Keychain, Google Password Manager, platform authenticators — so a single passkey may appear on -multiple physical devices. Therefore: +multiple physical devices: -> Passkeys are treated as user credentials, not device identities. - -A passkey authenticates a user account but does not grant access to any Host. +> Passkeys are treated as user credentials, not device identities. A passkey +> authenticates a user account; it grants access to no Host. **Presence, or verification.** The default demand is the authenticator's user-*presence* flag. A deployment raises it to user *verification* (biometric @@ -154,10 +152,10 @@ recoverable event (see [Device Key Loss](#device-key-loss)). Sign-in returns the asserted passkey's **public** key, so any browser profile holding a synced passkey can build pair and connect requests rather than only -the one that registered it. Deliberately not a weakening: the key is public, -the Host receives it in every `ConnectionRequest` regardless, and a Client that -signs in has merely asked — it reaches nothing until the Host's local approval -adds *its own device key* to the ACL. +the one that registered it. Not a weakening: the key is public, the Host +receives it in every `ConnectionRequest` regardless, and a Client that signs in +has merely asked — it reaches nothing until the Host's local approval adds *its +own device key* to the ACL. The device key has one use outside connection establishment: a Client signs its Web Push subscription with it, binding that subscription to the same identity @@ -216,12 +214,16 @@ is *not* automatically trusted — the Client must still complete Host pairing. from builds that kept the Host in webview `localStorage`; that path is bounded separately — `SECURITY.md` -> "Remote Control".) -What the ceremony establishes: the Client authenticates with a passkey and -presents its device public key; the Host displays local approval UI (the -pairing modal, same pattern as KillConfirm — `docs/specs/server.md`, Host -side); the user approves locally on the Host; the Host writes the -`HostAclRecord` binding the passkey credential identity to the device public -key. The Client is now trusted by that Host and no other. +What the ceremony establishes, in order: + +1. The Client authenticates with a passkey and presents its device public key. +2. The Host displays local approval UI (the pairing modal, same pattern as + KillConfirm — `docs/specs/server.md`, Host side). +3. The user approves locally on the Host. +4. The Host writes the `HostAclRecord` binding the passkey credential identity + to the device public key. + +The Client is now trusted by that Host and no other. **The approval is only as good as what the modal lets a human check.** The ceremony verifies no assertion, so the person at the Host *is* the control — @@ -238,14 +240,13 @@ text, reduced by `boundedPairingLabel` / `boundedPairingAccount` before display, so neither can overflow the dialog or carry bidi overrides that make it read as something else. -The Host validates the request's shape itself (`isPairingRequest`) rather than -relying on the Server having done so, for the same reason it re-verifies -everything at connect: the Server is not trusted, and an unvalidated relayed -object reaching the approval UI is both a crash surface and a route to a -malformed ACL record. +**The Host validates the request's shape itself** (`isPairingRequest`), never +relying on the Server having done so: the Server is not trusted, and an +unvalidated relayed object reaching the approval UI is both a crash surface and +a route to a malformed ACL record. -Each displayed approval is bound to the ceremony ticket's immutable -`pairingId`. If a Client replaces its pending request while the old modal or +**Each displayed approval is bound to the ceremony ticket's immutable +`pairingId`.** If a Client replaces its pending request while the old modal or its click command is still in flight, the Host rejects that stale action; it never selects a request by mutable `clientId` alone. Source of truth: `RemoteHostService.#pendingPairing` in `lib/src/host/remote/service.ts` and the @@ -279,9 +280,9 @@ any map is touched. That bounds the *pairing* path only. `connect` creates a client entry by another route (`#resetAuthorization`); those carry no pending request, and the -pairing counter deliberately neither sees nor evicts them, since dropping an -entry that may be `established` is a different act from denying a pending -request. They are cleared wholesale when the relay socket drops. +pairing counter neither sees nor evicts them, since dropping an entry that may +be `established` is a different act from denying a pending request. They are +cleared wholesale when the relay socket drops. Source of truth: `PairingRequest` / `PairingTicket` / `PairingCeremony` / `PAIRING_PRESENCE_WINDOW_MS` / `PAIRING_FIELD_LIMIT` / `MAX_PENDING_PAIRINGS` @@ -319,10 +320,10 @@ that mints before it can authenticate the caller — `POST /api/signin/begin` retains only what a caller can mint inside one TTL window, rather than accumulating for the process's lifetime. -Every new `connect` / `connect2` closes that Client's established message gate -and disposes its prior control session before this evaluation, and only the -newest evaluation may re-open that gate: each attempt carries an authorization -generation, and one that has been superseded while it awaited verification sends +**Every new `connect` / `connect2` closes that Client's established message +gate** and disposes its prior control session before this evaluation, and only +the newest evaluation may re-open that gate: each attempt carries an +authorization generation, and one superseded while it awaited verification sends no decision at all — otherwise an older `allowed` landing last would re-open the gate its successor had just closed. A structurally malformed request from the relay is contained as a denied decision rather than an async failure in the Node @@ -336,11 +337,13 @@ connection. The concrete sequence is the connect diagram in ## Storage Durability -Where the device key lives is browser-managed storage, and durability differs -by platform: an iOS browser tab is the weakest (storage may be evicted after -inactivity — do not treat it as permanent), an Android browser tab is generally -durable, and an installed PWA is the preferred mode on both, strongest on -Android. +The device key lives in browser-managed storage, and durability differs by +platform: + +* **iOS browser tab** — weakest; storage may be evicted after inactivity. + **Never treat it as permanent.** +* **Android browser tab** — generally durable. +* **Installed PWA** — the preferred mode on both, strongest on Android. Today Pocket generates the device key in whatever context it runs — the install-before-pairing guidance and storage-persistence hardening are staged @@ -369,9 +372,8 @@ Dormouse is designed so that: * Passkey synchronization does not automatically create trusted Clients. * Every trusted Client must be explicitly paired with every Host. * Every connection requires fresh user presence. -* Every access decision is ultimately made by the Host. - -The Host remains the final authority throughout the system. +* Every access decision is ultimately made by the Host — the final authority + throughout the system. **One honest qualification.** "Server compromise grants no Host access" bounds *creation*, not *action*. After a decision the Host gates `msg` frames on From 90323d08732b150f4bda50eeb6fa6de41cbe4fea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:20:29 -0700 Subject: [PATCH 05/17] docs(specs): house-form compression for alert.md Bold imperatives absorb the scaffolding across the Await ladder, WATCHING invariants, and completion-event ordering (never reorder 3 and 4; a retired id must stay retired; a failed await absorbs nothing); two ordering rules and the detector invariants become bullets; Source of truth lines consolidate at section end; three speech-teardown mechanism clauses land where alert-speech.ts's own comments already carry them, plus one new comment absorbing the token-clearing rule. The sw.js boundedPushText mirror now names its pinning test. Text And Security untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/alert.md | 111 ++++++++++++++++++++---------------- lib/src/lib/alert-speech.ts | 3 + 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index c9ba1a64..1ab0c3e5 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -1,8 +1,10 @@ # Alert Spec > See `docs/specs/glossary.md` for Session / Pane / Door vocabulary. This spec uses it throughout. +> +> Owns the Session Activity layer — the three alert tracks, attention, TODO, notification text and its sanitization, the two alarm sinks, and the Workspace union projection. `docs/specs/layout.md` defers here for all alert/TODO behavior and owns placement and sizing. -Terminal alert state belongs to the **Session** Activity layer. It survives Pane <-> Door movement and is destroyed with the Session. This spec also defines how the Workspace union counts browser-surface TODO flags: a browser Surface has no Session Activity machine, can never ring, and carries only a user-set TODO flag as Surface state that is destroyed with that browser Surface. +Alert state belongs to the Session Activity layer: it survives Pane <-> Door movement and is destroyed with the Session. A browser Surface has no Activity machine, can never ring, and carries only a user-set TODO flag as Surface state, destroyed with that Surface. Dormouse can owe the user attention in three ways: @@ -10,14 +12,14 @@ Dormouse can owe the user attention in three ways: - **Terminal report**: the PTY emitted a supported notification or progress protocol (`BEL`, `OSC 9`, `OSC 9;4`, `OSC 99`, or `OSC 777`). - **Command exit**: Dormouse saw a foreground command running while the user attended the Session, attention was lost while that same command was still running, and the command exited after at least `T_USER_ATTENTION`. -Terminal-report and command-exit alerts do not require WATCHING. All three share the same attention suppression rule — do not ring if the user is actively attending that Session at the completion moment — applied at the one seam every completion passes through (Completion events below). +Terminal-report and command-exit alerts do not require WATCHING. All three obey one suppression rule — **never ring while the user is actively attending that Session** at the completion moment — applied at the single seam every completion passes through (Completion events below). Internally these are three independent tracks — `watchingRingingCommand` (+ `outputSinceWatchingRing`), `protocolStatus` + `progress`, and `commandExitStatus` + `commandExitWatch`. Each runs IDLE -> busy/armed -> ringing without entangling the others, and each latches its own ring in the entry until it is cleared. The output/silence detector (`QuiesceDetector`) is not a track: it is an always-on observer the WATCHING track reads. ## Non-goals - No process heuristics. Dormouse never decides on its own that `vim`, `npm dev`, agents, or test runners deserve alerts. WATCHING applies only to command names the user explicitly asked for. -- No native OS notifications on the machine Dormouse runs on, and no separate progress-bar widget. The one audible local channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. Push notifications are the deliberate exception and go only to a *remote* paired phone, never to this machine — the point is reaching a user who walked away. +- No native OS notifications on the machine Dormouse runs on, and no separate progress-bar widget. The one audible local channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. Push notifications are the exception and go only to a *remote* paired phone, never to this machine — the point is reaching a user who walked away. - No process-tree introspection for command-exit alerts; normalized terminal semantic events are the reliable input. - No HTML, Markdown, ANSI styling, clickable actions, custom icons, or remote-controlled buttons in notification previews. - No Door-specific alert menu that changes the Door actions defined in `docs/specs/layout.md`. @@ -30,13 +32,13 @@ Public `status` is a projection — first match wins: 1. `ALERT_RINGING` if any of the three tracks is ringing. 2. `OSC_NOTIF_BUSY` if protocol progress is active. -3. The output/silence detector's own state if WATCHING is on — that is, if the rule set matches the running command. The detector runs regardless; the rule is what makes its state public. It outranks the command-exit arm deliberately — do not reorder 3 and 4 (rationale). +3. The output/silence detector's own state if WATCHING is on — if the rule set matches the running command. The detector runs regardless; the rule only makes its state public. **Never reorder 3 and 4** (rationale). 4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. 5. Otherwise `WATCHING_DISABLED`. -`awaited` sits beside `status`: true while at least one `dor await` is parked on the Session (Await below). It is derived from live waiters and is **never persisted** — a wait cannot survive the process that was blocking on it. +`awaited` sits beside `status`: true while at least one `dor await` is parked on the Session (Await below). It is derived from live waiters and **must never be persisted** — a wait cannot survive the process that was blocking on it. -Persist only `todo` and the sanitized `notification` (plus `status` for diagnostics). Restore replays those two and nothing else: it must not recreate a ring, protocol progress, or a command-exit arm. WATCHING is not per-Session state and is never persisted per Session — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. +**Persist only** `todo` and the sanitized `notification` (plus `status` for diagnostics); restore replays those two and **must not** recreate a ring, protocol progress, or a command-exit arm. WATCHING is not per-Session state and is never persisted per Session — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. ## Attention @@ -57,11 +59,14 @@ Source of truth: `cfg.alert` in `lib/src/cfg.ts` defines the shipped default for ## Completion events -Every completion — a detector settle, a command finish, a direct notification, and the end of a protocol progress cycle (completion or error) — is dispatched as a `CompletionEvent` **before** any suppression runs. Nothing is decided at the point of detection, so an observer sees the three-second `npm test` that finishes while the user is watching it and would never have rung anyone. +Every completion — a detector settle, a command finish, a direct notification, and the end of a protocol progress cycle (completion or error) — is dispatched as a `CompletionEvent` **before any suppression runs**: nothing is decided at the point of detection, so an observer sees even the three-second `npm test` that finishes attended and would never have rung anyone. -Claimants are registered per Session and get first refusal, in registration order; the first to return `true` claims the event and the rest are not offered it. A claimed event never rings, never sets TODO, and never stores an `ActivityNotification` — it stops before the ring rules. An unclaimed event falls through to its track's ring rule, which is where the attention suppression above and the command-exit armed and minimum-runtime checks live. With no claimant registered, the three tracks below behave exactly as they always have. +Claimants are registered per Session and get first refusal, in registration order; the first to return `true` claims the event and the rest are not offered it. **A claimed event never rings, never sets TODO, and never stores an `ActivityNotification`** — it stops before the ring rules. An unclaimed event falls through to its track's ring rule, which is where the attention suppression above and the command-exit armed and minimum-runtime checks live. -Two ordering rules matter. The progress cycle is cleared *before* dispatch, so a completion or error ends the cycle whether or not the event is claimed and `OSC_NOTIF_BUSY` falls back either way. And a command finish is dispatched for every watch that existed, including the short, unarmed, and attended ones the ring rule then discards. +Two ordering rules: + +- **Clear the progress cycle *before* dispatch**, so a completion or error ends the cycle whether or not the event is claimed and `OSC_NOTIF_BUSY` falls back either way. +- **Dispatch a command finish for every watch that existed**, including the short, unarmed, and attended ones the ring rule then discards. Source of truth: `registerCompletionClaimant` / `dispatchCompletion` in `lib/src/lib/alert-manager.ts`. @@ -78,11 +83,11 @@ Source of truth: `awaitCompletion` in `lib/src/lib/alert-manager.ts`, reached th | `quiet` | The Session settled, **or** the foreground command exited, **or** the Session emitted a notification | `quiet` / `exit` / `bell` | Agents that never exit — `claude`, `codex` | | `exit` | The foreground command exited. Nothing else | `exit` | Builds, test runs, migrations | -The ladder is deliberate: never narrow `quiet` to silence alone, and never let `exit` resolve on a bell (rationale). +**Never narrow `quiet` to silence alone, and never let `exit` resolve on a bell** (rationale). -`--until` has no default and is never inferred from the WATCHING rule set — that set is a human notification preference, and binding a program's wake condition to it would let an unrelated edit silently change what every `await` on the Session is waiting for (rationale). The caller states its own intent instead. +**`--until` has no default and is never inferred from the WATCHING rule set** — a human notification preference must not decide a program's wake condition (rationale). The caller states its own intent. -Settling comes from the always-on detector (WATCHING Track below), which needs no shell integration and cannot fire until it has been BUSY. A Session at a prompt is silent, but silent is not settled — which is what stops `dor send` followed immediately by an await from racing on the silence before the peer's first byte. +Settling comes from the always-on detector (WATCHING Track below), which needs no shell integration and cannot fire until it has been BUSY. A Session at a prompt is silent, but **silent is not settled**, so `dor send` followed immediately by an await cannot race on the silence before the peer's first byte. **Is there anything to wait for?** The one thing silence cannot distinguish is a peer that delivered its final answer long ago from one working quietly. @@ -91,7 +96,7 @@ Settling comes from the always-on detector (WATCHING Track below), which needs n | A foreground command is running (`commandExitWatch`) | There is something to wait for. Park, with no grace window. A silent build therefore resolves on its exit rather than being guessed at. | | Nothing running | Park for one grace window. A *command start* cancels it under either condition — it is the same "there is something to wait for" the row above tests, arriving a moment late. Under `quiet` *output* cancels it too; under `exit` output alone does not. Whichever arrives, the await goes on waiting for a real signal. Neither → resolve `cause: idle`. | -`idle` is a resolution, not a failure, and it is a distinct `cause` rather than a distinct failure (rationale). Absent shell integration, "is a command running" is unanswerable, so an `exit` await on such a shell falls back to the grace window and resolves `idle`: the host cannot tell a shell with no integration from one sitting at a prompt, so it degrades rather than erroring. +`idle` is a resolution, not a failure — a distinct `cause` (rationale). Absent shell integration, "is a command running" is unanswerable, so an `exit` await on such a shell falls back to the grace window and resolves `idle` rather than erroring. **Resolution consumes only the ring it resolved on.** An await that arrives while the Session is already ringing resolves immediately, with the cause named by *that ring's own source*: a protocol ring is `bell`, a command-exit ring is `exit`, a WATCHING ring is `quiet`. Under `exit` only a command-exit ring counts; the others are the human's and the await keeps waiting. Two of the three are gated, because their latches outlive the fact they describe: @@ -101,11 +106,11 @@ Settling comes from the always-on detector (WATCHING Track below), which needs n Consuming releases that one track's latch and **nothing else** — `todo` is neither set nor cleared, no `ActivityNotification` is dropped, `attentionDismissedRing` is untouched, and `attentionId` is never set. -**An await never sets TODO, and never clears a pre-existing one.** TODO means *a human owes this pane attention*, and after an await nobody does: a program asked to be told, was told, and acted. A TODO left by an unrelated earlier event is still owed to the human (rationale). +**An await never sets TODO, and never clears a pre-existing one.** TODO means *a human owes this pane attention*, and after an await nobody does — a program asked to be told, was told, and acted. A TODO left by an unrelated earlier event is still owed to the human (rationale). -**Absorption: absorb the summons, keep the receipt.** A completion an await consumes never latches a ring, so it does not ring the bell, speak an alarm, or push to a paired phone — the program is already handling it, and summoning the human too is noise. Nothing quieter is substituted (rationale). Absorption is **per-signal, not per-Session**: if the human independently holds a WATCHING rule on that Session, the next settle rings for them as usual. A failed await absorbs nothing — a timeout, a death, or a cancel claims no completion, so a crashed orchestration cannot silently eat the one signal that would have told the human the build finished. +**Absorption: absorb the summons, keep the receipt.** A completion an await consumes never latches a ring, so it does not ring the bell, speak an alarm, or push to a paired phone — the program is already handling it. Nothing quieter is substituted (rationale). Absorption is **per-signal, not per-Session**: if the human independently holds a WATCHING rule on that Session, the next settle rings for them as usual. **A failed await absorbs nothing** — a timeout, a death, or a cancel claims no completion, so a crashed orchestration cannot silently eat the one signal that would have told the human the build finished. -Claiming is delivery. Once a completion has been handed to an await the wait is settled and a later `cancel()` is a no-op; there is no release-after-claim. The window between claiming and the caller actually reading the outcome is therefore unacknowledged, and that is accepted (rationale). +**Claiming is delivery.** Once a completion has been handed to an await the wait is settled and a later `cancel()` is a no-op; there is no release-after-claim. The window between claiming and the caller reading the outcome is therefore unacknowledged (rationale). **Timing.** Every window derives from `cfg.alert`, so an await inherits the tuning the bell has had in the field: @@ -115,11 +120,11 @@ Claiming is delivery. Once a completion has been handed to an await the wait is | Settle — "has it stopped?" | 5000ms | `mightNeedAttention` + `needsAttentionConfirm` | | Ceiling | `timeoutMs` | `dor await`'s `--timeout` (seconds, default 600), and the only number not derived from `cfg.alert` | -`timeoutMs` is not an alert-tuning knob: it is the safety rail on a blocking call inside an agent loop, so a wedged peer cannot hang its caller forever. It is enforced **host-side**, alongside the grace and settle windows, so no intermediate hop can reap a parked await early and no caller can park forever by lying about its own deadline. The CLI accepts whole-second ceilings from 1 through 86400 (24h), and the host's `MAX_AWAIT_TIMEOUT_MS` matches; a ceiling is needed at all because `setTimeout`'s delay is a signed 32-bit millisecond count, so anything past ~24.9 days overflows and fires at once (rationale). A non-finite, non-positive, or over-ceiling host request is **rejected rather than clamped**: it settles `cancelled`, having absorbed nothing. The webview handler rejects the same values with a visible error rather than letting them settle silently. +`timeoutMs` is not an alert-tuning knob: it is the safety rail on a blocking call inside an agent loop, so a wedged peer cannot hang its caller forever. **Enforce it host-side**, alongside the grace and settle windows, so no intermediate hop can reap a parked await early and no caller can park forever by lying about its own deadline. The CLI accepts whole-second ceilings from 1 through 86400 (24h) and the host's `MAX_AWAIT_TIMEOUT_MS` matches; a ceiling exists at all because `setTimeout` overflows past ~24.9 days (rationale). A non-finite, non-positive, or over-ceiling host request is **rejected rather than clamped**: it settles `cancelled`, having absorbed nothing. The webview handler rejects the same values with a visible error rather than letting them settle silently. Several awaits may park on one Session. They share a single claimant, so one completion is delivered to every await whose condition it satisfies rather than only to whoever registered first, and each resolves on the first qualifying signal after it registered. -In VS Code the `AlertManager` lives in the extension host while `dor` control requests land in a webview, so an await crosses that boundary: the webview posts `alert:await` and, if it gives up, `alert:awaitCancel`; the host answers exactly one `alert:awaitResult` per request — a cancel included, so a claim is never released twice. The wait itself never leaves the host. A webview that disposes cancels everything it had parked, because a caller that cannot be answered must not go on absorbing, and it answers those requests itself *synchronously*, since the cancelled outcome would otherwise arrive a microtask after the router stopped posting. `cancelled` has no wire outcome of its own — the webview reports it to `dor` as an error, which is also what forgets the in-flight control request. Source of truth: `vscode-ext/src/message-router.ts` and `VSCodeAdapter.alertAwait`; the other hosts run the `AlertManager` in-process and call `awaitCompletion` directly. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles any request `cancelled` at once rather than parking a promise that can never resolve. +In VS Code the `AlertManager` lives in the extension host while `dor` control requests land in a webview, so an await crosses that boundary: the webview posts `alert:await` and, if it gives up, `alert:awaitCancel`; the host answers **exactly one** `alert:awaitResult` per request — a cancel included, so a claim is never released twice. The wait itself never leaves the host. A disposing webview **must cancel everything it had parked**, because a caller that cannot be answered must not go on absorbing, and **must answer those requests itself *synchronously*** — the cancelled outcome would otherwise arrive a microtask after the router stopped posting. `cancelled` has no wire outcome of its own — the webview reports it to `dor` as an error, which is also what forgets the in-flight control request. Source of truth: `vscode-ext/src/message-router.ts` and `VSCodeAdapter.alertAwait`; the other hosts run the `AlertManager` in-process and call `awaitCompletion` directly. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles any request `cancelled` at once rather than parking a promise that can never resolve. | Situation | Outcome | |---|---| @@ -139,7 +144,7 @@ In VS Code the `AlertManager` lives in the extension host while `dor` control re **The output/silence detector is always on.** Every Session runs one `QuiesceDetector` for its whole lifetime, fed by every output chunk and reset at every command boundary. It is a plain observer: it never latches and knows nothing about attention or rules. The rule set decides only whether the detector's state is publicly visible and whether a settle — a busy Session that stayed quiet — is allowed to *ring*. -Because a chunk creates the Session's entry, `remove(id)` also has to keep it removed: `disposeSession` retires the alert state and only *then* kills the PTY, so output already in flight would otherwise rebuild an entry and a detector that nothing ever disposes. Raw output and resizes therefore cannot revive a retired id — they are exactly what a dying PTY emits. A semantic or protocol event can, because an id may be handed to a replacement pane, and its first reported command start is the evidence that somebody is home. +**A retired id must stay retired.** Because a chunk creates the Session's entry, `disposeSession` retires the alert state and only *then* kills the PTY — otherwise output already in flight rebuilds an entry and a detector nothing ever disposes. **Raw output and resizes never revive a retired id**; they are exactly what a dying PTY emits. A semantic or protocol event may, because an id can be handed to a replacement pane and its first reported command start is the evidence that somebody is home. Rules: @@ -160,30 +165,34 @@ Rules: | `MIGHT_NEED_ATTENTION` | A busy Session went quiet. Debounce state. | | `ALERT_RINGING` | WATCHING observed likely completion while the Session lacked attention. | -Source of truth: `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts` implements the detector's `QuiesceStatus` transitions; `AlertManager.onSettled` in `lib/src/lib/alert-manager.ts` reports every settle into the completion-event seam above, whose `settled` ring rule decides whether it rings. Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. The invariants the implementation must honor: +Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. The invariants the implementation must honor: - Output drives the detector up the chain `NOTHING_TO_SHOW` -> `MIGHT_BE_BUSY` -> `BUSY`; silence drives it down `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled. The `MIGHT_*` states are debounce windows in both directions. - First output starts candidate tracking without changing status; unconfirmed `MIGHT_BE_BUSY` returns to `NOTHING_TO_SHOW`. -- The detector never holds `ALERT_RINGING`. A settle is reported once and the detector immediately returns to `NOTHING_TO_SHOW`; the ring it may raise latches in the Session entry (`watchingRingingCommand`), which is what makes the public status `ALERT_RINGING` and what keeps it there through further output. -- A settle rings only if a rule matches the foreground command *and* the Session lacks attention at the confirmation moment. Attention at confirmation time suppresses the ring. -- Attention alone never resets the detector: an in-flight `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled transition continues, so a parked quiet await still receives its completion. Only attending or dismissing an actual WATCHING ring resets it, to `NOTHING_TO_SHOW`, so the tail of the run that just rang cannot immediately settle again. -- Rings must be caused by a fresh transition — a settle the detector just reported — never by rerender, theme change, remount, minimize, or reattach. +- **The detector never holds `ALERT_RINGING`.** A settle is reported once and the detector immediately returns to `NOTHING_TO_SHOW`; the ring it may raise latches in the Session entry (`watchingRingingCommand`), which makes the public status `ALERT_RINGING` and keeps it there through further output. +- **A settle rings only if** a rule matches the foreground command *and* the Session lacks attention at the confirmation moment. +- **Attention alone never resets the detector**: an in-flight `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled transition continues, so a parked quiet await still receives its completion. Only attending or dismissing an actual WATCHING ring resets it, to `NOTHING_TO_SHOW`, so the tail of the run that just rang cannot immediately settle again. +- **Rings must be caused by a fresh transition** — a settle the detector just reported — never by rerender, theme change, remount, minimize, or reattach. + +Source of truth: `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts` implements the `QuiesceStatus` transitions; `AlertManager.onSettled` in `lib/src/lib/alert-manager.ts` reports every settle into the completion-event seam above, whose `settled` ring rule decides whether it rings. ## Terminal reports Terminal notifications are explicit requests for attention and are independent of WATCHING. A direct notification rings immediately only when the Session lacks attention; if the user has attention it is suppressed and unrelated protocol progress is left alone. A ring sets `todo = true`, stores the latest sanitized `ActivityNotification`, and sets `protocolStatus = ALERT_RINGING`; clearing it returns `protocolStatus` to `IDLE` and public status falls back to the other tracks. -Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`; parsing is `lib/src/lib/terminal-protocol.ts`. What each one means here: +Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`. What each one means here: -- **Standalone `BEL`** — a `BEL` outside an OSC is stripped from visible output and creates `TERMINAL_BELL_NOTIFICATION`. If the same parse batch also holds a richer OSC notification or progress event, drop the generic bells so they cannot overwrite useful preview text; multiple bells in one batch collapse to one notification. +- **Standalone `BEL`** — a `BEL` outside an OSC is stripped from visible output and creates `TERMINAL_BELL_NOTIFICATION`. If the same parse batch also holds a richer OSC notification or progress event, **drop the generic bells** so they cannot overwrite useful preview text; multiple bells in one batch collapse to one notification. - **`OSC 9`** — the message becomes the body, title null. Empty sanitized messages are ignored. It also feeds title-candidate derivation in `docs/specs/terminal-state.md`, which does not change alert behavior. - **`OSC 777`** — only the `notify` subcommand is supported. The first field after `notify` is the title; everything after the next semicolon is body, preserving semicolons there. Unsupported subcommands and empty sanitized notifications are ignored. - **`OSC 99`** (kitty) — metadata keys are single ASCII letters separated by `:`; unknown keys are ignored. `i` groups chunks of one pending notification, `d` is the done flag (default `1`), `e` selects plain or base64 payload encoding, and `p` selects the payload type (default `title`). `title`/`body` chunks append to the pending notification; completion rings once if the sanitized title or body is nonempty. Without `i`, only a complete single-sequence notification is meaningful. Management payloads contribute no content and are consumed: `p=?` sends `OSC99_SUPPORT_PAYLOAD` and `p=close` / `p=alive` are dropped outright, touching no pending notification. Any *other* unknown payload type still obeys kitty's done-flag semantics — carrying the default `d=1` it completes a pending same-`i` notification, which may then ring on its accumulated title/body. The pending-chunk TTL and max-pending-id cap live in `terminal-protocol.ts`. -- **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and creates no TODO; it never rings because of silence. `state=1, progress=100` rings as completion and `state=2` rings as error, both only when unattended. A clear rings as completion only if there was an active cycle, otherwise it is ignored. Warning progress does not ring by itself, but completing a warning cycle rings with a generated warning title. Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. Completion or error while attended clears the progress without TODO or ring. Source of truth for the generated titles/bodies: `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. +- **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and creates no TODO; it never rings because of silence. `state=1, progress=100` rings as completion and `state=2` rings as error, both only when unattended. A clear rings as completion only if there was an active cycle, otherwise it is ignored. Warning progress does not ring by itself, but completing a warning cycle rings with a generated warning title. Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. Completion or error while attended clears the progress without TODO or ring. + +Source of truth: parsing, sanitization limits, and OSC 99 chunk state in `lib/src/lib/terminal-protocol.ts`; the generated progress titles/bodies in `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. ## Command-exit Track -The command-exit track consumes normalized semantic command events from `docs/specs/terminal-state.md` (`OSC 133`, `OSC 633`, or equivalent). It must not parse raw OSC itself. +The command-exit track consumes normalized semantic command events from `docs/specs/terminal-state.md` (`OSC 133`, `OSC 633`, or equivalent) and **must not parse raw OSC itself**. Rules: @@ -216,7 +225,7 @@ Clearing behavior: ## Alarm settings -A second app-global store sits beside the WATCHING rule set: the alarm settings, edited in the app-global **Settings** dialog reached from the far right of the baseboard (`lib/src/components/SettingsDialog.tsx`). That dialog also carries the theme picker ([theme.md](./theme.md)), the shell picker ([standalone.md](./standalone.md)), and the remote-control section ([server.md](./server.md)); the alarm sections specified here are the rest of it. Each of those keeps its own store — none is folded into `AlertSettings`, which is relayed wholesale to the VS Code extension host. +A second app-global store sits beside the WATCHING rule set: the alarm settings, edited in the app-global **Settings** dialog (below). That dialog also carries the theme picker ([theme.md](./theme.md)), the shell picker ([standalone.md](./standalone.md)), and the remote-control section ([server.md](./server.md)); the alarm sections specified here are the rest of it. **Each of those keeps its own store — never fold one into `AlertSettings`**, which is relayed wholesale to the VS Code extension host. Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mirror, persisted at `dormouse:alert-settings`) and `lib/src/lib/alert-settings-host.ts` (multi-renderer coordinator). @@ -228,23 +237,29 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi Rules: -- Every field is validated and clamped on read *and* on write (`normalizeAlertSettings`), so a hand-edited `localStorage` blob or a hostile message can never install a `NaN` or absurd timer. Unknown keys are dropped and missing keys defaulted, so the blob evolves additively with no version field. The shipped defaults come from `cfg.alert`, keeping `lib/src/cfg.ts` the one place a default is written down. -- Distribution follows the WATCHING rule set's seed/broadcast shape, and for the same reason: each VS Code webview has its own origin and therefore its own `localStorage`, while the `AlertManager` is shared. The one difference is that an edit relays the **whole** blob rather than a per-command delta — not just the field the host consumes — so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. +- **Validate and clamp every field on read *and* on write** (`normalizeAlertSettings`), so a hand-edited `localStorage` blob or a hostile message can never install a `NaN` or absurd timer. Unknown keys are dropped and missing keys defaulted, so the blob evolves additively with no version field. The shipped defaults come from `cfg.alert`, keeping `lib/src/cfg.ts` the one place a default is written down. +- Distribution follows the WATCHING rule set's seed/broadcast shape, and for the same reason: each VS Code webview has its own origin and therefore its own `localStorage`, while the `AlertManager` is shared. The one difference is that an edit **relays the whole blob** rather than a per-command delta, so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. - Single-webview hosts (standalone, browser sidecar, Storybook) own the `AlertManager` in the renderer, so they apply the settings inline and broadcast nothing back. -**Both sinks run over one machine**, `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts`, rather than each carrying a copy of rules subtle enough to drift. It detects a *fresh* transition into `ALERT_RINGING` — any of the three tracks; "not attended" is track-agnostic — waits that sink's delay, then re-reads both the ring and the setting before firing, so attending, dismissing, killing the Pane, or switching the sink off during the delay cancels. A Session observed for the first time *already* ringing never fires: that is what keeps a restore or reconnect replaying a latched ring silent, and a restored session blob from buzzing the phone at every launch. One fire per ring — a Session that rings, is cleared, and rings again fires twice — and Sessions are independent, as are the two sinks: both fire when both are on, each on its own delay. +**Both sinks run over one machine**, `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts`, rather than each carrying a copy of rules subtle enough to drift: + +- It fires on a *fresh* transition into `ALERT_RINGING` — any of the three tracks; "not attended" is track-agnostic. +- **Re-read both the ring and the setting after the delay**, so attending, dismissing, killing the Pane, or switching the sink off during the delay cancels. +- **A Session observed for the first time *already* ringing never fires** — what keeps a restore or reconnect replaying a latched ring silent, and a restored session blob from buzzing the phone at every launch. +- One fire per ring: a Session that rings, is cleared, and rings again fires twice. +- Sessions are independent, as are the two sinks — both fire when both are on, each on its own delay. ### Spoken alarms When a Session rings and stays unattended for `speakDelayMs`, Dormouse says that Pane's name out loud. Source of truth: `lib/src/lib/alert-speech.ts`, armed once by `useAlertSpeech` in `Wall`. -- **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken whenever that text wins the normal Pane-label derivation. This is deliberate: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied it. The ringing `ActivityNotification` is never itself the speech payload. -- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, asterisks, and control characters become spaces, whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. This is a security requirement, not tidiness — WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like `` and terminal-supplied titles reach speech, so without this any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks are substituted for clarity rather than safety: `eight *` must not be announced as "eight asterisk". +- **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken whenever that text wins the normal Pane-label derivation: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied it. **The ringing `ActivityNotification` is never itself the speech payload.** +- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, asterisks, and control characters become spaces, whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like `` and terminal-supplied titles reach speech, so without this any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks are substituted for clarity rather than safety: `eight *` must not be announced as "eight asterisk". - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts` is a renderer-local `speaking | spoken` map keyed by Session. The engine's `start` event publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`. An utterance that never starts publishes neither. Each utterance carries an opaque generation token, so a late callback from a resolved or older ring cannot overwrite a newer ring or resurrect a cleared marker. - **Nothing in the settle path may assume the callback arrives after `speak()` returns.** An engine may dispatch `start` and then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). So the handlers close over the utterance itself and registration happens before dispatch; reading a variable the caller assigns afterward would drop the settle and pin the Session at `speaking` for the rest of the ring. A dispatch the engine refuses outright settles too. -- **Attending mid-sentence cuts the utterance off.** Once a deliberate action resolves the ring, finishing the sentence is noise, so the engine is silenced rather than the overlay merely un-rendered. What counts as mid-sentence is the sink's own record that an utterance started — its generation token — not the rendered `speaking` state. Web Speech has no per-utterance stop, so `cancel()` empties the whole queue: every still-ringing Session whose current-ring utterance had been accepted but not started is re-dispatched, because attending one Pane must not silence another Pane's alarm. A re-dispatch is a fresh decision to speak, held to the same gates as the first (attended meanwhile, or the setting switched off, drops out). A queued entry is pruned as soon as its ring resolves, so a later unrelated `cancel()` cannot re-dispatch a stale one, bypass the new ring's delay, and speak twice. A Session that is only queued is never cut — it has nothing audible to stop, and cutting it would take the Pane that *is* talking with it. -- **Teardown silences the engine, not just the callbacks.** Detaching handlers only protects the renderer's own state; `speechSynthesis` still owns its queue, so the disposer calls `cancel()`. Otherwise a webview that unmounts mid-alarm — closing a VS Code webview, switching workspaces — keeps reading Pane names aloud with no visible source and no UI left to stop it. -- **In-flight tracking is bounded.** A dropped utterance (the WebKit wedge above) never fires a callback to retire itself, so the tracking set and the Session-keyed queued index evict their oldest entry past a small shared cap rather than pinning an utterance and handler closure per ring for the life of the app. An evicted utterance that does still fire settles normally; it is merely no longer eligible for collateral re-dispatch. After teardown the generation token makes any late callback inert. +- **Attending mid-sentence cuts the utterance off** — silence the engine, not merely un-render the overlay. Mid-sentence is the sink's own record that an utterance started — its generation token — never the rendered `speaking` state. Web Speech has no per-utterance stop, so `cancel()` empties the whole queue: **re-dispatch every still-ringing Session whose current-ring utterance was accepted but never started**, because attending one Pane must not silence another Pane's alarm, and hold each re-dispatch to the same gates as the first (attended meanwhile, or the setting switched off, drops out). **Prune a queued entry as soon as its ring resolves**, so a later unrelated `cancel()` cannot re-dispatch a stale one, bypass the new ring's delay, and speak twice. **Never cut a Session that is only queued** — it has nothing audible to stop, and cutting it would take the Pane that *is* talking with it. +- **Teardown must `cancel()` the engine, not just detach the callbacks** — detaching protects only the renderer's own state, and a webview that unmounts mid-alarm would keep reading Pane names aloud with no visible source and no UI left to stop it. +- **In-flight tracking is bounded.** A dropped utterance (the WebKit wedge above) never fires a callback to retire itself, so the tracking set and the Session-keyed queued index evict their oldest entry past a small shared cap. An evicted utterance that does still fire settles normally; it is only no longer eligible for collateral re-dispatch. - `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`. Any deliberate action that resolves the ring clears it: clicking or entering the Pane, typing in passthrough, clicking/pressing `Enter` on its Door, dismissing the bell, or marking/clearing TODO. Mere visibility, hover, or command-mode selection does not. Killing the Session also clears it. The state is not persisted or sent to the host, so restore/reconnect never recreates it. - Renderer-side, via `window.speechSynthesis`. Where that is absent — Tauri on Linux (WebKitGTK ships no speech backend), or a test environment — speaking is a silent no-op rather than an error. `speak()` is the single seam a native host path would replace. - Desktop shell only: `MobileWall` / Pocket does not arm it and has no settings UI (no baseboard, so no Settings dialog). @@ -253,16 +268,16 @@ When a Session rings and stays unattended for `speakDelayMs`, Dormouse says that When a Session rings and stays unattended for `pushDelayMs`, Dormouse sends that Pane's name to every paired phone that has enabled alerts. Desktop shell only, and only where a Host runs — a build with no enrollment has nowhere to push. -**The two halves run in different processes.** Ring *detection* is webview state — the activity store, the alarm settings, the Pane's derived label — so `watchPushRings` (`lib/src/remote/host/alert-push.ts`) stays in the webview and fires one `push { sessionId, title }` command at the Host service. *Delivery* needs the enrollment and the ACL, which only the Host holds, so `sendPush` (`lib/src/remote/host/push-delivery.ts`) runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it; the service reads its own active ACL at send time. Watching is armed only while the service reports an enrollment (`enrolled-gate.ts`), so a machine that never enrolls pays no activity-store subscription, and a `push` arriving with no Host running is simply not sent. Both halves live under `remote/host/` to keep the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. +**The two halves run in different processes.** Ring *detection* is webview state — the activity store, the alarm settings, the Pane's derived label — so `watchPushRings` (`lib/src/remote/host/alert-push.ts`) stays in the webview and fires one `push { sessionId, title }` command at the Host service. *Delivery* needs the enrollment and the ACL, which only the Host holds, so `sendPush` (`lib/src/remote/host/push-delivery.ts`) runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it; the service reads its own active ACL at send time. Watching is armed only while the service reports an enrollment (`enrolled-gate.ts`), so a machine that never enrolls pays no activity-store subscription, and a `push` arriving with no Host running is not sent. Both halves live under `remote/host/` to keep the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. - **The derived Pane label is the payload**, on the same rule as speech: the ringing `ActivityNotification`'s title/body is not selected as the payload, but terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can appear when it is the winning Pane label. The body is a fixed string; the Pane name carries the information. -- **The label is sanitized by `toPushText` at send time, in the delivery half, and deliberately not by `toSpokenText`'s rule.** It keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `toPushText` is only this sink's limit and fallback over `boundedPushText`, which lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing. -- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host deliberately does **not** ask which devices are subscribed first (rationale). +- **The label is sanitized by `toPushText` at send time, in the delivery half, and not by `toSpokenText`'s rule.** It keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `toPushText` is only this sink's limit and fallback over `boundedPushText`, which lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing — the mirror is pinned by `lib/src/remote/pocket-app/service-worker.test.ts`. +- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host does **not** ask which devices are subscribed first (rationale). - **One notification per Session at a time.** Each push carries the Session id as a collapse tag, so a Pane that rings, is cleared, and rings again replaces its own notification rather than stacking copies on the lock screen. - **A push already delivered is never recalled.** Attending during the delay cancels it like any sink, but once it is out, reaching the phone again means sending a second push, and `userVisibleOnly` guarantees that would itself be visible — recall would trade one stale notification for one confusing one. - Delivery is an HTTP POST to the Server, not a relay frame ([server.md](./server.md) -> Web Push). The relay routes between two live sockets; a push exists to reach a phone whose app is closed. -- A failed send warns and is dropped, in both failure classes: a non-2xx response is checked rather than ignored, so a revoked host token cannot leave push permanently broken and silent; and a 2xx whose `PushSendResponse` counts report `failed > 0` or `delivered: 0` warns too, because the Server answers 200 even when every push service refused delivery. There is nothing useful to retry against — by the next ring the alarm is already stale. -- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`). A phone can enable alerts long after this machine booted, so a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. The list is the Host's join of the Server's subscriptions against its own ACL labels, so it comes back over the same bridge as a `pushDevices` command and answers `null` — rendered `no-host` — when no Host is running. Writes are fenced on request order (latest-request-wins), so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": the enrolled gate's disarm calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot land afterwards and repopulate the dialog with phones there is nothing left to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher installed, so the dialog can still ask on an un-enrolled machine and be told `no-host`; `resetPushDevices` drops the refresher too and is full teardown (a Storybook story, a test). +- **A failed send warns and is dropped**, in both failure classes: a non-2xx response is checked rather than ignored, so a revoked host token cannot leave push permanently broken and silent; and a 2xx whose `PushSendResponse` counts report `failed > 0` or `delivered: 0` warns too, because the Server answers 200 even when every push service refused delivery. **Never retry** — by the next ring the alarm is already stale. +- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`), because a phone can enable alerts long after this machine booted and a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. The list is the Host's join of the Server's subscriptions against its own ACL labels, so it comes back over the same bridge as a `pushDevices` command and answers `null` — rendered `no-host` — when no Host is running. **Writes are fenced on request order** (latest-request-wins), so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": the enrolled gate's disarm calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot land afterwards and repopulate the dialog with phones there is nothing left to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher installed, so the dialog can still ask on an un-enrolled machine and be told `no-host`; `resetPushDevices` drops the refresher too and is full teardown (a Storybook story, a test). ### Settings dialog @@ -270,17 +285,17 @@ When a Session rings and stays unattended for `pushDelayMs`, Dormouse sends that Reached from any of the controls at the far right of the baseboard; placement and the baseboard's right cluster belong to `docs/specs/layout.md`. Source of truth: `lib/src/components/SettingsDialog.tsx`. The alarm sections below sit under the theme and shell rows; when both are hidden (VS Code owns the theme and the shells), the rule list is first and drops its section divider. - Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation. -- Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to. +- **Delays are committed on blur or `Enter`, never per keystroke** — typing `3` on the way to `30` must not briefly install a 3-second timer. They are shown in seconds; an out-of-range or empty entry snaps back to whatever the store clamped it to. - The push group's device line names every device a push would reach, and otherwise states why there is none — no Host enrolled, nothing subscribed yet, or the server could not be asked. A push that silently goes nowhere is indistinguishable from a broken one. - Each alarm sink carries a **try it now** control — **Play test sound** and **Send test push** — because an alarm is otherwise unobservable until it fires unattended, which is the moment its being wrong costs the most. Source of truth: `lib/src/components/AlarmTestButtons.tsx`. Both sit outside the switch's dimming and stay enabled while the sink is off: checking that the speakers work, or that the phone buzzes, is most useful *before* committing to the alarm. Each reports its own outcome inline and clears it after a few seconds, because for both sinks a working path and a broken one produce the same observation — silence. - - **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but deliberately not through `speak()`: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does. + - **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but **not through `speak()`**: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does. - **Send test push** goes through the real Host, ACL and server, so what it proves is what the alarm will do. It is the one caller of the push path that must **not** swallow failures — the ring path's rule that a failed push never breaks the alert path would make a test button report success over a fan-out that reached nobody. It distinguishes four outcomes: no devices targeted (the ordinary answer on a freshly enrolled machine, and not a failure), nothing delivered, a partial fan-out, and success. The button is hidden entirely where no Host service exists, matching the Remote control section ([server.md](./server.md)). ## Workspace union -> See `docs/specs/glossary.md` for the Workspace / Window containers and the definitions of the three union fields (`ringing`, `todo`, `count`). +> `docs/specs/glossary.md` defines the Workspace / Window containers and the three union fields (`ringing`, `todo`, `count`). -The projection is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. It is display-only: it never enters the Activity state machine and never fires a ring of its own, so it simply mirrors whichever per-Session rings survive attention suppression. A Surface with no activity entry contributes nothing, and one that is both ringing and TODO counts once. Callers must include minimized (`Doored`) Surfaces — and, once Workspaces are more than one, the Surfaces of inactive (unmounted) Workspaces — because a Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob. +The projection is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. **Display-only:** it never enters the Activity state machine and never fires a ring of its own, mirroring whichever per-Session rings survive attention suppression. A Surface with no activity entry contributes nothing, and one that is both ringing and TODO counts once. Callers **must include** minimized (`Doored`) Surfaces — and, once Workspaces are more than one, the Surfaces of inactive (unmounted) Workspaces — because a Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob. Where it surfaces is host-specific: @@ -307,11 +322,11 @@ Bell interactions — one transition table, in `dismissOrToggleAlert` in `lib/sr - Pressing `a` on the selected Pane in command mode uses the same action. Right-click always opens the dialog. - Pressing `t` toggles TODO. -The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the list of every watched command with a remove control. The list is load-bearing, not decoration: it is the only place a rule set on a since-closed Pane can be found and removed. +The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the same `WatchedCommandList` the Settings dialog renders — load-bearing, not decoration, for the reason under Settings dialog above. The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO. On clear, the pill briefly shows the success flourish before unmounting. -Spoken-alarm delivery is deliberately much louder than the bell. While the engine is actually speaking, a pointer-transparent treatment spans the whole terminal Pane: a wash, an animated high-contrast inset, and an explicit `SPEAKING` label. After the utterance settles the animation stops, but a static inset, a `SPOKEN` label, and a half-strength wash remain until the ring is resolved — `SPOKEN` is an unbounded window, so the haze stays light enough to read terminal text through. `prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse, as does `cfg.alert.ringingPaused` (the Chromatic freeze that pins the bell — an infinite opacity cycle would otherwise snapshot at an arbitrary phase). Layering, placement, and sizing belong to `docs/specs/layout.md`; source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`. +Spoken-alarm delivery is much louder than the bell. While the engine is actually speaking, a pointer-transparent treatment spans the whole terminal Pane: a wash, an animated high-contrast inset, and an explicit `SPEAKING` label. After the utterance settles the animation stops, but a static inset, a `SPOKEN` label, and a half-strength wash remain until the ring is resolved — `SPOKEN` is an unbounded window, so the haze stays light enough to read terminal text through. `prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse, as does `cfg.alert.ringingPaused` (the Chromatic freeze that pins the bell — an infinite opacity cycle would otherwise snapshot at an arbitrary phase). Layering, placement, and sizing belong to `docs/specs/layout.md`; source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`. ### Door diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index 25fc4c67..d75b61e7 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -328,6 +328,9 @@ export function startAlertSpeech(): () => void { return () => { stopRingWatch(); unsubscribeActivity(); + // Evicted utterances keep their handlers (see `track`), so detaching below + // does not reach them. Dropping the tokens is what makes any late callback + // from one inert: `settle` early-outs on the generation check. currentToken.clear(); for (const utterance of utterances) detach(utterance); utterances.clear(); From 97cdb00f42f66f30fa5f4952855920fc63e8a0ab Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:20:58 -0700 Subject: [PATCH 06/17] docs(specs): house-form compression for server.md Bold imperatives absorb scaffolding across the guardrails, connect-src enforcement, and store contract; four narrative clusters (enforcement points, push-subscription rules, store contract, the four connect checks) become bullets; Source of truth lines consolidate per section; two mechanism restatements drop where host-status-store.ts and activation.ts already carry them, and one lifecycle-chain why moves to the rationale file. Every security check, ordering rule, and the final-authority statement keep full force; tables and the saas-multitenant scope untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/server.md | 341 +++++++++++++++++---------------- docs/specs/server.rationale.md | 2 + 2 files changed, 173 insertions(+), 170 deletions(-) diff --git a/docs/specs/server.md b/docs/specs/server.md index b7973821..bb0a771b 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -1,19 +1,20 @@ # Server (selfhost) > See `docs/specs/glossary.md` for Session / Pane / Surface vocabulary; this spec uses it for what the relay exposes. +> Owns the selfhost Server (`server/`) and the shared Host-service runtime (`lib/src/host/remote/`). Read +> [remote-security-model.md](./remote-security-model.md) first — it owns the trust model this one deploys; +> [remote-api.md](./remote-api.md) owns what flows after authorization, [pocket-app.md](./pocket-app.md) the phone. -The coordinating Server from the -[remote security model](./remote-security-model.md), in its selfhost mode, cut -down to the smallest thing that completes this loop: +The coordinating Server from the remote security model, in its selfhost mode, +cut down to the smallest thing that completes this loop: > Run the server with a setup password. Visit it, present the password, create > a passkey. Pair your phone with your laptop's Dormouse Terminal. Pick up a > running terminal session from the laptop on the phone. -One Node process (Hono, as the `server` package already is). No database. No -browser-surface support — **terminal-only**. The heavy lifting is already -done: every security primitive lives in `server-lib-common`, and the terminal -UI lives in `lib`/`standalone`. +One Node process (Hono). No database. **Terminal-only.** Every security +primitive lives in `server-lib-common`; the terminal UI lives in +`lib`/`standalone`. ## Guardrails @@ -24,9 +25,9 @@ UI lives in `lib`/`standalone`. * A dropped WebSocket is handled by reloading the page / reconnecting the host. No resume protocol. * Everything transient (challenges, sessions, relay state) is in memory; a - server restart just means everyone reconnects. In-memory is not unbounded: - `HostChallengeIssuer.issue` prunes expired entries on every call, and - `PairingCeremony` drops tickets one TTL past expiry — the frames that mint + server restart means everyone reconnects. **Transient stores must prune** — + `HostChallengeIssuer.issue` drops expired entries on every call, + `PairingCeremony` tickets one TTL past expiry — because the frames that mint them are cheap to send and need little or no auth (rationale). ## Configuration @@ -44,19 +45,17 @@ This table is the whole of what `server/src/` reads from the environment. | `DORMOUSE_BIND_HOST` | Interface to listen on. Unset binds every interface (what a container wants); set `127.0.0.1` when a TLS proxy on the same machine is the front door. | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair. Set both or neither. At startup the Server decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, the server mints a pair on first boot and persists it to `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292). Defaults to `DORMOUSE_ORIGIN` when that origin is https and not loopback; otherwise there is no default and push stays off. Validated at startup — an invalid value, a loopback contact included, exits. | -| `DORMOUSE_RUNTIME_FILE` | Absolute path the server records `{pid, releaseId, port, origin, startedAt}` into once it has **bound**, mode `0600`. Unset — dev, containers, every test — writes nothing. A relative value is a `ConfigError`: the wrapper runs under a service manager whose working directory is not the installer's, so it would land somewhere neither side can predict. Deliberately outside `DORMOUSE_STATE_DIR`, being runtime truth about one process rather than durable state that gets backed up and restored. | +| `DORMOUSE_RUNTIME_FILE` | Absolute path the server records `{pid, releaseId, port, origin, startedAt}` into once it has **bound**, mode `0600`. Unset — dev, containers, every test — writes nothing. A relative value is a `ConfigError`: the wrapper runs under a service manager whose working directory is not the installer's, so it would land somewhere neither side can predict. Outside `DORMOUSE_STATE_DIR`: runtime truth about one process, not durable state that gets backed up and restored. | | `DORMOUSE_RELEASE_ID` | The release directory's name, supplied by the installer's `run-server` wrapper, recorded in the runtime file. `null` when the server was not started by an installer. | -WebAuthn requires a secure context: `localhost` works for development; for a -real phone, put the server behind TLS (`tailscale serve` is the intended -selfhost path, any reverse proxy works). The server itself always speaks -plain HTTP. +**The server itself always speaks plain HTTP**, and WebAuthn requires a secure +context: `localhost` works for development; a real phone needs TLS in front +(`tailscale serve` is the intended selfhost path, any reverse proxy works). -Because of that, the listen interface is a security boundary whenever the TLS -proxy is local: `tailscale serve` reaches the app over loopback, so leaving the -socket on every interface would also publish the plaintext port to the LAN and -to the tailnet itself. `DORMOUSE_BIND_HOST` exists to close that, and the -selfhost install sets it. The default stays unbound so a container — where the +**Must bind loopback when the TLS proxy is local.** `tailscale serve` reaches +the app over loopback, so a socket left on every interface also publishes the +plaintext port to the LAN and to the tailnet itself; the selfhost install sets +`DORMOUSE_BIND_HOST`. The default stays unbound so a container — where the namespace is the boundary and the port is published explicitly — keeps working. Binding loopback is *containment, not admission*: every route is still gated by the setup password or a bearer token, exactly as `SECURITY.md` -> "Loopback @@ -68,23 +67,22 @@ clientData checks, passkey assertion verification, and the Host enrollment policy all use that normalized origin. Source of truth: `server/src/config.ts` (`readConfig`), a pure env→config -mapping unit-tested in `server/test/config.test.mjs`. Only the half that touches -disk stays in `server/src/index.ts`: with no keypair configured it mints one and -persists `vapid.json`, then validates the pair and subject before building the -app. `server/test/runtime-file.test.mjs` and `server/test/bind-host.test.mjs` -each spawn the real entrypoint — the first asserting the runtime file appears -only after a bind, names that process, and is `0600`; the second that the -plaintext port is unreachable off-loopback when `DORMOUSE_BIND_HOST=127.0.0.1`. +mapping unit-tested in `server/test/config.test.mjs`; only the disk half stays +in `server/src/index.ts`, which mints and persists `vapid.json` when no keypair +is configured, then validates the pair and subject **before** building the app. +`server/test/runtime-file.test.mjs` and `server/test/bind-host.test.mjs` spawn +the real entrypoint: the runtime file appears only after a bind, names that +process, and is `0600`; the plaintext port is unreachable off-loopback under +`DORMOUSE_BIND_HOST=127.0.0.1`. ## Where a Host may reach a relay server (self-host builds) -Neither Host renders the relay socket in a webview any more: standalone's runs -in the Node sidecar and VS Code's in the extension host, so no CSP fences either -of them. The same CSP-shaped source list is therefore **baked into the Node -bundle** and enforced there — one syntax, one build-time variable -(`DORMOUSE_REMOTE_CONNECT_SRC`), whichever process ends up holding the socket. -The webview CSPs carry no relay sources at all (`docs/specs/vscode.md` → "CSP -policy"; `standalone/scripts/tauri-conf.test.mjs` asserts the standalone one). +No CSP fences the relay socket — standalone's runs in the Node sidecar, VS +Code's in the extension host — so the same CSP-shaped source list is **baked +into the Node bundle** and enforced there: one syntax, one build-time variable +(`DORMOUSE_REMOTE_CONNECT_SRC`), whichever process holds the socket. The webview +CSPs carry no relay sources at all (`docs/specs/vscode.md` → "CSP policy"; +`standalone/scripts/tauri-conf.test.mjs` asserts the standalone one). The shipped binary is scoped to the SaaS origin only, `https://*.dormouse.sh wss://*.dormouse.sh`. An override **replaces** that @@ -97,10 +95,9 @@ DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscod So a self-host server on any other origin is reachable only from a custom build (same variable on `pnpm --filter dormouse-standalone tauri build`). The default -carries **no localhost entry**, and `http`/`ws` are a different scheme class -from `https`/`wss`, so a default build refuses to enroll against a plaintext -`http://localhost:3000` dev server — see "Running it" for the override a local -loop needs. +carries **no localhost entry and no plaintext scheme**, so a default build +refuses to enroll against an `http://localhost:3000` dev server — see "Running +it" for the override a local loop needs. `scripts/csp-defaults.mjs` holds the one definition of the default and the override rule; `standalone/scripts/build-sidecar-proxy.mjs` and @@ -115,21 +112,25 @@ which cannot import TypeScript; `connect-src.test.ts` pins the two patterns — and the two copies of the default — as identical. **Enforcement is `originAllowedByConnectSrc`, at three points in -`lib/src/host/remote/service.ts`:** `enroll` is refused for an origin outside -the list, before the setup password leaves the machine; `adopt` is refused the -same way, since a webview handing over an older build's enrollment may name a -relay this build may not reach; and `start` refuses a persisted enrollment -naming one, staying idle with a warning rather than connecting (a binary -downgraded from a custom build, or a server that moved). Matching is -deliberately narrower than a browser's: `https`/`wss` are one scheme class and +`lib/src/host/remote/service.ts`:** + +* `enroll` — refused for an origin outside the list, before the setup password + leaves the machine. +* `adopt` — refused the same way, since a webview handing over an older build's + enrollment may name a relay this build may not reach. +* `start` — refuses a persisted enrollment naming one, staying idle with a + warning rather than connecting (a binary downgraded from a custom build, or a + server that moved). + +Matching is narrower than a browser's: `https`/`wss` are one scheme class and `http`/`ws` the other, host matches exactly or by a leading `*.` wildcard covering any depth of sub-domain but never the bare domain, ports must match -unless the source says `*` (and numeric ports are canonicalized as `URL` does, -so a leading zero is not a silent miss), and anything unparseable fails closed. -Enrollment and Host-authenticated push fetches use `redirect: 'error'`: unlike -the former webview CSP, a Node process does not re-check a redirect target, so -following one could carry the setup password, Host bearer token, or notification -metadata outside the baked allowlist. +unless the source says `*` (numeric ports canonicalized as `URL` does, so a +leading zero is not a silent miss), and anything unparseable fails closed. +Enrollment and Host-authenticated push fetches **must** use `redirect: 'error'` +— a Node process does not re-check a redirect target, so following one could +carry the setup password, Host bearer token, or notification metadata outside +the baked allowlist. Reserved: the `https://*.dormouse.sh wss://*.dormouse.sh` entries are *wildcards* on purpose. The BYOT posture (`## Future`, Scope: saas-multitenant) @@ -151,52 +152,55 @@ $DORMOUSE_STATE_DIR/ vapid.json { publicKey, privateKey, createdAt } (only when unset by env) ``` -That is the entire persistent state. The Host's ACL is not here — it lives on -the Host, in the process that owns the PTYs +That is the entire persistent state. **The Host's ACL is never here** — it +lives on the Host, in the process that owns the PTYs (`lib/src/host/remote/host-state-store.ts`), which is the whole point of the security model. -Every write is temp-file-plus-rename, and every mutation is serialized through a +Every write is temp-file-plus-rename and every mutation is serialized through a per-store promise chain, so a crash cannot leave an unparseable file and two -concurrent read-modify-writes cannot lose each other. Source of truth: -`server/src/state.ts`. +concurrent read-modify-writes cannot lose each other. **Rows are validated as they are read** — `hosts.json` and `push-subscriptions.json` both — because hand-editing these files is the *documented* revocation mechanism, so a half-finished edit is an expected state -rather than corruption. A malformed host row is dropped instead of carried, +rather than corruption. A malformed host row is dropped rather than carried, since one bad line would otherwise 500 every `/ws/host` upgrade and every push -route (rationale). A malformed subscription reads as a missing registration, +route (rationale); a malformed subscription reads as a missing registration, which Pocket repairs by re-offering Enable, rather than as a live one nothing can be delivered to. -`push-subscriptions.json` is the one store that deletes rather than appends: a +`push-subscriptions.json` is the one store that deletes rather than appends — a push service reports a dead subscription with 404/410, and a browser that rotates its endpoint must replace the stale row rather than leave one per -rotation. Rows are keyed on the **pair** (`hostId`, `devicePublicKey`), so a -phone paired with two laptops subscribes twice and a Host can only ever read or -reach its own subscribers, and each records the public VAPID key it was -registered under so a rotation reads as stale rather than as still working. The -row holds no label — the Server never learns one. Because one service-worker -scope has only one subscription, an upsert whose endpoint, encryption keys, or -VAPID key differs from an existing row for that device atomically deletes all of -the device's prior Host rows. The response reports the state that mutation left -behind — every Host this device is still registered with — rather than the fact -that a deletion happened, so a committed POST whose response was lost is -repaired by its own idempotent retry. Scoping that answer to the device is safe -where `GET /api/push/subscriptions` must not be: the request carries a device -signature, so the caller has proven it owns the identity reported on. +rotation: + +* **Rows are keyed on the pair (`hostId`, `devicePublicKey`)**, so a phone + paired with two laptops subscribes twice and a Host can only ever read or + reach its own subscribers. Each row records the public VAPID key it was + registered under, so a rotation reads as stale rather than as still working, + and holds no label — the Server never learns one. +* **An upsert that differs deletes the device's other rows atomically.** One + service-worker scope has only one subscription, so an endpoint, encryption + keys, or VAPID key differing from an existing row for that device deletes all + of that device's prior Host rows. +* **The response reports the state that mutation left behind** — every Host + this device is still registered with — rather than the fact that a deletion + happened, so a committed POST whose response was lost is repaired by its own + idempotent retry. Scoping that answer to the device is safe where + `GET /api/push/subscriptions` must not be: the request carries a device + signature, so the caller has proven it owns the identity reported on. `hosts.json` stores `hostToken` — the host↔server relay bearer secret — in plaintext, and `vapid.json` a private key, so both files are written owner-only: the state dir is created `0o700` and every write lands in a `0o600` temp file -before the rename. Any new file under `$DORMOUSE_STATE_DIR` must go through -`writeAtomic` for the same reason. +before the rename. **Any new file under `$DORMOUSE_STATE_DIR` must go through +`writeAtomic`.** **Never build anything on that mode** — it is a cheap default, +not the guarantee the deployment rests on (rationale); what protects the +*installed* server's state is the installer's directory permissions, in +"Installing it" below. -That mode is a cheap default, not the guarantee the deployment rests on, so -nothing may be built on top of it (rationale). What protects the *installed* -server's state is the installer's directory permissions, in "Installing it" -below. +Source of truth: `server/src/state.ts`. ## WebAuthn without a WebAuthn library @@ -228,9 +232,6 @@ browser's `clientDataJSON.challenge` by decoded base64url bytes, so padded browser serializations redeem the issued challenge without weakening single-use replay protection. -Sharing the verifier is also what makes the whole thing testable without a -browser — see Testing below. - ## HTTP API This table is the whole of it. The paths live in `API_ROUTES` / `WS_ROUTES` / @@ -238,7 +239,7 @@ This table is the whole of it. The paths live in `API_ROUTES` / `WS_ROUTES` / | Route | Auth | Does | | -------------------------------- | -------------- | ------------------------------------------------- | -| `GET /api/hello` | — | The shared greeting. Deliberately carries no release identity: it is unauthenticated, CORS-`*` and reachable through `tailscale serve` — see the runtime file under "Installing it" | +| `GET /api/hello` | — | The shared greeting. Carries no release identity: it is unauthenticated, CORS-`*` and reachable through `tailscale serve` — see the runtime file under "Installing it" | | `POST /api/setup/begin` | setup password | `{ challenge }` for registration. Only the password gates it — re-presenting the password adds another passkey to the account | | `POST /api/setup/finish` | setup password | `{ credentialId, publicKey, clientDataJSON }` → creates/updates `account.json` | | `POST /api/signin/begin` | — | `{ challenge }` for sign-in | @@ -308,9 +309,9 @@ dependency. Source of truth: `server/src/push.ts` plus the routes in their identities for diagnosis, while the Host has no deliverable devices. - **The subscription is bound to a Client identity by signature.** The Client signs `(hostId, challenge, devicePublicKey, endpoint)` with its device key - under `PUSH_SUBSCRIBE_DOMAIN` — deliberately *not* `DEVICE_AUTH_DOMAIN`, since - the Server relays Host-issued challenges during `connect` and so sees them in - transit. Binding the endpoint is what stops a captured signature registering a + under `PUSH_SUBSCRIBE_DOMAIN`, never `DEVICE_AUTH_DOMAIN`, since the Server + relays Host-issued challenges during `connect` and so sees them in transit. + Binding the endpoint is what stops a captured signature registering a different endpoint under the same identity. The challenge is single-use and consumed before verification, as at sign-in. - **A subscription authorizes nothing.** It is a delivery address the Host may @@ -351,9 +352,9 @@ dependency. Source of truth: `server/src/push.ts` plus the routes in the race is left to its own inactivity timeout (rationale). Both are separate from the 300-second provider TTL — an alarm that arrives an hour late is noise, not information. -- Push is disabled, not half-working, when no VAPID key **or no VAPID subject** - is configured: the config route reports `null` and challenge/subscribe/send - answer 503. The key and the subject are advertised together or not at all — a +- **Push is disabled, not half-working**, when no VAPID key **or no VAPID + subject** is configured: the config route reports `null` and + challenge/subscribe/send answer 503. Key and subject ship together or not — a phone that registered against a key the Server has no contact to sign with would be subscribed to a push it can never receive. - **A VAPID subject naming a loopback host is a startup error, not a default.** @@ -375,7 +376,7 @@ both directions. `clientId` is a server-assigned secret stamped onto every host-bound frame so the Host can address replies, and is never sent to the Client. -Only one socket may own a `hostId`. Registering a second one for the same +**Only one socket may own a `hostId`.** Registering a second one for the same `hostId` displaces the first: clients bound to it are told `host-gone`, their sessions are cleared, and the old socket is closed with `WS_CLOSE_HOST_REPLACED` (4000) / `WS_CLOSE_HOST_REPLACED_REASON`. Those @@ -385,7 +386,7 @@ contract rather than a log line: the evicted Host keys its stand-down on it *replacement* time and not only on disconnect is load-bearing — the displaced socket's own close event is a no-op here, and the new Host process has a fresh ACL and no memory of those sessions, so their in-flight `msg` frames must never -stay authorized. Source of truth: `server/src/relay.ts` (`registerHost`). +stay authorized. The relay keeps one current Host binding per Client socket. Host-originated handshake replies and `msg` frames are routed only when the frame comes from @@ -405,8 +406,9 @@ Client with a relay-local expiry derived from the server's observation time Client, but the server never compares its own clock to that Host wall-clock timestamp. That memory is consumed **unconditionally** on the next `connect2`, whether or not the rest of the check passes, so a replayed `connect2` is refused -at the relay before the Host's challenge can be burned. Source of truth: -`server/src/handshake.ts`. +at the relay before the Host's challenge can be burned. + +Source of truth: `server/src/relay.ts` (`registerHost`), `server/src/handshake.ts`. ### Pairing (phone ↔ laptop, first time) @@ -435,10 +437,10 @@ UI or burn a ticket. The ceremony beyond this point — `PairingCeremony`, local approval as the only thing that writes the ACL — is [remote-security-model.md](./remote-security-model.md) -> Pairing Ceremony. -**Both sides run the shape guard**, and deliberately so: the server's -`isPairingRequest` is a courtesy that keeps a bad frame off the wire, while the -Host runs the same guard on arrival because the security model does not trust -the relay (rationale). The Host likewise reduces `requestedLabel` with +**Both sides run the shape guard.** The server's `isPairingRequest` is a +courtesy that keeps a bad frame off the wire; the Host runs the same guard on +arrival because the security model does not trust the relay (rationale). The +Host likewise reduces `requestedLabel` with `boundedPairingLabel` before any consumer sees it (same rule as `boundedPushText`): it is attacker-chosen text rendered in a security dialog. Source of truth: `RemoteHost.#onPair` in `lib/src/remote/host/remote-host.ts`. @@ -466,16 +468,22 @@ before sending one `connect2` (`PocketClient.connect` in `lib/src/remote/client/pocket-client.ts`). The server's half of "fresh user presence is validated by the Server and the -Host" is four checks, all of which must pass or the Client gets a `decision` -with the failure list and the Host never sees the request: the challenge is the -exact one this server relayed to *this* client for *this* host and unexpired; -the account is the owner; the asserted credential is a registered passkey whose -stored key equals the one the request carries; and the assertion verifies -**against the stored key** — never against `request.passkey.publicKey`, which is -what makes a substituted public key useless. A pass also refreshes the session's -presence stamp, so "connect to host A, then pair host B moments later" needs no -second prompt. The Host's `authorizeConnection` remains the final authority -regardless of what the server claims to have checked. +Host" is four checks; all must pass or the Client gets a `decision` with the +failure list and the Host never sees the request: + +* the challenge is the exact one this server relayed to *this* client for + *this* host, and unexpired; +* the account is the owner; +* the asserted credential is a registered passkey whose stored key equals the + one the request carries; +* the assertion verifies **against the stored key** — never against + `request.passkey.publicKey`, which is what makes a substituted public key + useless. + +A pass also refreshes the session's presence stamp, so "connect to host A, then +pair host B moments later" needs no second prompt. **The Host's +`authorizeConnection` remains the final authority** regardless of what the +server claims to have checked. ### After authorization @@ -492,9 +500,7 @@ sidecar (`docs/specs/standalone.md` → "Remote Host service") and in the VS Cod extension host (`docs/specs/vscode.md` → "Remote Host: a service in the extension host"). The webview holds only UI — the pairing modal, the `window.dormouseRemoteHost` console hook, and answering what its own panes are -called — and reaches the service over the `remoteHost:*` bridge, so the console -API's shape is unchanged and its calls are now promises one round trip further -away. +called — and reaches the service over the `remoteHost:*` bridge. **One service, two bindings.** The runtime every host shares lives in `lib/src/host/remote/`: the service itself (`service.ts`), the wire contract @@ -513,26 +519,28 @@ the per-host message-name table in `docs/specs/transport.md` → "Message protocol". **The store contract.** Both stores implement `HostStateStore` -(`lib/src/host/remote/host-state-store.ts`) under the same rules: reads fail -closed — an error that says nothing about what the file holds must answer -neither empty nor stale, because an empty ACL silently de-pairs every device; -the in-memory view advances only after the durable write lands, so a failed -save cannot be mistaken for durable state by a later read; every mutation is -serialized in call order through the shared `createSerialQueue` -(`lib/src/host/remote/serial-queue.ts`, also the service's own start/stop -chain) — two rapid pairing approvals write successively larger ACL snapshots, -and the older must not finish last and erase the newer; and a store that -cannot persist still *holds* what it is given in memory and reports -`persistent: false` rather than dropping writes. Each store's mechanics — the -sidecar's single 0600 JSON file, rename semantics, and memory fallback; -VS Code's SecretStorage/globalState split and cross-window memo invalidation — -live in that host's spec. +(`lib/src/host/remote/host-state-store.ts`) under the same rules: + +* **Reads fail closed** — an error that says nothing about what the file holds + must answer neither empty nor stale, because an empty ACL silently de-pairs + every device. +* **The in-memory view advances only after the durable write lands**, so a + failed save cannot be mistaken for durable state by a later read. +* **Every mutation is serialized in call order** through the shared + `createSerialQueue` (`lib/src/host/remote/serial-queue.ts`, also the service's + own start/stop chain): two rapid pairing approvals write successively larger + ACL snapshots, and the older must not finish last and erase the newer. +* **A store that cannot persist still holds what it is given** in memory and + reports `persistent: false` rather than dropping writes. + +Each store's mechanics — the sidecar's single 0600 JSON file, rename semantics, +and memory fallback; VS Code's SecretStorage/globalState split and cross-window +memo invalidation — live in that host's spec. * **Enrollment** (Settings dialog, or the console hook, once): server URL + setup password → `POST /api/host/enroll` → the service persists `{ serverUrl, hostId, hostToken, origin, rpId }` (+ `requireUserVerification` - when the server sent it) through its `HostStateStore` — a 0600 JSON file under - the app-data dir in standalone, `SecretStorage` in VS Code — then opens and + when the server sent it) through its `HostStateStore`, then opens and maintains `GET /ws/host`. `hostToken` is a bearer credential and never enters a webview realm. Refused outright for a server outside this build's allowlist (above), before the password leaves the machine. **A 200 that is not an @@ -541,9 +549,8 @@ live in that host's spec. one mistyped throws naming those fields rather than minting a record with an `undefined` in the `ConnectionPolicy` the Host authenticates passkeys against (rationale). The request carries a 10 s `AbortSignal.timeout`, under the - webview's own 15 s command budget so the console sees the real error, because - it runs on the service's lifecycle chain where every later start/stop command - queues behind it. Source of truth: `lib/src/remote/host/enrollment.ts`. + webview's own 15 s command budget so the console sees the real error + (rationale). Source of truth: `lib/src/remote/host/enrollment.ts`. **Order matters, and the store goes first.** The `hostToken` exists nowhere else and cannot be re-minted from the same password exchange, so the save is @@ -564,8 +571,8 @@ live in that host's spec. on it would evict the newer Host, which would reconnect and evict this one, forever. Coming back is an explicit act — `reconnect()` — which takes the slot back and displaces the other Host in turn. `displaced` is therefore the one - connection state the user has to act on, and the only one the Settings dialog - gives a button. A close event from a socket the controller no longer owns is + connection state the user has to act on. A close event from a socket the + controller no longer owns is ignored, so a dead socket's late eviction cannot stand down the live one, and disposing the service is terminal: an enrollment or ACL read already in flight cannot construct a socket after its sidecar/extension instance tore down. @@ -586,11 +593,11 @@ live in that host's spec. `clientId`.** The service coalesces a re-sent pair under one `clientId` by *replacing* what it holds, but rejects an old modal action whose immutable ticket id no longer matches; the mirror compares on `pairingId` and remounts - keyed by it, while leaving an unchanged item alone — every snapshot arrives as - fresh JSON, so identity comparison would re-render on every event. The modal - shows the requested label + account with Approve / Deny (same pattern as - KillConfirm); approving after the ticket expires sends - `pair-result approved:false` and dismisses, ACL untouched. In VS Code the queue + keyed by it, while leaving an unchanged item alone + (`lib/src/remote/host/activation.ts`). The modal shows the requested label + + account with Approve / Deny (same pattern as KillConfirm); approving after the + ticket expires sends `pair-result approved:false` and dismisses, ACL + untouched. In VS Code the queue is broadcast to every window, since any may be the one in front of the user. * **Terminal bridge**: served through a `HostSurfaceProvider` ([remote-api.md](./remote-api.md)). `directory.watch` snapshots come from the @@ -640,37 +647,33 @@ only on `displaced` — `Reconnect`. Rules the UI exists to honor: gate arms on — so every event triggers a full `status` command, and the dialog re-reads on open since another window may have enrolled meanwhile. The *connection* moves with no event at all, so the store also polls every 2 s - **while something is subscribed** — the seconds the dialog is open, not a - standing timer in every window. The answer is compared field-wise before being - published, since the service returns a fresh object every poll (rationale; - same rule as `setPushDevices` in `lib/src/lib/push-devices.ts`). + **while something is subscribed**, never as a standing timer in every window, + and compares the answer field-wise before publishing (rationale; same rule as + `setPushDevices` in `lib/src/lib/push-devices.ts`). - **Reads are serialized, and coalescing stops at anything that changes the answer.** Ticks arriving during a slow read queue behind it, so a 15-second Host-service timeout becomes the visible error instead of being superseded by - newer polls. But `enroll`, `reconnect` and `clearEnrollment` each *drop* the - read in flight, because a `status` issued beforehand answers the question as it - stood then — joining it would report the old enrollment as though the command - had not run, the inverse of the delete-first ordering the service uses. Losing - the last subscriber drops it for the same reason: a reopened dialog must not be - answered with a status fetched for the closed one (rationale). Source of - truth: `dropInFlightRead` in `lib/src/remote/host/host-status-store.ts`. - -The `window.dormouseRemoteHost` console hook keeps the same four commands and -remains the scripting seam. Pairing approval is deliberately *not* here: it is a -modal, because it must interrupt + newer polls; `enroll`, `reconnect`, `clearEnrollment` and losing the last + subscriber each *drop* the read in flight, since an answer fetched before the + command — or for a dialog now closed — is no longer the question anyone asked + (rationale). Source of truth: `dropInFlightRead` in + `lib/src/remote/host/host-status-store.ts`. + +The `window.dormouseRemoteHost` console hook exposes the same four commands and +remains the scripting seam. **Pairing approval is never here** — it is a modal, +because it must interrupt ([remote-security-model.md](./remote-security-model.md), Pairing Ceremony). `docs/stories/pairing.mdx` walks this section and the pairing modal in sequence with the rest of the setup, rendering the real components; it is a narrative -Storybook page, not a spec, so this section is what it defers to. +Storybook page that defers to this one. ## Pocket side (phone) Pocket is served by this server and built from `lib`; its architecture, theming, and same-origin deployment rule are [pocket-app.md](./pocket-app.md). -What matters here is only the seam: the server ships the static build and -authors no styling of its own — its one self-authored response is the plaintext -missing-build stub at `GET /`. +The seam: the server ships the static build and authors no styling of its own — +its one self-authored response is the plaintext missing-build stub at `GET /`. ## Testing @@ -678,9 +681,9 @@ The security and relay layers are covered without a browser: `pnpm --filter server test` drives setup → pairing → connect end to end through the real server — `app.request()` for HTTP routes, real WebSockets against an ephemeral-port server for the relay — using `SimAuthenticator` (from `server-lib-common`) plus -the `FakeHost` harness in `server/test/harness/fake-host.mjs`. Two suites -instead spawn the real entrypoint, because what they assert is a property of the -process rather than of the app: `bind-host` and `runtime-file`. Revoked-record +the `FakeHost` harness in `server/test/harness/fake-host.mjs`. `bind-host` and +`runtime-file` instead spawn the real entrypoint, since what they assert is a +property of the process rather than of the app. Revoked-record denial is covered at the unit level in `server-lib-common`'s own tests, not through the relay. Browser-dependent layers — the Host module and the Pocket terminal view — are dogfooded rather than automated. @@ -701,10 +704,9 @@ Builds the Pocket app (`lib/dist-pocket`) and the server, then serves both on needs a secure context, and only `localhost` is exempt. On the default localhost origin **push is off** and the server says so at -startup: there is no routable operator contact to sign a VAPID JWT with, and a -phone could not route to localhost anyway. Setting `DORMOUSE_ORIGIN` to an https -origin enables it with no further configuration, since that origin becomes the -subject. To exercise push against a desktop browser on localhost, supply a +startup — no routable VAPID subject (Web Push above). Setting `DORMOUSE_ORIGIN` +to an https origin enables it with no further configuration, since that origin +becomes the subject. To exercise push against a desktop browser on localhost, supply a contact explicitly: ```sh @@ -712,11 +714,11 @@ DORMOUSE_SETUP_PASSWORD=hunter2 DORMOUSE_VAPID_SUBJECT=mailto:you@example.com \ pnpm dev:pocket-server ``` -**2. Host** (the laptop being controlled). The Host runs in the sidecar / the -extension host and refuses any origin outside the allowlist baked into that -bundle — by default the SaaS origin only, with no localhost and no plaintext -scheme. A local server therefore needs the override at build time, which -`dev:standalone` picks up because it re-stages the sidecar bundles on the way: +**2. Host** (the laptop being controlled). The Host refuses any origin outside +the allowlist baked into its bundle (above), which by default admits neither +localhost nor a plaintext scheme, so a local server needs the override at build +time — `dev:standalone` picks it up because it re-stages the sidecar bundles on +the way: ```sh DORMOUSE_REMOTE_CONNECT_SRC='http://localhost:3000 ws://localhost:3000' pnpm dev:standalone @@ -765,10 +767,9 @@ machine, reachable only from their tailnet — a LaunchAgent on macOS, a Scheduled Task on Windows, a systemd *user* service on Linux — with `tailscale serve` terminating HTTPS on the node's MagicDNS name and proxying to the server on loopback. There is no cloud relay: an always-on relay is the same -installer on an always-on tailnet machine. Availability follows from what a -per-login agent is: the relay is down while the machine sleeps, is shut off, or -has no logged-in user — usually fine, since there is then no local Host to -control either. +installer on an always-on tailnet machine. The relay is down while the machine +sleeps, is shut off, or has no logged-in user — usually fine, since there is +then no local Host to control either. **[SELF_HOST.md](../../SELF_HOST.md) is both the operator runbook and the installer spec**: the platform mechanism map, the invariants the three diff --git a/docs/specs/server.rationale.md b/docs/specs/server.rationale.md index 6640c7ea..7c4ef8c3 100644 --- a/docs/specs/server.rationale.md +++ b/docs/specs/server.rationale.md @@ -43,6 +43,8 @@ **Where a bad enrollment record would surface.** A record minted with an `undefined` in its `ConnectionPolicy` would not fail at enrollment time. It would fail at the *next* read — the store rejects it, so the machine silently un-enrolls at the next launch, an app-restart away from the response that caused it. Failing the exchange on the spot names the missing fields instead. +**Why the enrollment request's 10 s timeout has to be the shorter one.** It runs on the service's lifecycle chain, where every later start/stop command queues behind it, so an enrollment left to hang past the webview's own 15 s command budget would replace the real error with a timeout — and stall every command queued after it. + **What losing the `hostToken` costs.** The alternative ordering — stop the running Host, then save — strands the machine with no Host, a status that says otherwise, and a credential that cannot be re-minted from the same password exchange. The user's only recovery is a fresh enrollment against the server. ## Remote control, in the Settings dialog From 2f13239a3f9873cc343220bdfb508522d39ce266 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 30 Aug 2026 15:20:58 -0700 Subject: [PATCH 07/17] docs(specs): house-form compression for tiling-engine.md The five-tax Why becomes a table naming each owning section, the DnD gesture and restore-token degradation paragraphs become invariant bullets, front matter gains Owns/Defers blockquotes, and five mechanism restatements shrink to rule + pointer where the constraining modules (cumulativeRound, setLayoutGeometry, admit, OVERLAY_COMPONENTS, slope) already carry the mechanism in comments. The zero-size parking why moves to the rationale file; the cross-file StrictMode trap stays inline. All headings and the acceptance matrix untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TFmGuuAAEc7UvCc1WhtQeu --- docs/specs/tiling-engine.md | 119 +++++++++++++++----------- docs/specs/tiling-engine.rationale.md | 4 + 2 files changed, 72 insertions(+), 51 deletions(-) diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index ffd3cfec..8b1d5980 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -1,22 +1,31 @@ # Tiling Engine (Lath) > See [glossary.md](glossary.md) for the Surface model, the `Window ⊃ Workspace ⊃ Pane ⊃ Surface` hierarchy, and the Pane / Door / baseboard / passthrough vocabulary used here. - -Lath is Dormouse's in-house headless tiling engine, named for the strips hidden behind a plaster wall: a pure core under `lib/src/lib/lath/` (model, layout, ops, animator, hit-testing) plus a Wall binding with native motion and hierarchical DnD. It replaced dockview-react; that dependency is gone. This spec owns the engine internals — [layout.md](layout.md) owns the interaction model on top and is the source of truth for selection, modes, and session lifecycle. +> **Owns** the engine internals: the pure core under `lib/src/lib/lath/` (model, layout, ops, animator, hit-testing) plus the Wall binding with native motion and hierarchical DnD. Lath — named for the strips hidden behind a plaster wall — replaced dockview-react; that dependency is gone. +> **Defers** the interaction model on top to [layout.md](layout.md): selection, focus, modes, session lifecycle. Not re-disclaimed per section below. +> Evidence behind the rules: [tiling-engine.rationale.md](tiling-engine.rationale.md). ## Why -Dormouse consumed a narrow slice of dockview — binary split tree, sash resize, drag-move, maximize, serialization; no tab stacking, no floating groups, and the mobile compositions never touched it — yet paid a broad tax for the parts of dockview's model that fought the product: activation events that conflated user intent with engine mechanics, a tree rebalance that re-parented DOM, an animation model the kill animation had to fight, single-level DnD racing React's synthetic events, and a shadow model the app re-derived anyway. **Every principle below is the direct answer to one of those, and none of the five may be reintroduced** — each survives as a live rule in its own section (rationale). +Dormouse used a narrow slice of dockview — binary split tree, sash resize, drag-move, maximize, serialization — yet paid a broad tax for the parts of its model that fought the product. **Never reintroduce any of the five**; each survives as a live rule in its own section (rationale). + +| dockview's tax | Lath's answer | +| --- | --- | +| Activation events conflated user intent with engine mechanics | No activation events at all (Principles) | +| Tree rebalance re-parented DOM | The binding never re-parents (The HTML adapter) | +| The kill animation had to fight the animation model | The animator is a pure function of time (Animation) | +| Single-level DnD raced React's synthetic events | Pointer-only hierarchical DnD | +| The app re-derived a shadow model of the tree | Pure `neighbors()` / `layout()` queries (Layout) | ## Principles and non-goals -Lath is a **headless geometry engine**. It owns the split tree, rects, animation targets, and drag hit-testing — nothing else. +Lath is a **headless geometry engine**: it owns the split tree, rects, animation targets, and drag hit-testing — nothing else. -- Pure core: every operation is `(tree, args) → result`. No listeners, no event emitters, no timing assumptions. Invalid operations return the input tree unchanged with `ok: false`. -- Renderer-agnostic core: the core never imports DOM (or React, or Three.js) types — tree, `layout()`, ops, hit-testing, sash geometry, and the animator are all plain-data-in, plain-data-out. The HTML adapter (LathHost, below) is the first consumer; a Three.js adapter (serving the VR Window item in [remote-api.md](remote-api.md)'s staged remainder) is a planned second and must be able to reuse all of it unchanged. -- Lath has **no concept of selection, focus, mode, or activation**. Those stay in the Wall, where the (kind, id) selection pair and its policies already live. -- The DOM binding **never re-parents** a pane's element. Layout is geometric (absolute position + size on stable nodes), not structural. -- Non-goals: tab stacking, floating groups, popout windows (agent-browser pop-out is a separate mechanism), and the mobile compositions (MobileWall does not tile). Building the Three.js adapter itself is also out of scope — the guarantee is only that the core stays consumable by one. +- **Every operation is `(tree, args) → result`.** No listeners, no event emitters, no timing assumptions. +- **The core must never import DOM, React, or Three.js types.** Tree, `layout()`, ops, hit-testing, sash geometry, and the animator are all plain-data-in, plain-data-out. LathHost is the first consumer; a Three.js adapter (the VR Window item in [remote-api.md](remote-api.md)'s staged remainder) is a planned second and must be able to reuse all of it unchanged. +- **Never give Lath a concept of selection, focus, mode, or activation.** Those stay in the Wall, where the (kind, id) selection pair and its policies already live. +- **The DOM binding never re-parents a pane's element.** Layout is geometric (absolute position + size on stable nodes), not structural. +- **Non-goals**: tab stacking, floating groups, popout windows (agent-browser pop-out is a separate mechanism), and the mobile compositions (MobileWall does not tile). Building the Three.js adapter itself is also out of scope — the guarantee is only that the core stays consumable by one. ## Core model @@ -35,17 +44,17 @@ type LathChild = { node: LathNode; weight: number }; type LathTree = { root: LathNode | null }; ``` -A `'row'` split lays children left→right; `'col'` top→bottom. Trees are immutable: ops return fresh nodes along the mutated path and share structure elsewhere. +A `'row'` split lays children left→right; `'col'` top→bottom. **Trees are immutable**: ops return fresh nodes along the mutated path and share structure elsewhere. -Invariants, enforced by every op and checked by the `validate(tree)` helper (returns human-readable violations; used throughout the tests): +Invariants, enforced by every op and checked by `validate(tree)` (human-readable violations; used throughout the tests): -- A split has ≥ 2 children; a split never directly contains a same-direction split (same-direction children are flattened on construction, i3-style, by the shared `normalize` constructor every op builds through). This normalization is what gives DnD its depth semantics: every ancestor boundary is a real, distinct drop level. +- A split has ≥ 2 children and **never directly contains a same-direction split** — same-direction children are flattened on construction, i3-style, by the shared `normalize` constructor every op builds through. This normalization is what gives DnD its depth semantics: every ancestor boundary is a real, distinct drop level. - Weights within a split are > 0 and normalized to sum 1. -- Leaf ids are unique. `root: null` is the empty Wall; the Wall's auto-spawn rule ("always one pane visible") stays app-level: a Wall effect watches the store and spawns into an emptied tree. There is no op for inserting into an empty tree — the Wall seeds one with `leafTree(id)`. +- Leaf ids are unique. `root: null` is the empty Wall; the "always one pane visible" auto-spawn rule stays app-level (a Wall effect watches the store and spawns into an emptied tree). **There is no op for inserting into an empty tree** — the Wall seeds one with `leafTree(id)`. -Nodes are addressed by **path** (`number[]` of child indexes from the root; the root is `[]`). Paths are ephemeral — valid only until the next op — and never persisted. +Nodes are addressed by **path** (`number[]` of child indexes from the root; the root is `[]`). **Paths are ephemeral** — valid only until the next op, never persisted. -Zoom is not in the tree. It is presentation state (`zoomedId` in the wall store): the zoomed leaf animates into an elevated wall-sized rect inset by half the 30px pane-header height (15px), while the tree and all other rects stay unchanged beneath and no leaf DOM moves or reorders. The exposed perimeter and blurred app-background-colored shadow make the stacking relationship visible. +**Zoom is never in the tree.** It is presentation state (`zoomedId` in the wall store): the zoomed leaf animates into an elevated wall-sized rect inset by half the 30px pane-header height (15px), while the tree, every other rect, and all leaf DOM stay unchanged beneath. The exposed perimeter and blurred app-background-colored shadow make the stacking relationship visible. ## Layout @@ -55,9 +64,9 @@ Source of truth: `lib/src/lib/lath/layout.ts`. layout(tree: LathTree, rect: Rect, opts: { gap: number; minLeaf: Size }): Map ``` -Pure. Splits divide their axis by weight; sizes round to integer pixels by cumulative rounding (child *boundaries* round, so drift never accumulates and the remainder lands left-to-right) — adjacent panes never seam or overlap. Weights are clamped at layout time against `minLeaf` via a per-split waterfill (children below their recursive minimum are pinned to it and the rest redistributes by weight); stored weights are never rewritten by layout. A split whose minimums exceed its span degrades to min-proportional allocation — still exact tiling, minimums honored only when feasible. Zero/negative rects yield zero-size rects, never a crash. Property tests assert: rects exactly tile `rect` minus gaps, no overlap, every leaf present. +Pure. Splits divide their axis by weight and round to integer pixels so children plus gaps tile the span exactly — **adjacent panes never seam or overlap**. Weights are clamped at layout time against `minLeaf` via a per-split waterfill (children below their recursive minimum are pinned to it and the rest redistributes by weight); **stored weights are never rewritten by layout**. A split whose minimums exceed its span degrades to min-proportional allocation — still exact tiling, minimums honored only when feasible. Zero/negative rects yield zero-size rects, never a crash. Property tests assert: rects exactly tile `rect` minus gaps, no overlap, every leaf present. -Derived pure queries replace what used to be DOM inspection. Each takes the same `rect` + `opts` the caller renders with — feed them anything else and their geometry diverges from the screen: +Derived pure queries replace DOM inspection. **Each must be called with the same `rect` + `opts` the caller renders with** — feed them anything else and their geometry diverges from the screen: - `neighbors(tree, rect, id, direction, opts) → LeafId | null` — spatial navigation without rect-scanning group elements. Candidates must lie strictly beyond the leaf's edge; secondary-axis overlap is preferred, then nearest edge-to-edge, with deterministic tie-breaks (smaller y, then x, then id). - `autoEdge(tree, rect, id, opts) → Edge` — the aspect-ratio split heuristic: laid-out rect wider than tall → `'right'`, else `'bottom'` (also `'right'` for a missing leaf). @@ -68,7 +77,7 @@ Derived pure queries replace what used to be DOM inspection. Each takes the same Source of truth: `lib/src/lib/lath/ops.ts`. -All ops return `{ tree: LathTree; ok: boolean }` plus op-specific fields. All are pure and synchronous. On `ok: false` the returned `tree` is the **input tree object** unchanged — callers may identity-compare to detect rejected ops; on `ok: true` the tree is always a fresh object, so tree identity never signals "no visual change." +All ops are pure and synchronous, returning `{ tree: LathTree; ok: boolean }` plus op-specific fields. **On `ok: false` the returned `tree` is the input tree object** unchanged — callers may identity-compare to detect rejected ops; on `ok: true` the tree is always a fresh object, so tree identity never signals "no visual change." | Op | Shape | Notes | | --- | --- | --- | @@ -87,34 +96,38 @@ type DropTarget = | { kind: 'swap'; leaf: LeafId }; ``` -`DropTarget` is defined with the ops; its `edge`-at-ancestor-path form is what gives DnD its depth levels (Hierarchical drag and drop, below). +`DropTarget`'s `edge`-at-ancestor-path form is what gives DnD its depth levels (Hierarchical drag and drop, below). -Because ops are cheap pure functions, speculative evaluation is free — sash live-resize and DnD previews run `layout(op(tree, …).tree, …)` per frame without committing. +Ops are cheap pure functions, so speculative evaluation is free — sash live-resize and DnD previews run `layout(op(tree, …).tree, …)` per frame without committing. ## Hierarchical drag and drop -Source of truth: `lib/src/lib/lath/hit-test.ts` (core); the `DragController` in `lib/src/components/wall/lath-drag-controller.ts` (one gesture owner — threshold, hit-test, click-suppression — for both pane and Door drags; LathHost builds one per mount and feeds it header presses / the `externalDrag` mirror); `Door.tsx` / `Baseboard.tsx` (press reporting only); the drag callbacks in `Wall.tsx`. +Source of truth: `lib/src/lib/lath/hit-test.ts` (core); the `DragController` in `lib/src/components/wall/lath-drag-controller.ts` — one gesture owner (threshold, hit-test, click-suppression) for both pane and Door drags, built once per LathHost mount and fed header presses / the `externalDrag` mirror; `Door.tsx` / `Baseboard.tsx` (press reporting only); the drag callbacks in `Wall.tsx`. -Pointer events only (`pointerdown` → 5px threshold → drag; no HTML5 DnD), so drags are testable from CDP and never race React's synthetic events. The controller owns the single `DRAG_THRESHOLD`; a live drag hit-tests the store's tree read fresh each frame, so a background `dor split`/`dor kill` commit mid-drag is reflected in the next preview. +**Pointer events only** (`pointerdown` → 5px `DRAG_THRESHOLD` → drag; no HTML5 DnD), so drags are testable from CDP and never race React's synthetic events. A live drag hit-tests the store's tree read fresh each frame, so a background `dor split` / `dor kill` commit mid-drag is reflected in the next preview. ```ts hitTest(tree, rect, point, dragged: LeafId | null, opts): DropCandidate[] // DropCandidate = { target: DropTarget; previewRect: Rect; depth: number }, ordered innermost → outermost ``` -`hitTest` is core and renderer-agnostic: it consumes a point already in Wall coordinates (`dragged: null` is an external drag — a Door coming in — which yields no `swap` and previews via `insert`). The HTML adapter feeds it pointer positions; a Three.js adapter would feed raycast intersections. Gesture mechanics and the preview overlay are adapter concerns. +`hitTest` is core: it takes a point already in Wall coordinates — LathHost feeds pointer positions, a Three.js adapter would feed raycast intersections. `dragged: null` is an external drag (a Door coming in): no `swap` candidates, previews via `insert`. Gesture mechanics and the preview overlay are adapter concerns. The depth model: - The center region of a leaf yields `swap` (internal drags only, never with yourself). - The inner edge bands of a leaf — `min(0.3 × extent, 96)` px per side; the nearest in-band edge wins a corner — yield `edge` targets **at the leaf's level**. A point in a gap attributes to the nearest leaf, so split boundaries have no dead zones. - When the hovered leaf's edge coincides (≤ 0.5px) with an ancestor boundary, `hitTest` also yields `edge` targets **at each ancestor level** — "beside this entire column," up to the root ("new full-height/width band at the Wall's edge"). -- Every candidate's `previewRect` is the exact rect the drop would commit — computed by speculatively running `move` (or `insert`) + `layout`, never a heuristic hint zone. Rejected ops, beside-itself no-ops (committed layout identical to current), and duplicates (ancestor levels the flatten invariant collapses into their child's result — common when removing the dragged leaf collapses its column) are filtered out, so every surviving depth is a genuinely different drop. +- **Every candidate's `previewRect` is the exact rect the drop would commit** — computed by speculatively running `move` (or `insert`) + `layout`, never a heuristic hint zone. Rejected ops, beside-itself no-ops (committed layout identical to current), and duplicates (ancestor levels the flatten invariant collapses into their child's result — common when removing the dragged leaf collapses its column) are filtered out, so every surviving depth is a genuinely different drop. - Default resolution is the innermost candidate; the **scroll wheel** during a drag cycles outward through `depth` (wrapping; scroll up cycles backward). The candidate list resets to innermost whenever its target set changes. -Adapter gesture (LathHost): drags start on a leaf's header slot (primary button only, bailing on buttons/inputs/contenteditable so header chrome keeps working, and never while zoomed or during a sash drag — the two drags are mutually exclusive); the dragged leaf dims to 0.6; one `data-lath-drop-preview` overlay renders the chosen candidate's rect in the selection color; hit-testing is rAF-coalesced; Escape cancels; the click the browser synthesizes on pointerup is swallowed in the capture phase. Grabbing a header also fires the header's press-time click path first, so a drag begins from passthrough on that pane — selection lands correctly, accepted quirk. Drops surface as proposals the Wall commits: `onDragStart(id)` (Wall moves selection onto the dragged pane — covering the drag-while-door-selected case), `onProposeMove(id, target)` (→ `moveLeaf`, then select), `onProposeMinimize(id)` when released below the container (→ the standard `minimizePane`, token and all; the Wall gates it on `showBaseboard`, so it no-ops when the baseboard is hidden — there is nowhere to minimize into). Committed moves tween via the animator. +Adapter gesture (LathHost): -Door drag-out: a `Door` press reports its start point (`onDoorDragStart(item, press)`), and the Wall puts LathHost into external-drag mode immediately (`externalDrag={ id, startX, startY }`). LathHost applies the same threshold as an internal drag: below it the press is a plain click (reattach); once crossed it runs the same hit-test/preview/wheel machinery with `dragged: null`, the chip staying put in the baseboard. A drop on a candidate removes the Door and `insertLeaf`s the surface at the hit-tested target (the token is not consulted — the user chose the position) with an enter hint from the target edge; a drop on nothing (or Escape, a sub-threshold release, or dropping back onto the baseboard) leaves the Door in place. One gesture system — one threshold, one click-suppressor — spans panes and Doors. +- **Start** on a leaf's header slot, primary button only, bailing on buttons/inputs/contenteditable so header chrome keeps working. **Never while zoomed or during a sash drag** — the two drags are mutually exclusive. Grabbing a header also fires the header's press-time click path first, so a drag begins from passthrough on that pane — selection lands correctly, accepted quirk. +- **During**: the dragged leaf dims to 0.6; one `data-lath-drop-preview` overlay renders the chosen candidate's rect in the selection color; hit-testing is rAF-coalesced; Escape cancels; the click the browser synthesizes on pointerup is swallowed in the capture phase. +- **Drops surface as proposals the Wall commits**: `onDragStart(id)` (Wall moves selection onto the dragged pane — covering the drag-while-door-selected case), `onProposeMove(id, target)` (→ `moveLeaf`, then select), `onProposeMinimize(id)` when released below the container (→ the standard `minimizePane`, token and all; the Wall gates it on `showBaseboard`, so it no-ops when the baseboard is hidden — there is nowhere to minimize into). Committed moves tween via the animator. + +**Door drag-out** runs the same threshold / hit-test / preview / wheel machinery with `dragged: null`. A `Door` press reports its start point (`onDoorDragStart(item, press)`) and the Wall puts LathHost into external-drag mode immediately (`externalDrag={ id, startX, startY }`); below the threshold the press is a plain click (reattach), above it the chip stays put in the baseboard. A drop on a candidate removes the Door and `insertLeaf`s the surface at the hit-tested target — **the token is not consulted, because the user chose the position** — with an enter hint from the target edge. A drop on nothing (or Escape, a sub-threshold release, or dropping back onto the baseboard) leaves the Door in place. ## Restore tokens (Doors) @@ -122,11 +135,13 @@ Source of truth: `RestoreToken` and `restore` in `lib/src/lib/lath/ops.ts`. `remove` returns a JSON-serializable token capturing the leaf's ancestry: the nearest same-parent sibling leaf it sat beside (`siblingId`), the full leaf set and structure fingerprint of that sibling node when the sibling is itself a split subtree (`siblingLeafIds` / `siblingFingerprint`), the edge relationship (`edge`, such that neighbor-tier restore is `split(siblingId, edge, leafId)`), its normalized `weight`, its child `index`, and a structure-only `fingerprint` (kinds, dirs, leaf ids — no weights) of the parent split *post-removal*. `restore` applies a three-tier policy (the Wall drives it from `handleReattach`): -1. exact — the fingerprinted context still exists around `siblingId`: reinsert at the original index with the original weight (existing siblings shrink proportionally); -2. neighbor — the sibling still exists: split beside it on the original edge; -3. fallback — split beside a caller-supplied reference leaf (`opts.fallbackRef`) via `autoEdge` (or `'right'` when no rect is supplied). Restoring into an empty tree makes the leaf the root. +1. **exact** — the fingerprinted context still exists around `siblingId`: reinsert at the original index with the original weight (existing siblings shrink proportionally); +2. **neighbor** — the sibling still exists: split beside it on the original edge; +3. **fallback** — split beside a caller-supplied reference leaf (`opts.fallbackRef`) via `autoEdge` (or `'right'` when no rect is supplied). Restoring into an empty tree makes the leaf the root. -A leaf removed from a two-child split whose survivor is a single leaf always degrades to the neighbor tier: the collapse erases the fingerprinted parent, and the neighbor tier reproduces the same position (at 50/50 rather than the original weights). If the survivor is a split subtree, exact restore targets that unchanged subtree by `siblingLeafIds` / `siblingFingerprint` so `A | (B over C)` restores beside the whole `B/C` column rather than inside it. A token whose sibling is gone and whose caller supplies no `fallbackRef` fails with `ok: false` — callers own picking a live reference. +- A leaf removed from a two-child split whose survivor is a single leaf **always degrades to the neighbor tier**: the collapse erases the fingerprinted parent, and the neighbor tier reproduces the same position (at 50/50 rather than the original weights). +- A survivor that is a split subtree keeps the exact tier, targeted by `siblingLeafIds` / `siblingFingerprint`, so `A | (B over C)` restores beside the whole `B/C` column rather than inside it. +- **A token whose sibling is gone and whose caller supplies no `fallbackRef` fails with `ok: false`** — callers own picking a live reference. Tokens serialize with Doors (`PersistedDoor.token`) as the sole restore payload. A parked leaf (below) still carries one: parking decides whether the DOM survives, the token decides where the leaf lands. @@ -137,9 +152,9 @@ Source of truth: `parked` / `doorLeaf` / `addDoor` / `forgetLeaf` / `parkedIds` A **parked** leaf is mounted by the adapter but absent from the split tree: its DOM survives while it lays out nothing, paints nothing, and takes no input. It exists for Surfaces whose state lives *in the DOM* — an `