From 5a0eaa7dcfaa4cdd8e346daa24c7946abc8f1bcb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:52:59 -0700 Subject: [PATCH 1/2] docs(remote): correct the remote specs against the code and move mechanism to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten inaccuracies the audit found in the remote group, and eight sections whose mechanism belongs at the module that carries it. Corrections: the direct handoff deadline is the setup deadline (15 s), not five seconds; the Relay's challenge issuers are the one unkeyed transient store, so Guardrails states the exception rather than a universal claim; the setup-token mint answers `{ token, expiresAt }`, meaning no origin and no URL rather than "the token alone"; the runtime file's placement outside the state dir is the installers' convention, not a Relay check; push is switched off by a missing VAPID subject alone, since the key is always minted; the install notice deliberately omits push; the scanner has two camera lines, not one; the Burrows-view install notice is gated on push not being disabled; ImageAddon loads only where `inlineImages` is on; the pairing prologue puts `burrowId` ahead of the version the QR leads with; the push upsert response is VAPID-current; a passkey label's control characters become spaces before the bidi set is dropped; `DORMOUSE_ORIGIN` must be http(s); the root-build pin named a test that pins only the assertion's own fixtures. Pocket's phone-side selfhost-onboarding item is done, so it leaves `## Future`, and only `getUserMedia` in a Home Screen web app is still unverified on a device. `paneRef` is marked reserved — it equals `surfaceId` and no Client reads it — alongside `focused` and `exitCode`, which have no Client reader either. The QR panel, the Burrow status store, the connect-src build guards, the Pocket storage probe and the capability harness keep their bolded rules and their `Source of truth:` pointers; their mechanism now lives in comments at the code, and the harness gets its own heading, since it is not serving. Two code comments cited scopes that no longer exist. Co-Authored-By: Claude Fable 5.1 --- docs/specs/pocket-app.md | 119 ++++++++++++------------ docs/specs/pocket-app.rationale.md | 2 - docs/specs/relay.md | 123 +++++++++++++------------ docs/specs/relay.rationale.md | 2 +- docs/specs/remote-api.md | 32 +++---- docs/specs/remote-security-model.md | 26 ++---- lib/pocket/diagnostics/capabilities.js | 7 +- lib/src/remote/client/pocket-db.ts | 19 +++- relay/test/e2e-ceremony.test.mjs | 4 +- scripts/csp-defaults.mjs | 7 +- scripts/spec-word-budgets.json | 4 +- 11 files changed, 181 insertions(+), 164 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 7a9a66732..42474d8c7 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -62,8 +62,9 @@ navigates, and hands the text to `parsePairingInvitationUrl` included — is not a setup code for this Relay. `pairingInvitationExpired` re-runs that same parser at the epoch, so the grammar has no second copy. * **The camera tracks stop on every way out** — accepted, cancelled, errored, - unmounted, and on a start that finished after the screen was gone. Only a - refused permission gets its own line; it leaves paste working. + unmounted, and on a start that finished after the screen was gone. **A refused + permission and an unusable camera each get their own line**, and no other + failure does; both leave paste working. * **The invitation lives in memory-only ceremony state**, cleared on every terminal outcome. @@ -99,7 +100,8 @@ v1 grants no phone-side kill or layout authority (rationale). **An inline image crosses the relay as the ordered `terminal.data` messages its PTY chunks produce**, reassembled by Pocket's own xterm — panes are built through -the same `createXtermHost`, so ImageAddon is loaded. **Nothing coalesces them and +the same `createXtermHost`, so ImageAddon is loaded wherever the terminal's +`inlineImages` setting is on, as it is by default. **Nothing coalesces them and no size gates them** (rationale); each is bounded only by what the Burrow feeds its parser ([remote-api.md](./remote-api.md) → Terminal surfaces). @@ -216,7 +218,7 @@ with **no `type: 'module'`**, pinned by `service-worker.test.ts`. **A worker tha is not one classic self-contained script fails the build**: `lib/scripts/assert-pocket-worker.mjs` runs last in `build:pocket` and owns the exact refusals (rationale). **Root `pnpm build` runs `build:pocket`**, so CI -checks a real bundler output (rationale; `assert-pocket-worker.test.ts`). +checks a real bundler output (rationale). `dev:pocket` bundles the same config in memory per `/sw.js` request (rationale). - **The worker caches nothing and registers no `fetch` handler** (rationale). It @@ -254,9 +256,11 @@ returns to sign-in** (`PasskeyUnavailableError`); the verified response restores it on any profile. **Pocket states the order on the screen that leads with the scan**, above the -action rather than after it: install to the Home Screen **first**, then scan, -approve the pairing on the machine, and enable push from within it — everything -partition-bound is minted from there, the passkey and each per-Burrow static. +action rather than after it: install to the Home Screen **first**, then scan and +approve the pairing on the machine — everything partition-bound is minted from +there, the passkey and each per-Burrow static. **Push is not named there**, +since whether it works at all depends on the Relay's push config; the Burrows +view's notice and push rows gate on that. **One phone can hold two Client identities, so Pocket names the mode in the label it suggests at pairing** — `Dormouse Pocket (Home Screen)` versus @@ -279,7 +283,7 @@ order, and every unavailable result is named in the UI:** | Result | Condition | UI consequence | |---|---|---| -| `needs-install` | `navigator.standalone` exists but the app is not installed; checked before capability probes, since iOS tabs omit those APIs | Explain Home Screen install above the scan action and again on the Burrows view; with no prompt API it stays advice — a tab must still scan | +| `needs-install` | `navigator.standalone` exists but the app is not installed; checked before capability probes, since iOS tabs omit those APIs | Explain Home Screen install above the scan action, and again on the Burrows view unless push is `disabled` there; with no prompt API it stays advice — a tab must still scan | | `unsupported` | Service workers, `Notification`, or `PushManager` unavailable after the install gate | Explain that this browser cannot receive push | | `no-worker` | The tracked registration failed or resolved empty, commonly on an insecure origin | Explain the worker failure | | `denied` | Notification permission is denied | Direct the user to browser settings | @@ -303,9 +307,9 @@ fetch offers Retry, which only caches the key, and the next tap reveals Enable. **Permission is requested on that separate, fresh tap** — a network round trip can consume an iOS gesture's transient activation. (rationale) -Browser availability and Burrow registration are separate states: a -`PushSubscription` belongs to the service-worker scope; the Relay stores one row -per `(burrowId, deliveryId)`. +Browser availability and Burrow registration are separate states: the +`PushSubscription` belongs to the service-worker scope, the Relay's rows to +`(burrowId, deliveryId)` ([relay.md](./relay.md) → State files). **Push is asked for once per device, on one card, on the Burrows view** — the prompt and the subscription it mints are scope-wide; the per-Burrow rows are @@ -337,8 +341,8 @@ settles at empty on its own behalf**: the card re-offers its idempotent Enable. **A Relay row is necessary but not sufficient for Push notifications on.** Pocket also checks that permission is still granted, that the scope holds a `PushSubscription` minted for the Relay's current VAPID key, and that it points -at the registered address; any of the four failing re-exposes Enable. **The -Relay likewise omits rows under an old VAPID key** (rationale). +at the registered address; any of the four failing re-exposes Enable, and the +Relay omits such rows too ([relay.md](./relay.md) → Web Push). **Pocket records a SHA-256 digest of the address each time the Relay accepts a registration** (`dormouse-pocket:push-endpoint`, beside the `:passkey:` cache) @@ -390,29 +394,26 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` / ## What Pocket stores **Must have a successful current-page storage probe before a scan starts -registration, sign-in, token retirement, or pairing.** Share in-flight work and -cache successful selection only in memory for that page; never retain failures. -Invalidate selection after production store or key-generation failure. -Probe native storage first, then encrypted -storage only if native fails, using fresh disposable keys in Pocket's record -shape. Reopen and verify identical key agreement; reject missing or extractable -runtime keys. Both formats failing shows a storage -compatibility error without resetting pairing data. Attempt probe database -deletion on exit. (rationale) +registration, sign-in, token retirement, or pairing** — one probe per page, +shared in flight, cached only on success and only in memory, and invalidated by +any later production store or key-generation failure. **Must probe native +storage first and encrypted storage only if native fails**, on fresh disposable +keys in Pocket's own record shape, selecting a format only once it survives +reopen and identical key agreement. **Both formats failing shows a storage +compatibility error without resetting pairing data**, pointing at +`/diagnostics/index.html`. (rationale) **Must identify the failed probe stage and an allowlisted exception name; never display browser exception messages or key material.** -**Must keep the separate-key experiment out of pairing preflight.** Direct -compatibility failures to `/diagnostics/index.html`. +**Must keep the separate-key experiment out of pairing preflight.** **Must use metadata-only summaries for listing, push registration/queries, removal, and re-pair identity checks.** `getSummary` and `listSummaries` omit key -material without decryption/import. Corrupt keys cannot block these operations; -re-pairing requires fresh approval and preserves the Burrow pin. Connection -and push decryption use full records. +material without decryption or import, so a corrupt key blocks none of them; +connection and push decryption use full records. **Must report connection-record read failures with fixed retry/scan recovery text, never browser exception details or authorization changes.** A fresh scan -retains the pin and requires approval; a read failure grants nothing. +keeps the Burrow pin and requires fresh approval; a read failure grants nothing. **Must use the selected format for new keys and decode both formats in the shared page/worker store.** The encrypted format stores AES-GCM ciphertext, @@ -452,35 +453,6 @@ in `lib/src/remote/client/pocket-client.ts`. ## Serving the built bundle -**Must serve the opt-in capability harness at `/diagnostics/index.html`, built -from `lib/pocket/diagnostics/` as a second Pocket HTML entry.** Test fresh keys and isolated temporary -storage, report stage failures and cleanup failures, and never read pairing -data, request passkeys or media permissions, or upload results. API presence -is observational; crypto storage success requires reopening and using the key. -**Must use the production key codec for encrypted round-trip and restart tests, -including authenticated context.** Diagnostics never open production databases. -Keep primitive checks independently generated and database-isolated. -**Must keep diagnostics platform-neutral and state which browser/app context -was tested.** API presence alone never certifies Android, iOS, or desktop support. - -**Must retain a restart checkpoint only on explicit preparation, in a -diagnostic-only database, until explicit cleanup.** Verification requires a new -page instance and derives the saved expected result using the recovered key; -never claim page reload proves process termination. The diagnostic manifest has -its own identity and start URL. Reports omit key material. Pinned by -`lib/src/remote/client/capability-harness.test.ts`. -**Must identify harness v3 production-format reports and reject legacy restart -checkpoints with explicit cleanup/reprepare instructions**, never silently -reclassify experimental evidence. Preserve the v1 production envelope/context. - -Source of truth: `runCapabilities` in -`lib/pocket/diagnostics/capabilities.js`; UI: -`lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in -`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` / -`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`. -Both built HTML shells are checked by `assertPocketShell` in -`lib/scripts/assert-pocket-worker.mjs`. - Content types need no special-casing: `serveStatic` already answers `application/manifest+json` for `.webmanifest` and `text/javascript` for `sw.js`. @@ -498,7 +470,36 @@ previous build's hashed assets (rationale). Two rules make it hold: response's cache policy describes the response, and the shell is never a useful answer to a subresource miss. (rationale) -Source of truth: `registerPocketServing` in `relay/src/app.ts`. +Source of truth: `registerPocketServing` in `relay/src/app.ts`. Both built HTML +shells are checked by `assertPocketShell` in +`lib/scripts/assert-pocket-worker.mjs`. + +### The capability harness + +**Must serve the opt-in capability harness at `/diagnostics/index.html`**, built +from `lib/pocket/diagnostics/` as a second Pocket HTML entry with its own +manifest identity and start URL. Four rules make its evidence worth anything: + +- **Never read pairing data, open a production database, request passkeys or + media permissions, or upload results**; reports omit key material. +- **Must use the production key codec, authenticated context included**, for the + encrypted round-trip and restart tests. API presence is observational and + certifies no platform, so a report states which browser/app context it + measured; a crypto-storage pass requires reopening and using the key. +- **Must retain a restart checkpoint only on explicit preparation**, in a + diagnostic-only database, until explicit cleanup; verification needs a new page + instance and derives the saved expected result with the recovered key. +- **Never claim a page reload proves process termination.** + +**Must identify harness v3 production-format reports and reject legacy restart +checkpoints with explicit cleanup/reprepare instructions**, never silently +reclassifying experimental evidence; the v1 production envelope and context are +preserved. Pinned by `lib/src/remote/client/capability-harness.test.ts`. + +Source of truth: `runCapabilities` in `lib/pocket/diagnostics/capabilities.js`; +UI: `lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in +`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` / +`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`. ## A backgrounded phone loses its Burrow session @@ -643,5 +644,3 @@ Source of truth: `pocketContentSecurityPolicy` in `relay/src/app.ts`, 3. **Theme picker in Pocket** — the app restores the persisted theme but exposes no picker; add the shared `ThemePicker` (and its theme-debugger entry) once its dropdown is phone-friendly. -4. **Onboarding friction** — Pocket carries the phone-side items of the - **selfhost-onboarding** scope ([relay.md](./relay.md) `## Future`). diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index 994878811..8b7fc9bdf 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -72,8 +72,6 @@ mode. [WebKit's iOS Web Push guidance](https://webkit.org/blog/13878/web-push-fo **Why a failed or in-flight read never settles at empty.** An empty result and "not yet known" would render the same card. Re-offering the idempotent Enable costs a redundant registration at worst, while a stale **Push notifications on.** claim hides the repair entirely. -**Why the Relay omits rows under an old VAPID key.** After a key rotation those rows can never be delivered to. Serving them would let a device that repaired one Burrow see another Burrow's dead endpoint as current, and stop offering the Enable that would fix it. - **Why the push endpoint is fingerprinted.** A push service may rotate an address on its own with the VAPID key unchanged: the subscription stays valid and correctly keyed while every stored Relay row points somewhere unreachable — a state no other check can see. One scope holds one subscription, so a move invalidates every Burrow row for that device at once, and one recorded digest covers them all. **Why a matching subscription is reused rather than replaced.** Calling `subscribe()` again with a matching `applicationServerKey` mints a new endpoint and invalidates the one already stored for every other Burrow — turning a single Burrow's registration into a silent outage for all of them. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 99426b4db..f21613f2e 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -37,10 +37,13 @@ primitive lives in `remote-lib-common`, the terminal UI in `lib`/`standalone`. prune** — `ChallengeIssuer.issue` drops expired entries on every call, as do the presence-nonce and setup-token stores (rationale). * **A cap that one caller can spend on another's behalf is not a cap.** Every - bounded transient store here is keyed by whoever grew it: setup tokens per + *keyed* transient store here is keyed by whoever grew it: setup tokens per minting Burrow (`MAX_TOKENS_PER_BURROW`), presence nonces per session (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`, `MAX_REAUTH_NONCE_SESSIONS` LRU - buckets bounding the total) (rationale). + buckets bounding the total). **The two challenge issuers are the accepted + exception** — one flat `MAX_PENDING_CHALLENGES` map apiece, whose oldest entry + any unauthenticated caller can evict at the cost of one ceremony's retry + (WebAuthn below; rationale). ## Configuration @@ -56,9 +59,16 @@ Production configuration (`pnpm --filter relay start`, containers, and installer | `DORMOUSE_BIND_HOST` | Interface to listen on; unset binds every interface (below). | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair; set both or neither. At startup the Relay decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, it mints a pair on first boot into `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292), defaulted from `DORMOUSE_ORIGIN` and validated at startup — Web Push below. | -| `DORMOUSE_RUNTIME_FILE` | Absolute path the Relay 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`, and the path lives outside `DORMOUSE_STATE_DIR` (rationale). | +| `DORMOUSE_RUNTIME_FILE` | Absolute path the Relay 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 installers keep it in `run/`, outside `DORMOUSE_STATE_DIR` (`SELF_HOST.md`), which nothing in the Relay enforces (rationale). | | `DORMOUSE_RELEASE_ID` | The release directory's name, supplied by the installer's `run-relay` wrapper, recorded in the runtime file. `null` when the Relay was not started by an installer. | -| `DORMOUSE_ENROLL_TOKEN_FILE` | Absolute installer offer path — `{origin, token, mintedAt}`, the token 64 hex characters, shape in `remote-lib-common/src/remote/enroll-offer.ts` — which `POST /api/burrow/enroll` accepts in place of the setup password; unset, one-click enrollment is off. A relative value is a `ConfigError` (rationale). **The offer lasts until the first Burrow enrollment or 24 hours, whichever comes first**, `burrows.json` being the durable marker (rationale); the Burrow-store mutex serializes password/token requests. **Redemption atomically renames the file before minting**, so exactly one concurrent redemption wins, and a mismatched claim is restored by no-clobber hard link so a newer installer generation wins. The installer rotates offers only before `burrows.json` exists. | +| `DORMOUSE_ENROLL_TOKEN_FILE` | Absolute path to the installer's enrollment offer — `{origin, token, mintedAt}`, shape in `remote-lib-common/src/remote/enroll-offer.ts` — which `POST /api/burrow/enroll` accepts in place of the setup password; unset, one-click enrollment is off. A relative value is a `ConfigError` (rationale). | + +**The enrollment offer lasts until the first Burrow enrollment or 24 hours, +whichever comes first**, `burrows.json` being the durable marker (rationale). +**Redemption renames the file before minting**, so exactly one concurrent +redemption wins and a claim holding a newer offer is put back untouched. +Source of truth: `relay/src/enroll-token.ts`; test: +`relay/test/enroll-token.test.mjs`. **Must generate the setup password inside the Relay on first boot, never accept it as configuration, and persist it as `setup-password.json`.** Use 32 @@ -85,11 +95,12 @@ not admission*: the HTTP API table owns the public routes and credential gates. **`DORMOUSE_ORIGIN` is normalized to a bare origin exactly once**, in `readConfig` by the shared `normalizeOrigin` in `remote-lib-common`; a value that -is not a URL with a host is a `ConfigError` naming the variable (rationale). +is not an `http`/`https` URL with a host is a `ConfigError` naming the variable +(rationale). WebAuthn clientData checks, passkey assertion verification, the Burrow enrollment policy and the pairing URL a Burrow composes all compare against that string -rather than re-parsing it; `createApp` parses it only to take `rpId` from the -hostname. +rather than re-parsing it; `createApp` re-checks that same shape and takes +`rpId` from the hostname. **`startRelay` in `relay/src/start.ts` validates the VAPID pair and subject before building the app** — the only disk half of an otherwise pure env→config mapping. @@ -129,13 +140,12 @@ override rule; `standalone/scripts/build-sidecar-proxy.mjs` and `bakedConnectSrc()` in `lib/src/host/remote/connect-src.ts` is the single reader — **reading it as a `declare const`, never an import**, so the value is a literal nothing at runtime can move. -Two build-time guards, both because their failure mode is silent (rationale): -`assertConnectSrcBaked` greps the bundle for the define, and -`resolveRemoteConnectSrc` rejects an override the matcher could never read — a -trailing slash, a path, a bare host, a scheme outside `http`/`https`/`ws`/`wss`, -a port outside 1–65535. The grammar is one regex duplicated into the `.mjs`, -which cannot import TypeScript; `connect-src.test.ts` pins both patterns, and -both copies of the default, as identical. +**Both failure modes are silent, so the bundle is grepped for the define and +the override grammar is validated, at build time** (rationale): +`assertConnectSrcBaked` and `resolveRemoteConnectSrc`. The grammar is one regex +duplicated into the `.mjs`, which cannot import TypeScript; +`lib/src/host/remote/connect-src.test.ts` pins both patterns, and both copies of +the default, as identical. **Enforcement is `originAllowedByConnectSrc` in `lib/src/host/remote/service.ts`:** @@ -149,8 +159,7 @@ both copies of the default, as identical. Matching is narrower than a browser's: `https`/`wss` are one scheme class and `http`/`ws` the other, hostname 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 `*` (numeric ports canonicalized as `URL` does, so a -leading zero is not a silent miss), and anything unparseable fails closed. +unless the source says `*`, and anything unparseable fails closed. **Enrollment and Burrow-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, Burrow bearer token, or notification metadata outside the baked @@ -231,8 +240,8 @@ stale row rather than leave one per rotation: them (rationale). Rows already carrying the *presented* endpoint are the same scope and stay, which is what makes a second Burrow's registration additive. * **The response reports the state that mutation left behind** — every Burrow the - presented endpoint is still registered with — so a committed POST whose - response was lost is repaired by its own idempotent retry. + presented endpoint is still registered with under the current VAPID key — so a + committed POST whose response was lost is repaired by its own idempotent retry. * **Removing a Burrow row is observed lazily**, nothing cascading on write: `listForBurrow` answers nothing for a `burrowId` that is gone, so its rows are unreachable the moment the edit lands and leave disk on the next 404/410 prune @@ -294,7 +303,7 @@ The Relay owns the fixed `/api/hello` health response, pinned by | -------------------------------- | -------------- | ------------------------------------------------- | | `GET /api/hello` | — | Fixed `{ message: "Hello, world!" }` health response. **Carries no release identity** — it is unauthenticated and reachable through the HTTPS proxy; the runtime file carries it ("Installing it") | | `POST /api/setup/begin` | setup token | Issues a registration challenge, gated exactly as `finish` is so neither is softer. Answers the account's credential ids for a retry's `excludeCredentials`, so no passkey that already signs in is duplicated — an orphan the Relay never registered is absent, and is still replaced | -| `POST /api/setup/finish` | setup token | Registers the passkey in `account.json`; the token is spent at the gate and put back if registration then fails. `label` is **reduced, not refused** — the same `boundedPushText` a pairing label goes through, to `MAX_PASSKEY_LABEL_LENGTH` code points with control and bidi characters stripped | +| `POST /api/setup/finish` | setup token | Registers the passkey in `account.json`; the token is spent at the gate and put back if registration then fails. `label` is **reduced, not refused** — the same `boundedPushText` a pairing label goes through, to `MAX_PASSKEY_LABEL_LENGTH` code points, control characters becoming spaces that then collapse and the bidi and zero-width set dropped outright | | `POST /api/setup/retire` | session token | Spends a live setup token without registering anything (rationale). 204, or 401 `SETUP_TOKEN_INVALID_ERROR` | | `POST /api/signin/begin` | — | Issues a sign-in challenge | | `POST /api/signin/finish` | — | Verifies the assertion and issues a 12-hour in-memory session token | @@ -359,8 +368,8 @@ because only a Burrow sends one; **a rejected setup token answers the distinct ### Setup tokens and the pairing QR An enrolled Burrow mints a setup token over its own authenticated channel; the -response carries the token alone — the Burrow knows its enrolled origin and -composes the QR itself. **Scanning is the only way a passkey is registered** — +response carries `{ token, expiresAt }` and no origin or URL — the Burrow knows +its enrolled origin and composes the QR itself. **Scanning is the only way a passkey is registered** — `/api/setup/*` takes no other credential. **The QR grammar is this spec's.** Exactly @@ -484,10 +493,13 @@ Relay's Web Push dependency. Burrow and webview halves: **Must count sender throws and rejections as `failed`, preserving sibling deliveries and subscriptions**, pinned by `relay/test/push.test.mjs`. Both are separate from the 300-second provider TTL — an alarm an hour late is noise. -- **Push is disabled, not half-working**, when no VAPID key **or no VAPID - subject** is configured: the config route reports `null` and subscribe/send - answer 503; a phone registered against a key the Relay has no contact to sign - with would be subscribed to a push it can never receive. +- **Push is disabled, not half-working**, and **only a missing VAPID subject + turns it off**: `startRelay` always resolves a keypair, minting one into + `vapid.json` when env supplies none, so the contact is the whole switch. With + none the config route reports `null` and subscribe/send answer 503 — a phone + registered against a key the Relay has no contact to sign with would be + subscribed to a push it can never receive. Only an injected `createApp` config + reaches the key-less path. - **A VAPID subject naming a loopback host is a startup error, not a default** (rationale). The default is `DORMOUSE_ORIGIN` when that origin is https and not loopback; otherwise there is none, and a loopback dev server turns push off @@ -647,9 +659,11 @@ framed as application messages on the Noise session (below). four signals** ([remote-api.md](./remote-api.md) → Direct path), which the Relay routes without reading, like every other ciphertext. - **Prologues are `lengthPrefixedConcat`** of `dormouse/e2e/v1`, the ceremony - kind, the `burrowId`, and — for a connection — the connection id; for a pairing, - every field of its invitation in QR order ("Setup tokens and the pairing QR" - above), so a transcript is useless against another Burrow, id, or ceremony. + kind, the `burrowId`, and — for a connection — the connection id; for a + pairing, the invitation's own fields in QR order after that — `v`, `inviteId`, + `expiry`, `setupToken`, `ephPub` ("Setup tokens and the pairing QR" above) — + the `burrowId` ahead of `v` here, behind it in the QR. A transcript is + therefore useless against another Burrow, id, or ceremony. Source of truth: `remote-lib-common/src/security/noise-transport.ts`, pinned by `remote-lib-common/test/noise-transport.test.mjs` and driven through the real @@ -804,8 +818,7 @@ the build cannot do. `no-burrow`**, a superset covering both a Burrow service that has not enrolled *and* a build with no Burrow service at all ([alert.md](./alert.md) -> Push notifications). Only the first has a section beneath it, so only the first says -"below". `describePushTargets` takes the seam as an argument; the `PushNoBurrow` / -`PushNotEnrolled` story pair holds the two apart. +"below"; the `PushNoBurrow` / `PushNotEnrolled` story pair holds the two apart. Un-enrolled it is a three-field form (Relay, setup password, Burrow name — prefilled with the `suggestedLabel` `status` carries) calling the service's @@ -820,7 +833,7 @@ exists to honor: three-field form folded behind "Enroll with a different Relay…" — **folded with `hidden`, never unmounted**, so typed input survives the disclosure and an offer appearing underneath it. **Reading the file is bounded to the un-enrolled - state**; enrolled answers `offer: null` without touching disk (rationale). + state** (rationale). - **The offer's token never enters a webview.** `status` carries only the origin; `enrollOffer` re-reads the file in the Burrow service, so an old card cannot reuse a spent offer (`docs/specs/security-remote.md` -> "Credentials at rest"). @@ -828,54 +841,46 @@ exists to honor: file that no longer names it — an installer rerun rewrites the offer with an origin nobody reviewed. `enrollOffer` takes `{ origin, label }`: the origin reviewed, never the one enrolled against, which stays the file's. -- **The card outlives its offer**, so a refusal landing after the enroll unlinked - the file is not silence over a spent token. +- **The card outlives its offer**, so a late refusal is not silence over a spent + token. - **Only one enrollment may run.** One synchronous gate covers both forms and pre-render double clicks. - **The password is passed through, never held**, cleared on success; `enroll` answers `{ burrowId, relayUrl }`, so `burrowToken` never re-enters the webview. - **Refusals are shown, not swallowed**, the offer card included: the allowlist - error (above) is what the form renders, so the failure reads as "this build - will not talk to that Relay" rather than as a wrong password. + error (above) is what the form renders, never a wrong-password message. - **Enrolled, "Set up a phone" opens an inline QR panel**, so a phone is set up by pointing a camera at the laptop rather than typing an origin and a 64-hex password. It mints on open and never before, re-mints shortly before `expiresAt` while the panel stays open, and always offers New code and Done, the only exit from a dead code (`RemoteControlSection.test.tsx`). Rules it exists to honor: - - **The panel owns its busy and error**, not the section's shared pair, since - its mint also fires on a timer. - - **Must clamp refresh delay to `[30 s, DEFAULT_PAIRING_TTL_MS - 20 s]`**: the - floor stops a fast-clock mint loop, the ceiling replaces a slow-clock code - before its real Relay expiry. + - **The panel owns its busy and error**, not the section's shared pair. + - **Must clamp refresh delay to `[30 s, DEFAULT_PAIRING_TTL_MS - 20 s]`.** - **The code being replaced stays on screen** until its replacement lands; only a first mint blanks. - **An invitation state change flips only the panel showing that `inviteId`**, - so a second window offering a different code stays live, and **the panel - stays subscribed past the QR**: `reserved` spends the code, `consumed` says - the request it produced has been answered. + and **the panel stays subscribed past the QR**: `reserved` spends the code, + `consumed` says the request it produced has been answered. - **The panel reports which decision ended the code**, in fixed copy per outcome (rationale), riding that `consumed` event; a retirement nobody - decided carries none. **One region reports it**: the panel where it - supersedes that sentence, the section otherwise. **Only a user action clears - it**, never the timed re-mint. + decided carries none. **One region reports it** — the panel where it + supersedes that sentence, the section otherwise — and **only a user action + clears it**. - **The view is keyed by enrollment identity and the QR sits behind its own - error boundary**: a Relay swap drops the stale code, and a failed chunk - fetch or refused encode costs a retry button, not the app-wide ErrorBoundary. + error boundary**, so neither a Relay swap nor a refused encode reaches the + app-wide ErrorBoundary. - **Disconnect asks first**: clearing the enrollment drops every paired phone until each pairs again. -- **Status is re-read, not patched, and the connection is polled.** The - service's `status` event carries only `{ enrolled }`, 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 polls every 2 s **while something is subscribed**, never as a standing - timer in every window, comparing field-wise before publishing (rationale; same - rule as `setPushDevices` in `lib/src/lib/push-devices.ts`). +- **Status is re-read, not patched**: the service's `status` event carries only + `{ enrolled }`, so every event triggers a full `status` command, and the dialog + re-reads on open since another window may have enrolled meanwhile. **The + connection is polled every 2 s while something is subscribed**, never as a + standing timer in every window, comparing 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 - Burrow-service timeout becomes the visible error rather than being superseded by - newer polls; `enroll`, `reconnect`, `clearEnrollment` and losing the last - subscriber each *drop* the read in flight (rationale). + answer** — `enroll`, `reconnect`, `clearEnrollment` and losing the last + subscriber each drop the read in flight (rationale). Source of truth: `useSetupQr` and `ScannableCode` in `lib/src/components/RemoteControlSection.tsx` over `lib/src/components/QrCode.tsx` diff --git a/docs/specs/relay.rationale.md b/docs/specs/relay.rationale.md index f6cb65527..354434889 100644 --- a/docs/specs/relay.rationale.md +++ b/docs/specs/relay.rationale.md @@ -16,7 +16,7 @@ **Why a relative path is a `ConfigError`.** `DORMOUSE_RUNTIME_FILE` and `DORMOUSE_ENROLL_TOKEN_FILE` come from the installer's `run-relay` wrapper, which a service manager launches with a working directory that is not the installer's — a relative value lands where neither side can predict. The same drift is why `DORMOUSE_POCKET_DIR` resolves from the compiled Relay's own location: a service manager could otherwise change what is served. -**Why the runtime file sits outside the state dir.** It is runtime truth about one process — pid, port, release — not durable state a backup should capture and a restore replay. +**Why the installers keep the runtime file outside the state dir.** It is runtime truth about one process — pid, port, release — not durable state a backup should capture and a restore replay. **Why a blank `PORT` is not zero.** `Number('')` is 0, which asks the OS for an ephemeral port and moves the Relay out from under whatever proxy is pointed at it — the same reason an explicit `PORT=0` is refused. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 57ca3f7ba..1a16d016b 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -94,11 +94,9 @@ signal always fits one control body. **No ICE servers**, and **never a public STUN or TURN default**: `iceServers: []` at both ends, host candidates only. (rationale) -**The two shipped stacks are proven against each other by hand.** No CI job has -a browser, so `scripts/direct-interop/run.mjs` negotiates a real browser against -the real addon over the shipped `DirectPeer` — measuring the browser's offer -against `MAX_DIRECT_SDP_LENGTH`, which is a property of the host's interfaces -rather than of the code (rationale). +**The two shipped stacks are proven against each other by hand**, by +`scripts/direct-interop/run.mjs` over the shipped `DirectPeer`, which also +measures a real browser's offer against `MAX_DIRECT_SDP_LENGTH` (rationale). **Every byte on the channel is a Noise transport message of the promoted session**: one message per channel frame, raw bytes, the same two `CipherState`s @@ -114,13 +112,11 @@ session, and an answerer that refuses before it has answered declines rather than leaving the offerer to wait out its setup budget. A limit the implementation does not report is not treated as small. -**Two limits of those checks are known and accepted.** The reliability flags -reach only as far as the implementation reports them, and `node-datachannel`'s -polyfill rebuilds an incoming channel with its own defaults — so on the -standalone Burrow only the label comparison is load-bearing (rationale). And the -message limit is the *remote's* advertised one, so it is per direction: where -the two ends disagree, a peer that has already switched loses the session rather -than staying relayed. +**Two limits of those checks are known and accepted**: on the standalone Burrow +the reliability flags reach nothing, so only the label comparison is +load-bearing; and the message limit is the *remote's* advertised one, so where +the two ends disagree a peer that has already switched loses the session rather +than staying relayed. (rationale) **A sender bounds its own queue rather than the implementation's.** Past `DIRECT_BUFFER_HIGH` of buffered channel data the ciphertext queues, draining at @@ -129,10 +125,8 @@ overtakes a frame encrypted before it. **A frame is written once or not at all** the implementation's send either consumes a message or throws, and a retry would put counted ciphertext on the wire twice. Overflowing `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` disposes the session, -as the receiver's hold does. **Each failure is reported in its own words**: this -end's queue overrunning and the channel refusing a write are opposite diagnoses, -and the reason is all an operator reading a burrow-loss log has to tell them -apart. +as the receiver's hold does. **Each failure is reported in its own words** — a +queue overrun and a refused write are opposite diagnoses in a burrow-loss log. **The switch preserves order per direction:** @@ -211,6 +205,12 @@ Reserved: a `capabilities` field on the client hello (what the client can render `directory.watch` subscribes to a live, lightweight listing of every pane — enough to render the picker and know which pane wants attention, without attaching. `DirectoryEntry` / `DirectorySnapshot` carry the terminal-only payload: identity, derived title, focus, semantic state, PTY liveness, and the `ringing` / `hasTODO` badges. Nothing else — thumbnails are staged. +Reserved: **`paneRef` is set to the same value as `surfaceId`** and no Client +reads it — it becomes the Pane handle when `window.watch` lands ([Future](#future), +The Window), so a Burrow keeps setting it. **`focused` and `exitCode` likewise +have no Client reader today**, produced for picker affordances the phone does +not render yet. + **Snapshot-only, never deltas**: on any change the Burrow coalesces (150ms window, `DIRECTORY_DEBOUNCE_MS`) and resends the whole listing. (rationale) **One snapshot per collect** — the provider answers for every surface the Burrow can reach, so no subset is known sooner. **A collect is dropped unless it is still the newest and its subscription neither replaced nor torn down**, a per-collect generation of the same shape as the per-attach one keeping a stale answer — an empty timed-out one included — from blanking the picker (rationale). **A collection that rejects emits nothing** and leaves the last good snapshot standing, contained inside the session; the next invalidation or `directory.watch` retries it. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 2c7f0d15a..98a7dc573 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -349,7 +349,7 @@ omits `client-gone`, invents client IDs, or reorders frames. | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | -| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | 5 000 / 5 000 | same | +| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | `= DIRECT_SETUP_TIMEOUT_MS` (15 000) / 5 000 | same | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | | `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | | `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` | the same pair, for what a sender holds | same | @@ -422,22 +422,15 @@ admits Burrow enrollment with ([relay.md](./relay.md#http-api)). Pinned by **The direct path adds no layer to this model.** A WebRTC data channel replaces the Relay as the carrier of an already-authorized session; every rule above holds unchanged, because nothing about *what* is carried changes. -[remote-api.md](./remote-api.md) -> "Direct path" owns the design and is not -restated here: that the channel carries transport messages of the session -promoted at [Connection](#connection) on that `Split`'s own two `CipherState`s, -that every signal rides inside the ciphertext, that nothing is offered before -promotion, and that one peer connection per session is closed by every path -that ends one, are its rules. What this model adds is what is *underneath* them. +[remote-api.md](./remote-api.md) -> "Direct path" owns the design — the session +promoted at [Connection](#connection) and its `CipherState`s, the signals that +ride inside it, the empty ICE-server list, and the bound on either end's queue. +What this model adds is what is *underneath* them. - **DTLS beneath is transport hygiene this model does not rely on.** It protects nothing the Noise session does not already protect, and **the fingerprints in an SDP are authentic for exactly one reason — that SDP arrived inside the session**. A DTLS peer is never an authenticated one. -- **Never an ICE server.** Both ends pass an empty list. (rationale) -- **What the channel may buffer is this side's bound, not the - implementation's**, in both directions ([remote-api.md](./remote-api.md) -> - "Direct path"): the same pair of numbers holds a sender's queue and a - receiver's, and overrunning either disposes the session. **The listener is UDP on every interface a candidate names, for the life of an attempt.** The standalone Burrow's addon binds one socket on the unspecified @@ -609,10 +602,11 @@ Onboarding changes with security surface are staged in the ### Device verification -Two properties of the shipped Pocket client are observable only on a real iOS -device, and both are load-bearing: the selected Client-static storage format -surviving an app and phone restart, and `getUserMedia` working inside a Home Screen web app (without -it the install has only the paste field). +`getUserMedia` inside a Home Screen web app is observable only on a real iOS +device, and without it the install has only the paste field. The Client-static +storage format surviving an app and phone restart is no longer open: the v3 +Home Screen report is in [Client statics](#client-statics)'s rationale and in +[pocket-app.rationale.md](./pocket-app.rationale.md). ### Revocation propagation diff --git a/lib/pocket/diagnostics/capabilities.js b/lib/pocket/diagnostics/capabilities.js index 903671242..ffb2e7e9d 100644 --- a/lib/pocket/diagnostics/capabilities.js +++ b/lib/pocket/diagnostics/capabilities.js @@ -1,4 +1,9 @@ -// Diagnostic databases are independent of Pocket's authorization database. +// Diagnostic databases are independent of Pocket's authorization database: each +// run opens its own temporary database under PREFIX and deletes it afterwards, +// reporting both stage failures and cleanup failures, and every primitive check +// generates its own key material rather than reusing another check's. Nothing +// here reads pairing data, asks for a passkey or a media permission, or uploads +// anything (`docs/specs/pocket-app.md` -> "The capability harness"). import { toBase64Url } from 'remote-lib-common'; import { generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index 60a3a5f5c..aaaedd7af 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -136,6 +136,12 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native } } +/** + * The one probe this page runs, shared by every caller that arrives while it is + * in flight. Memory only and per page: a success is evidence about this runtime + * now, not something to carry across an app restart, and a failure is never + * retained at all — the next attempt re-probes. + */ let storageProbe: Promise | undefined; /** A storage/key failure invalidates compatibility evidence for this page. */ @@ -161,6 +167,14 @@ export async function requirePocketKeyStorage(): Promise { if (storageProbe !== pending) throw new Error(POCKET_KEY_STORAGE_ERROR); } +/** + * Native first, encrypted only where native fails: the encrypted format exists + * for WebKit's broken X25519 key storage, not as a preference. Each attempt + * probes on fresh disposable keys in Pocket's own record shape, so a format is + * selected only once it has survived a reopen and identical key agreement; both + * failing leaves the caller with a compatibility error and the pairing records + * untouched. + */ async function selectKeyStorage(): Promise { try { await probePocketKeyStorage('native'); @@ -211,8 +225,9 @@ export interface KnownBurrowV1 { readonly accountId: string; /** * What to call this machine. The Burrow's own label, as it arrived inside the - * encrypted pairing outcome — never the Relay's copy, which a Client is not - * told and which stops existing in stage 4c. + * encrypted pairing outcome. The Relay keeps no label for a Burrow at all — + * `burrows.json` rows are `{burrowId, burrowToken, enrolledAt}` + * (`docs/specs/relay.md` -> "State files"). */ readonly label: string; /** The pinned Burrow Noise static, base64url. A change is a terminal error. */ diff --git a/relay/test/e2e-ceremony.test.mjs b/relay/test/e2e-ceremony.test.mjs index aa76549fe..70b6f5975 100644 --- a/relay/test/e2e-ceremony.test.mjs +++ b/relay/test/e2e-ceremony.test.mjs @@ -1,7 +1,7 @@ /** * Both end-to-end ceremonies driven through the real Relay - * (`docs/specs/remote-security-model.md` -> `## Future` -> **Scope: - * e2e-client-burrow**, stage 4): a phone scans a Burrow's QR, registers a passkey + * (`docs/specs/remote-security-model.md` -> Pairing and Connection): a phone + * scans a Burrow's QR, registers a passkey * off the setup token that QR carries, signs in, pairs over IK against the * invitation key, and then connects against the Burrow static it pinned. * diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index a6063a7f2..1d567571a 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -41,9 +41,10 @@ function isSupportedSource(source) { * * An override the runtime matcher cannot parse fails the build. Silently it * matches nothing — `originAllowedByConnectSrc` fails closed on a source it - * cannot read — so a trailing slash or a missing scheme produces a binary that - * builds green and then refuses to enroll against the very server it was built - * for, with an error naming the list it was already given. + * cannot read — so a trailing slash, a path, a bare host, a scheme outside + * http/https/ws/wss, or a port outside 1–65535 produces a binary that builds + * green and then refuses to enroll against the very server it was built for, + * with an error naming the list it was already given. */ export function resolveRemoteConnectSrc(env = process.env, label = 'build') { const override = env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5708c5379..9edf32f22 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,9 +15,9 @@ "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000, "docs/specs/pocket-app.md": 4900, - "docs/specs/relay.md": 10200, + "docs/specs/relay.md": 10100, "docs/specs/remote-api.md": 4700, - "docs/specs/remote-security-model.md": 4800, + "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1900, "docs/specs/security-ci.md": 2700, "docs/specs/security-hosted.md": 450, From c9181afb31c76ebe013bde4d793a2a56b26243a7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 01:01:20 -0700 Subject: [PATCH 2/2] refactor(mobile,direct): one home each for the pane mouse override and the direct-path queue bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three duplications the audit found, each of one fact expressed twice. `MobileTerminalUi` carried `activeSection` / `defaultSection` / `onSectionChange` beside `activeKeyboardMode` / `defaultKeyboardMode` / `onKeyboardModeChange` for one piece of state, resolved through `??` ladders. The `*Section` trio had no production consumer, only Storybook; it is gone, and the stories drive the keyboard-mode props directly. The per-pane mouse override — Select mode plus a pane that reports mouse events means `permanent`, everything else `off` — was written out in Pocket's wall and again in the website playground. `lib` now exports `paneMouseOverride`, both compositions loop over their panes calling it, and `mobile-terminal-ui.md` gains a real `Source of truth:` for a rule it previously stated only in prose. `MAX_DIRECT_OUTBOUND_BYTES` / `_FRAMES` were literal aliases of `MAX_DIRECT_PENDING_BYTES` / `_FRAMES`, feeding the same `DirectFrameQueue` class, and the Burrow-bounds table spent two rows saying so. One pair of constants now bounds both directions, documented where they are declared. Co-Authored-By: Claude Fable 5.1 --- docs/specs/mobile-terminal-ui.md | 9 +++-- docs/specs/remote-api.md | 4 +- docs/specs/remote-security-model.md | 3 +- docs/specs/security-remote.md | 2 +- lib/src/components/MobileTerminalUi.test.tsx | 21 +++++++++- lib/src/components/MobileTerminalUi.tsx | 39 ++++++++++++------- lib/src/remote/direct/direct-peer.test.ts | 10 ++--- lib/src/remote/direct/direct-peer.ts | 6 +-- lib/src/remote/pocket-app/PocketWall.tsx | 9 +++-- lib/src/stories/MobileTerminalUi.stories.tsx | 15 ++++--- remote-lib-common/src/security/direct-path.ts | 33 +++++++--------- remote-lib-common/test/direct-path.test.mjs | 14 +------ .../components/PocketTerminalExperience.tsx | 13 +++---- 13 files changed, 92 insertions(+), 86 deletions(-) diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index f1dcb7811..55e6cfc2f 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -71,7 +71,7 @@ Touch mode is global, so **each mounted pane's mouse override is a pure function of that mode and the pane's *own* mouse-reporting state** (`selection` + reporting ≠ `none` → `permanent`, else `off`), recomputed for **every** pane, not just the active one — a pane switched away from must not keep a stale -override. The consumer owns this wiring. +override. `lib` exports the function; the consumer owns the loop. Select mode **must route touch and pen drags through the shared terminal mouse-selection router**, never a mobile-only one, so every selection and copy @@ -99,8 +99,9 @@ the pointer, and **must never reach xterm or the pane** for focus, selection, or pane interaction. **Non-primary mouse buttons are ignored**, so their browser or host behavior continues. -Source of truth: `TOUCH_MODES` in `lib/src/components/MobileTerminalUi.tsx`; -per-pane override wiring in `lib/src/remote/pocket-app/PocketWall.tsx` and +Source of truth: `TOUCH_MODES` and `paneMouseOverride` in +`lib/src/components/MobileTerminalUi.tsx`; per-pane wiring in +`lib/src/remote/pocket-app/PocketWall.tsx` and `website/src/components/PocketTerminalExperience.tsx`. ## Gesture mode @@ -234,7 +235,7 @@ same rule as the touch selector. | Draft | Draft reserve copy, filling the reserve. | Default input mode is **Type**. Recent and Draft are placeholder-only today and -say so in the reserve — the real features are staged (see [Future](#future)). +say so in the reserve ([Future](#future)). **Must focus the hidden input synchronously inside the Type selector's tap/click handler** (rationale). A follow-up effect retries via rAF and staggered timers diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 1a16d016b..a8e0e95bc 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -124,8 +124,8 @@ than staying relayed. (rationale) overtakes a frame encrypted before it. **A frame is written once or not at all** — the implementation's send either consumes a message or throws, and a retry would put counted ciphertext on the wire twice. Overflowing -`MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` disposes the session, -as the receiver's hold does. **Each failure is reported in its own words** — a +`MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` — the one pair both +directions use — disposes the session, as the receiver's hold does. **Each failure is reported in its own words** — a queue overrun and a refused write are opposite diagnoses in a burrow-loss log. **The switch preserves order per direction:** diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 98a7dc573..ce816ad66 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -351,8 +351,7 @@ omits `client-gone`, invents client IDs, or reorders frames. | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | | `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | `= DIRECT_SETUP_TIMEOUT_MS` (15 000) / 5 000 | same | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | -| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | -| `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` | the same pair, for what a sender holds | same | +| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first; one pair for a receiver's hold and a sender's queue alike (rationale) | same | | `DIRECT_BUFFER_HIGH` / `DIRECT_BUFFER_LOW` | 256 KiB / 64 KiB | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index fbdf98de3..b0544331f 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -236,7 +236,7 @@ layer; neither is restated below. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by that same pair — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. - **FAIL IF** a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is not `DIRECT_CHANNEL_LABEL`, one reported unordered or partially reliable, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH` — all before it reports the open, so each abandons the attempt while the relay still carries the session. **The reliability half is defence in depth against a paired Client, not a boundary control**, and reaches only as far as the implementation reports those flags: on the standalone Burrow it does not, which `lib/src/host/remote/native-direct-peer.test.ts` pins so a version that changes it is noticed (`docs/specs/remote-api.md` -> Transport -> "Direct path"). `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). diff --git a/lib/src/components/MobileTerminalUi.test.tsx b/lib/src/components/MobileTerminalUi.test.tsx index 1307623e3..19ff6e6ef 100644 --- a/lib/src/components/MobileTerminalUi.test.tsx +++ b/lib/src/components/MobileTerminalUi.test.tsx @@ -4,7 +4,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MobileTerminalUi, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; +import { MobileTerminalUi, paneMouseOverride, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; import { setNativeFieldValue } from '../lib/dom'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -357,3 +357,22 @@ describe('MobileTerminalUi touch modes', () => { expect(received).toEqual([]); }); }); + +describe('paneMouseOverride', () => { + it('overrides a reporting pane only in Select mode', () => { + expect(paneMouseOverride('selection', 'vt200')).toBe('permanent'); + expect(paneMouseOverride('selection', 'any')).toBe('permanent'); + }); + + it('leaves a pane that reports nothing alone, Select mode included', () => { + expect(paneMouseOverride('selection', 'none')).toBe('off'); + }); + + it('never overrides outside Select mode', () => { + for (const mode of ['gestures', 'cursor'] as const) { + for (const reporting of ['none', 'x10', 'vt200', 'drag', 'any'] as const) { + expect(paneMouseOverride(mode, reporting)).toBe('off'); + } + } + }); +}); diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index d64ba59af..9764a6fe0 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -41,6 +41,7 @@ import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; import { isEditableTarget } from '../lib/dom'; import { TouchUiContext } from './touch-ui-context'; import type { SessionStatus } from '../lib/terminal-registry'; +import type { MouseTrackingMode, OverrideState } from '../lib/mouse-selection'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; @@ -84,6 +85,24 @@ const KEYBOARD_MODES: Array<{ id: MobileTerminalKeyboardMode; label: string; Ico { id: 'draft', label: 'Draft', Icon: ArticleNyTimesIcon }, ]; +/** + * The mouse override one pane gets, from the global touch mode and that pane's + * *own* mouse reporting. + * + * A pure function rather than each consumer's own conditional: touch mode is + * one UI state for the whole wall, so every mounted pane has to be reconfigured + * on a change — a pane switched away from that kept `permanent` would swallow + * the inner program's mouse input for as long as it stayed mounted. Both + * compositions call it in a loop over their panes + * (`docs/specs/mobile-terminal-ui.md` -> "Touch mode selector"). + */ +export function paneMouseOverride( + touchMode: MobileTerminalTouchMode, + reporting: MouseTrackingMode, +): OverrideState { + return touchMode === 'selection' && reporting !== 'none' ? 'permanent' : 'off'; +} + const TOUCH_MODES: Array<{ id: MobileTerminalTouchMode; label: string; @@ -98,9 +117,6 @@ const TOUCH_MODES: Array<{ export interface MobileTerminalUiProps { terminal: ReactNode; - activeSection?: MobileTerminalKeyboardMode; - defaultSection?: MobileTerminalKeyboardMode; - onSectionChange?: (section: MobileTerminalKeyboardMode) => void; activeKeyboardMode?: MobileTerminalKeyboardMode; defaultKeyboardMode?: MobileTerminalKeyboardMode; onKeyboardModeChange?: (mode: MobileTerminalKeyboardMode) => void; @@ -454,11 +470,8 @@ class RetrySchedule { export function MobileTerminalUi({ terminal, - activeSection, - defaultSection = 'type', - onSectionChange, activeKeyboardMode, - defaultKeyboardMode, + defaultKeyboardMode = 'type', onKeyboardModeChange, activeTouchMode, defaultTouchMode = 'gestures', @@ -477,10 +490,9 @@ export function MobileTerminalUi({ style, }: MobileTerminalUiProps) { useDynamicPalette(); - const resolvedDefaultKeyboardMode = defaultKeyboardMode ?? defaultSection; - const [internalKeyboardMode, setInternalKeyboardMode] = useState(resolvedDefaultKeyboardMode); + const [internalKeyboardMode, setInternalKeyboardMode] = useState(defaultKeyboardMode); const [internalTouchMode, setInternalTouchMode] = useState(defaultTouchMode); - const keyboardMode = activeKeyboardMode ?? activeSection ?? internalKeyboardMode; + const keyboardMode = activeKeyboardMode ?? internalKeyboardMode; const touchMode = activeTouchMode ?? internalTouchMode; const terminalHostRef = useRef(null); const inputRef = useRef(null); @@ -562,17 +574,14 @@ export function MobileTerminalUi({ }, [blurRetries, configurePaneTextInputs, focusRetries]); const setKeyboardMode = useCallback((nextMode: MobileTerminalKeyboardMode) => { - if (activeKeyboardMode === undefined && activeSection === undefined) { - setInternalKeyboardMode(nextMode); - } + if (activeKeyboardMode === undefined) setInternalKeyboardMode(nextMode); onKeyboardModeChange?.(nextMode); - onSectionChange?.(nextMode); if (nextMode === 'type') { focusInput(); } else { blurInput(); } - }, [activeKeyboardMode, activeSection, blurInput, focusInput, onKeyboardModeChange, onSectionChange]); + }, [activeKeyboardMode, blurInput, focusInput, onKeyboardModeChange]); const setTouchMode = useCallback((nextMode: MobileTerminalTouchMode) => { if (nextMode === 'cursor' && !cursorTouchAvailable) return; diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index 220421c7a..75229d8eb 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -12,8 +12,8 @@ import { DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, + MAX_DIRECT_PENDING_BYTES, + MAX_DIRECT_PENDING_FRAMES, NOISE_MAX_MESSAGE_LENGTH, } from 'remote-lib-common'; @@ -261,7 +261,7 @@ describe('DirectPeer', () => { const frame = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); let held = 0; - while (client.closes.length === 0 && held <= MAX_DIRECT_OUTBOUND_FRAMES) { + while (client.closes.length === 0 && held <= MAX_DIRECT_PENDING_FRAMES) { clientPeer.send(frame); held += 1; } @@ -270,8 +270,8 @@ describe('DirectPeer', () => { // log has only the reason to tell those two apart. expect(client.closes).toEqual(['the direct path outran what a sender can hold in order']); // Bytes bind first: the frame cap is far above what this many reaches. - expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); - expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); + expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_PENDING_BYTES); + expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_PENDING_BYTES); }); it('reports the channel gone when a queued frame will not go out', async () => { diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 420dff704..530ec7d0a 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -24,8 +24,8 @@ import { DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, DirectFrameQueue, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, + MAX_DIRECT_PENDING_BYTES, + MAX_DIRECT_PENDING_FRAMES, NOISE_MAX_MESSAGE_LENGTH, isDirectSdp, } from 'remote-lib-common'; @@ -143,7 +143,7 @@ export class DirectPeer { readonly #setTimer: RemoteTimer; #channel: DirectChannelLike | null = null; /** Ciphertext waiting on the channel to drain; see {@link send}. */ - readonly #outbound = new DirectFrameQueue(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_OUTBOUND_BYTES); + readonly #outbound = new DirectFrameQueue(MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_PENDING_BYTES); #cancelSetup: (() => void) | null = null; /** Cancels the grace a `disconnected` connection is given, if one is running. */ #cancelDisconnected: (() => void) | null = null; diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx index 9b7b08f73..f1247c0d6 100644 --- a/lib/src/remote/pocket-app/PocketWall.tsx +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -10,6 +10,7 @@ import { import type { DirectoryEntry } from 'remote-lib-common'; import { MobileTerminalUi, + paneMouseOverride, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode, } from '../../components/MobileTerminalUi'; @@ -87,13 +88,13 @@ export function PocketWall({ adapter, onError }: { const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined && activeMouseState.mouseReporting !== 'none'; - // Touch mode × each pane's own reporting decides its mouse override — configure - // every pane so one switched away from isn't left in a stale override. + // Every pane, not just the active one: `paneMouseOverride` is a function of + // touch mode and that pane's own reporting, so one switched away from would + // otherwise keep a stale override. useEffect(() => { for (const entry of entries) { const reporting = mouseStates.get(entry.surfaceId)?.mouseReporting ?? 'none'; - const override = touchMode === 'selection' && reporting !== 'none' ? 'permanent' : 'off'; - setMouseOverride(entry.surfaceId, override); + setMouseOverride(entry.surfaceId, paneMouseOverride(touchMode, reporting)); } }, [entries, mouseStates, touchMode]); diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index bc6fb00d3..42f4ab5fb 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -143,7 +143,7 @@ function PocketWallFrame(args: MobileTerminalUiProps) { if (!adapterRef.current) adapterRef.current = initPlatform('fake'); const [activePaneId, setActivePaneId] = useState(POCKET_WALL_PANE); const [keyboardMode, setKeyboardMode] = useState( - args.activeKeyboardMode ?? args.activeSection ?? args.defaultKeyboardMode ?? args.defaultSection ?? 'type', + args.activeKeyboardMode ?? args.defaultKeyboardMode ?? 'type', ); const sessionItems = useMobileWallSessionItems(POCKET_WALL_SESSIONS, activePaneId); @@ -164,7 +164,6 @@ function PocketWallFrame(args: MobileTerminalUiProps) { onKeyboardModeChange={(mode) => { setKeyboardMode(mode); args.onKeyboardModeChange?.(mode); - args.onSectionChange?.(mode); }} sessions={sessionItems} onSessionSelect={setActivePaneId} @@ -281,28 +280,28 @@ function GestureSnapshotFrame({ export const TypePane: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', }, render: (args) => , }; export const RecentTodoPane: Story = { args: { - defaultSection: 'recent', + defaultKeyboardMode: 'recent', }, render: (args) => , }; export const DraftTodoPane: Story = { args: { - defaultSection: 'draft', + defaultKeyboardMode: 'draft', }, render: (args) => , }; export const NonInteractivePhoneMockup: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', interactive: false, }, render: (args) => , @@ -310,7 +309,7 @@ export const NonInteractivePhoneMockup: Story = { export const CursorTouchAvailable: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', cursorTouchAvailable: true, }, render: (args) => , @@ -318,7 +317,7 @@ export const CursorTouchAvailable: Story = { export const PocketWall: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', }, parameters: { layout: 'fullscreen', diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 1c514ac0c..e0bbbd82c 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -63,16 +63,22 @@ export const DIRECT_GATHER_TIMEOUT_MS = 3_000; export const MAX_DIRECT_SDP_LENGTH = 2000; /** - * How many bytes of held channel frames a receiver holds while awaiting the - * peer's switch. **The operative bound of the two**: what the window has to - * cover is one relay one-way hop of a terminal stream, and bytes are what the - * machine actually holds (`docs/specs/remote-security-model.md` -> "Burrow - * bounds"). + * How many bytes of channel frames either direction may hold — a receiver while + * it awaits the peer's switch, a sender while the channel drains. **The + * operative bound of the two**: what the window has to cover is one relay + * one-way hop of a terminal stream, and bytes are what the machine actually + * holds (`docs/specs/remote-security-model.md` -> "Burrow bounds"). + * + * **One pair of numbers for both directions**, since each covers a burst of the + * same terminal stream: sizing them apart would mean one had a reason the other + * did not. A sender that overruns them is one whose peer is not draining fast + * enough to stay in order, which is a dead session rather than a dropped + * frame — the same answer the receiver gives. */ export const MAX_DIRECT_PENDING_BYTES = 4 * 1024 * 1024; /** - * How many channel frames a receiver holds, whatever their size. Set above + * How many channel frames either direction holds, whatever their size. Set above * where the ~1 KiB frames a PTY produces can reach it, so it stops only a peer * sending thousands of tiny ones; {@link MAX_DIRECT_PENDING_BYTES} is what * bounds real traffic. The relationship is pinned by @@ -108,26 +114,13 @@ export const DIRECT_HANDOFF_TIMEOUT_MS = DIRECT_SETUP_TIMEOUT_MS; */ export const DIRECT_DISCONNECTED_GRACE_MS = 5_000; -/** - * How much a sender holds while the channel drains, in bytes and in frames. - * - * The same pair of numbers as the receiver's hold, for the same reasons: what a - * queue has to cover is a burst of a terminal stream, bytes are what the machine - * actually holds, and the frame count sits above where the ~1 KiB frames a PTY - * produces can reach it. A sender that overruns them is one whose peer is not - * draining fast enough to stay in order, which is a dead session rather than a - * dropped frame — the same answer the receiver gives. - */ -export const MAX_DIRECT_OUTBOUND_BYTES = MAX_DIRECT_PENDING_BYTES; -export const MAX_DIRECT_OUTBOUND_FRAMES = MAX_DIRECT_PENDING_FRAMES; - /** * How much the channel implementation may have buffered before a sender stops * handing it more and queues instead, and the level it must drain back to * before sending resumes. * * Two levels rather than one, so a busy stream is not woken on every frame. - * {@link MAX_DIRECT_OUTBOUND_BYTES} is what bounds the wait; these only decide + * {@link MAX_DIRECT_PENDING_BYTES} is what bounds the wait; these only decide * where the ciphertext sits while the association catches up. */ export const DIRECT_BUFFER_HIGH = 256 * 1024; diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 9b1f73a78..65620cf75 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -22,8 +22,6 @@ import { DIRECT_SETUP_TIMEOUT_MS, DirectCutover, DirectFrameQueue, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_BYTES, MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_SDP_LENGTH, @@ -139,17 +137,7 @@ test('the send water marks bracket the queue they feed', () => { assert.equal(DIRECT_BUFFER_HIGH, 256 * 1024); assert.equal(DIRECT_BUFFER_LOW, 64 * 1024); assert.ok(DIRECT_BUFFER_LOW < DIRECT_BUFFER_HIGH); - assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_OUTBOUND_BYTES); -}); - -/** - * A sender holds what a receiver holds. The window each covers is a burst of - * the same terminal stream, so sizing them apart would mean one of the two - * numbers had a reason the other did not. - */ -test('both directions are bounded the same way', () => { - assert.equal(MAX_DIRECT_OUTBOUND_BYTES, MAX_DIRECT_PENDING_BYTES); - assert.equal(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_FRAMES); + assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_PENDING_BYTES); }); // --- The queue both directions use ------------------------------------------ diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 283bb88f9..533a7cb8f 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { MobileTerminalUi, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; +import { MobileTerminalUi, paneMouseOverride, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; import { MobileWall, useMobileWallSessionItems, type MobileWallSession } from "dormouse-lib/components/MobileWall"; import { getMouseSelectionSnapshot, @@ -228,16 +228,13 @@ export function PocketTerminalExperience({ }; }, [getPocketTouchMode, handleNotifyPocket, handleOpenGithub, subscribeToPocketTouchMode, tryAutoStart]); - // Touch mode is a single global UI state, so each pane's mouse override is a - // pure function of (touch mode) × (that pane's own reporting) — not of which - // pane happens to be active. Configuring every pane prevents a pane the user - // switched away from being left stuck in a stale override (e.g. a - // mouse-reporting pane left "permanent" after leaving Select mode). + // Every pane, not just the active one: `paneMouseOverride` is a function of + // touch mode and that pane's own reporting, so a pane the user switched away + // from would otherwise be left stuck in a stale override. useEffect(() => { for (const session of POCKET_SESSIONS) { const reporting = mouseStates.get(session.id)?.mouseReporting ?? "none"; - const override = touchMode === "selection" && reporting !== "none" ? "permanent" : "off"; - setMouseOverride(session.id, override); + setMouseOverride(session.id, paneMouseOverride(touchMode, reporting)); } }, [mouseStates, touchMode]);