Skip to content

E2E remote control, stages 1–3: Noise suite, identities, relay harness (no user-facing change) - #515

Merged
nedtwigg merged 11 commits into
mainfrom
e2e-client-host
Sep 2, 2026
Merged

E2E remote control, stages 1–3: Noise suite, identities, relay harness (no user-facing change)#515
nedtwigg merged 11 commits into
mainfrom
e2e-client-host

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 1, 2026

Copy link
Copy Markdown
Member

First of three stacked PRs for the e2e-client-host scope (docs/specs/remote-security-model.md## Future): moving pairing, connection, terminal traffic, and push to a mandatory Noise_IK_25519_ChaChaPoly_SHA256 channel the Server cannot read. This PR is infrastructure only; nothing user-visible changes and no ceremony, relay reader, or store is switched.

What lands

  • Stage 0 — the plan, dissolved into the specs. The scope, trust model, suite, presence proofs, pairing, connection, Host bounds, push sealing, and residual metadata live under remote-security-model.md ## Future; QR grammar, relay envelope, routes, and state files under server.md; phone flows and the worker build under pocket-app.md; pointers in remote-api.md and alert.md.
  • Stage 1 — Noise suite and vectors. server-lib-common/src/security/noise.ts: exactly one suite, Noise rev 34, WebCrypto-only X25519/SHA-256/HMAC, Noise's HMAC-based HKDF, ChaChaPoly from @noble/ciphers pinned at 2.4.0 with the release-versus-audit delta recorded in the module header. Conformance is byte-for-byte against the vendored Cacophony vector plus the RFC 7748 and RFC 8439 vectors; ephemeral injection is the only test hook.
  • Stage 2 — additive identities and storage. Host Noise static minted at enrollment and persisted with the enrollment (file store / VS Code SecretStorage), imported non-extractably at start; probeNoiseSupport (not yet enforced); the dormouse/presence/v1 challenge builder and PresenceBinding guards; Pocket IndexedDB v2 with known-hosts and pending-deletions beside the legacy device-key store.
  • Stage 3 — relay envelope, framing, harness. The e2e relay frames and bounds accepted and routed additively by the Server; the transport framing module (noise-transport.ts: kind byte, length-prefixed app stream with a 1 MiB reassembly cap, 4096-byte padded control messages, keepalives, permanent session poisoning); a fake Host and fake Client speaking Noise through the real relay, proving transcript binding, directional cipher states, counters, framing, teardown, relay opacity, and tamper rejection.

Each stage had a /simplify pass and a high-effort /code-review pass committed on top of it (the review commits are in the history). Specs were promoted above the fold per stage; pnpm lint:specs is green.

Verification

Root pnpm test (all lints, server-lib-common, server, lib, vscode-ext, website) passes on the head commit.

Next

PR 2 (stacked) lands the atomic pairing/connection/push-re-keying cutover and the Host bounds; PR 3 lands sealed push, the built service worker, and the documentation/lint enforcement. Two iOS facts are still to be verified on a device before PR 2 ships: an X25519 CryptoKey survives IndexedDB structured clone, and getUserMedia works inside a Home Screen web app.

🤖 Generated with Claude Code

https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj

nedtwigg and others added 10 commits September 1, 2026 13:07
Dissolve the mandatory end-to-end Client–Host migration plan into the specs
that own each part of it: the trust model, suite, presence proofs, pairing,
connection, Host bounds, and push sealing under remote-security-model.md
`## Future` as **Scope: e2e-client-host**; the QR grammar, relay envelope,
routes, and state files under server.md; the phone flows and worker build
under pocket-app.md; one pointer each in remote-api.md and alert.md.

Design changes relative to the earlier draft, all recorded in the scope:
pairing verifies presence exactly like connection (a handshake-hash-bound
proof after Split, replacing the 30-second window); delivery IDs are
possession-only capabilities the Server never lists; push is re-keyed in the
same stage that removes the device key so no stage ships a broken push path;
the `setup-token-redeemed` frame gives way to a Host-owned invitation state;
paste joins the scanner; the app-session key, the one-minute resume, and the
versioned state-file refusals are dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Stage 1 of the e2e-client-host scope: the one Noise suite the end-to-end
protocol will run on, with nothing wired to it yet.

`server-lib-common/src/security/noise.ts` implements Noise revision 34 for
IK only — CipherState, SymmetricState, and the two-message handshake — with
no pattern registry, cipher negotiation, or protocol-name override. X25519,
SHA-256, and HMAC are WebCrypto, so a long-term private key can stay a
nonextractable CryptoKey; HKDF is Noise's own HMAC construction, not
WebCrypto's. ChaCha20-Poly1305 comes from @noble/ciphers pinned to exactly
2.4.0, and the module header records the pin, the Cure53 audit of 1.0.0
(Sep 2024), and what changed in the chacha path since.

Conformance is the Cacophony vector for this suite, vendored with its
Unlicense attribution and checked byte for byte through both handshake
messages, every transport message in both directions, and the handshake
hash, alongside RFC 7748 X25519 and RFC 8439 AEAD. Ephemeral-key injection
is the only test hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only pass over stage 1; no wire bytes change (the Cacophony vector
tests still pass byte for byte).

- Each handshake message's framing size gets one encoding (`MESSAGE_1_OVERHEAD`
  / `MESSAGE_2_OVERHEAD`) and one guard (`requireMessageLength`) that owns both
  ends of the contract. The write cap lived in the leaves while the read cap
  lived in the `readMessage` wrapper and the read minimum back in the leaves,
  spelling the same number four different ways.
- `NoiseHandshake`'s constructor is private with a validating static `start`,
  so the module-private `SymmetricState` no longer leaks its whole shape into
  the published `.d.ts`, and every key is length-checked in one place.
- Collapse `#injectedEphemeral`/`#ephemeral` into one field, drop the dead
  `#messageIndex = 2` stores for a boolean, and derive `Split`'s direction from
  `#role` instead of two magic booleans.
- Reuse `constantTimeEqual` for the all-zero DH check, and import the HMAC key
  once per HKDF instead of three times.
- Drop the unused `KeyDataLike` widening added alongside `deriveBits`: every
  `importKey` caller passes a `Uint8Array`.
- Say it once: cut header, docstring, and test comments that restated
  `docs/specs/remote-security-model.md` -> Noise suite or the code below them.
  The `@noble/ciphers` pin and audit-delta note stay verbatim; the spec
  requires them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Five confirmed findings, each pinned by a test that fails without its fix.

- **Buffer aliasing.** Node aliases `Buffer.prototype.slice` to `subarray`, so
  `message.slice(0, 32)` returned a live view for the `Buffer` a Node host gets
  from a WebSocket frame. The responder's `re` could change under the `ee` DH
  that message 2 still owed if the caller reused its read buffer; the
  initiator's `rs` had the same defeat. One `copyKey` helper now copies
  unconditionally, and the `remoteStaticPublicKey` getter hands back a copy
  rather than the array the `se` DH still depends on.
- **Transport messages were uncapped.** Noise's 65,535-byte cap covers every
  message, but only the handshake enforced it, so an over-length frame was
  silent on the sending side and failed only at the conformant peer.
  `encryptWithAd`/`decryptWithAd` now enforce it, tag counted.
- **`generateNoiseKeyPair` could reject with a `DOMException`** on a runtime
  without X25519 — the one exported function that escaped `NoiseError`, so a
  caller's `instanceof NoiseError` branch missed it.
- **`NoiseHandshake.start` could pair a role with a mismatched `rs`**, whose
  only symptom was `#writeMessage1` dereferencing `undefined` into an opaque
  'handshake failed'. The role is derived from `rs` instead — in IK the
  initiator is exactly the side that knows it — so the mismatch is
  unrepresentable and the `#remoteStatic!` assertion is sound.
- **An over-long remote static** would have been accepted as its first 32
  bytes once the copy truncated it; it is length-checked before the copy.

Skipped: the review's claim that no spec cites the e2e-client-host stages by
number is false — `docs/specs/pocket-app.md` and `docs/specs/server.md` both
do, so the `## Future` numbering stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…ores (stage 2)

Stage 2 of the e2e-client-host scope: additive only. Nothing in production
reads any of this yet — no legacy store deleted, no v1 state rejected, no
ceremony, relay, or reader switched, no gate enforced.

- `probeNoiseSupport` (server-lib-common/src/security/noise-support.ts): one
  X25519 generateKey plus one deriveBits, `false` on every rejection including
  a missing WebCrypto, never throws. No caller yet.
- `presenceChallenge` / `PresenceBinding` / `isPresenceBinding`
  (server-lib-common/src/security/presence.ts) under `dormouse/presence/v1`,
  with one encoding rule — base64url fields hashed as their bytes, everything
  else UTF-8 — pinned by a vector computed from `node:crypto` in the test.
- `mintNoiseStaticKeyPair` / `importNoiseStaticPrivateKey` in noise.ts; the
  Host mints its static after the Server answers `POST /api/host/enroll`
  (request body unchanged) and carries it in the enrollment record, so both
  stores persist it unchanged. `isEnrollment` takes both halves or neither;
  `RemoteHost` imports the private half nonextractably in `start()` and holds
  it unread.
- Pocket IndexedDB v2 (lib/src/remote/client/pocket-db.ts): one owner of the
  database name, version, and stores, adding `known-hosts` and
  `pending-deletions` beside `device-key`, with typed records, IndexedDB and
  in-memory stores, and a best-effort `navigator.storage.persist()`.
  `device-key.ts` now opens through the same function.
- Specs promoted above the fold (remote-security-model, pocket-app, server),
  stage 2 marked landed, SECURITY.md's credentials table names the new key,
  two word budgets raised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Four parallel cleanup reviews (reuse, simplification, efficiency,
altitude) over the stage-2 diff; efficiency found nothing.

- `noise.ts` absorbs `probeNoiseSupport` (deleting `noise-support.ts`,
  which had re-declared the X25519 algorithm and key length) and gains
  `isNoiseStaticMaterial`, so the persisted-static shape rule lives beside
  what mints and imports it instead of being hand-rolled a package away in
  `isEnrollment`. `generateNoiseKeyPair` and `mintNoiseStaticKeyPair` now
  share one `generateX25519`.
- `pocket-db.ts` gets `withPocketStore`, collapsing nine copies of
  open/transaction/close; the two unused in-memory fakes are gone (the
  repo's precedent is a test-local fake, and only their own test used
  them).
- `#loadNoiseStatic` returns `void`, matching its one fire-and-forget
  caller; `isAddressedFrame` adopts the hoisted `isBoundedString`, and
  `pairing.ts` drops the alias-plus-shadow around it so both guard modules
  spell the wrapper `bounded`.
- Prose: comments that restated the specs shrink to pointers, and the
  pocket-app budget tightens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Every finding below is pinned by a test that fails without its fix.

- `isPresenceBinding` takes exactly one kind's field set. It accepted extra
  keys, and only what `presenceChallenge` hashes is covered by the
  assertion — an unhashed field would have reached the Host inside a
  binding it had just verified.
- `presenceChallenge` bounds `serverNonce`. It is the one input no binding
  guard covers, and on the Host's recompute path it arrives from the
  Client, so nothing stopped a megabyte of base64url from being decoded and
  hashed.
- `openPocketDb` handles both version-collision directions: it closes on
  `versionchange` so a handle here cannot block the staged v3, and rejects
  on `blocked` so a pre-v2 tab holding v1 open names the failure instead of
  leaving the open promise unsettled forever.
- `mintNoiseStatic` checks its own output against `isNoiseStaticMaterial`.
  Minting is documented as best-effort, but a PKCS#8 outside what
  `isEnrollment` accepts failed the whole exchange — with an error naming
  no field, since `missingEnrollmentFields` only reports the server's.
- `isNoiseStaticMaterial` says out loud that it is shape only: it does not
  derive the public point, so halves from different keypairs pass.
- Spec: the stage-4 gate needs a mint-on-start backfill, since minting is
  never retried and a gate alone would un-enroll a machine over one
  transient failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
…e 3)

Stage 3 of the e2e-client-host scope: the `e2e` relay envelope, the E2E
transport framing, and a relay-integrated Noise harness. Harness-only —
`RemoteHost`, `PocketClient`, pairing, connection, `/api/hosts`, and every
UI are untouched, and nothing in production distributes or sends a Host
static key. Both parties' statics are injected by the tests.

- `server-lib-common/src/remote/wire.ts`: the `e2e` frames beside the legacy
  union (nothing legacy removed), with `E2E_ID_LENGTH`,
  `MAX_E2E_CIPHERTEXT_LENGTH` computed from `NOISE_MAX_MESSAGE_LENGTH`, and
  guards a stage-4 Host reuses verbatim.
- `server-lib-common/src/security/noise-transport.ts`: `[kind: u8][body]`
  plaintexts (keepalive / stream / 4096-byte padded control), the
  length-prefixed application stream with its 1 MiB reassembly cap, the
  chunker, and `NoiseTransportSession`, which the first decrypt failure,
  nonce gap, or framing violation poisons permanently.
- `server/src/relay.ts`: `init` binds as `connect` does, transport is
  forwarded only within the binding, and the hub never parses `ct` or keeps
  Noise state. Its shape and size guards are defense in depth.
- Harness: `FakeClient` (initiator) and `FakeHost`'s responder half, with the
  shared prologue builder in `server/test/harness/e2e.mjs`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Quality-only cleanups from /simplify over the stage-3 diff. No framing
constant, byte layout, or wire bound changes; the harness is still the
only speaker of the `e2e` envelope.

Reuse
- `isBoundedBase64Url` + `base64UrlLength` move to `bytes.ts`; the
  base64url charset regex had three copies. `isSetupTokenHandle`,
  `isE2eId`, and `isE2eCiphertext` now share one.
- `writeUint32BE` / `readUint32BE` / `UINT32_SIZE` move to `bytes.ts`,
  used by `lengthPrefixedConcat`, the app-stream framing, and the test
  that hand-rolled its own length prefix.
- `MAX_E2E_CLIENT_ID_LENGTH` and `remote-host.ts`'s `MAX_CLIENT_ID_LENGTH`
  were the same bound with the same rationale twice; one now, shared.
- `RelayHub.#bindClientToHost` is the one place the client↔host binding
  transition is written, so `connect` and an `e2e` `init` cannot drift.
- `server/test/harness/frame-socket.mjs` holds the socket, frame
  recording, and teardown both fake peers had copies of.

Simplification
- `StreamChunker` was a stateless class; it is now `chunkAppMessage`.
- Dropped `MAX_REASSEMBLY_BUFFER`'s export, `sendNonce`, `entry.noise`,
  `FakeHost.sendFrame`, `e2eSendKeepalive`, `e2eSendControl`, and
  `e2eEntry`'s unused keyed lookup — none had a caller.
- `TransportReceipt` derives from `TransportPlaintext`; the two shared
  arms were spelled twice with nothing pinning them.
- "Say it once": the relay's opacity, the both-sides guard rule, and the
  no-production-speaker caveat were stated 5-7 times each across code,
  harness, tests, and the spec. Each keeps one home plus pointers.

Efficiency
- `StreamReassembler` queues bodies and copies once per completed
  message instead of re-concatenating a growing buffer on every push.
  `MAX_STREAM_BODY_LENGTH` is a maximum, not a minimum: an authenticated
  peer may split one 1 MiB message into single-byte bodies, which the
  old form turned into seconds of blocking memcpy. Pinned by a
  one-byte-body reassembly test and a split-length-prefix test.
- `isE2eServerToHostFrame` checks the free `clientId` bound before the
  ciphertext scan it used to follow (~33 us on a maximal `ct`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Every finding is pinned by a test that fails without its fix.

- `NoiseTransportSession` no longer destroys itself when `sendControl` or
  `sendApp` refuses an over-size input. That refusal happens before the
  first `encryptWithAd`, so no ciphertext exists and no counter moved —
  the stream is exactly as synchronized as it was. Poisoning there turned
  a caller's size error into a re-handshake, which costs fresh user
  presence. A failure at or after the first encrypt still poisons.

- `FakeHost` serializes socket frames through a promise chain, as the
  relay does for its client socket and for the same reason: `#onFrame`
  awaits three times on an `e2e` `init` before recording the session, so
  a `transport` pipelined behind its own `init` was answered "no e2e
  session" instead of being read against the ceremony it belongs to.
  Stage 4 pipelines exactly that — the Client's first transport payload
  is what authorizes the connection.

- Dropped the reassembler's queue-size throw. It could not fire: the
  drain loop only ever waits on a declared length it already accepted,
  so the length cap is what bounds the queue. Keeping an unreachable
  branch that reads like the backstop would hide that if the cap moved.
  Spec says so now instead.

- Capped the harness frame logs. Recording moved into `FakeHost` with
  the shared socket helper, and `scripts/fake-host.mjs` is a
  long-running dev stand-in driving a live echo terminal — it would have
  retained every relayed PTY byte for the life of the process.

- Pinned two behaviors that were correct but untested: a Client
  `transport` before any `init` is dropped rather than forwarded, and
  control JSON containing a NUL survives the padding strip (trailing-NUL
  stripping cannot truncate legitimate JSON, since `JSON.stringify`
  escapes NUL).

Noted for stage 4 in server.md `## Future`: `e2e` requires a `hostId` of
base64url-16-bytes where `isStoredHost` accepts any string, so once the
legacy frames are deleted, enrollment has to pin the shape or a
hand-edited `hosts.json` row becomes an unreachable Host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3abb214
Status: ✅  Deploy successful!
Preview URL: https://1b4f8b0a.mouseterm.pages.dev
Branch Preview URL: https://e2e-client-host.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Test is red on f1be971, and the failure hides more than it looks like: it fires at the Dependency disclosure is current step, which sits ahead of Test and Build in the job — both were skipped, so pnpm test and pnpm build never actually ran on this head. The PR body's "Root pnpm test ... passes on the head commit" is a local claim CI has not confirmed.

The cause is this PR's own new dependency. website/src/data/dependencies-npm.json still has 49 entries and no @noble/ciphers. Fix is node website/scripts/generate-deps.js, committed; the hunk CI printed goes right after the @hono/node-ws entry:

  {
    "name": "@noble/ciphers",
    "version": "2.4.0",
    "license": "MIT",
    "author": "Paul Miller (https://paulmillr.com)",
    "homepage": "https://paulmillr.com/noble/"
  },

It's outside the diff, so no inline suggestion — happy to push that commit if you want it off your plate.

Since CI couldn't, I ran what I could locally, and it's clean: all six root lint/selftest scripts pass, and server-lib-common is 218/218 under node --test against a locally-built dist and a real @noble/ciphers@2.4.0 — Cacophony vector byte-for-byte through both handshake messages and all six transport messages, RFC 7748, RFC 8439, framing, reassembly, and the wire guards included. lib, server, vscode-ext, and website remain unverified until the disclosure step goes green.

No other findings. Details of what I checked and came up empty on, in case it saves a re-derivation later:

Review notes

Noise conformance — traced IK against Noise rev 34 by hand before trusting the vector: message 1 e, es, s, ss and message 2 e, ee, se mix in pattern order on both the write and read sides, the pre-message <- s mixes rs on the initiator and its own s on the responder, and Split gives the initiator k1 for send. NOISE_PROTOCOL_NAME is exactly 32 bytes, so initialize's "name is exactly HASHLEN" guard is load-bearing rather than decorative. Static reflection (a Client claiming the Host's own s) fails at ss because the forger cannot compute DH(priv_r, pub_r) — no separate guard needed.

Framing boundsMAX_STREAM_BODY_LENGTH (65518) + kind byte + tag lands exactly on 65535, so the chunker sits on the boundary rather than under it. StreamReassembler's queue is bounded by the accepted declared length: at most 4 + 1 MiB - 1 held plus one maximal body. #take's peek path increments index even on a partial chunk, which is harmless — a partial chunk is always the last iteration. #queued stays equal to the sum of the live tail on every path, including the length === 0 case that consumes 4 and pushes an empty message.

Relay#bindClientToHost is behavior-preserving for pair/connect/connect2: dropEstablished || client.hostId !== hostId is the old client.hostId !== frame.hostId || frame.t === 'connect'. The host→client e2e case is gated by the pre-existing client.hostId !== host.hostId return, so it inherits the binding rule. isE2eId on hostId matches the server's toBase64Url(randomBytes(16)) minting (22 chars), and clientId is minted the same way, well inside MAX_CLIENT_ID_LENGTH.

Packaging@noble/ciphers@2.4.0 really does map "./chacha.js" in its exports, so the import specifier resolves; it appears in no esbuild external list, so it bundles into the VS Code host rather than needing runtime resolution.

IndexedDB v2pocket-db.ts is the only indexedDB.open in the repo, so the "two of them can never disagree about the version" claim holds. The create-what-is-absent upgrade keeps a v1 device-key record with its original out-of-line key. One cosmetic wrinkle: onblocked rejects, but the underlying open request stays live and can still fire onsuccess later, leaving an unclosed handle — the onversionchange handler set there closes it on the next bump, so it self-heals.

utf8Decode accepts overlong sequences and lone surrogates rather than rejecting them, which is a decoder-differential only against consumers that don't use this module. Since the encoder never emits those forms and control bodies are already Noise-authenticated, it isn't a finding today; worth remembering if a non-Dormouse speaker ever reads these bytes.

`Build & Test` died at the **Dependency disclosure is current** step, which
sits ahead of `Test` and `Build` — so neither `pnpm test` nor `pnpm build`
ran on this head at all. Stage 1 added `@noble/ciphers` as a prod dependency
of `server-lib-common` without regenerating the snapshot the step diffs
against (SECURITY.md, Dependency Supply Chain).

Regenerated with `node website/scripts/generate-deps.js`; the only change is
the one entry CI printed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
@nedtwigg

nedtwigg commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Fixed in 3abb214 — regenerated website/src/data/dependencies-npm.json with node website/scripts/generate-deps.js; the only change is the @noble/ciphers entry you quoted, byte-for-byte.

You were right that the PR body's claim was unconfirmed, since Test and Build never ran. Re-ran both locally on the new head after the regeneration:

  • Root pnpm test exit 0 — server-lib-common and server (217/217 under node --test), lib 1970/1970, vscode-ext 113/113, standalone 62/62, website 65/65, plus all six root lint/selftest scripts.
  • Root pnpm build exit 0 (lib, VS Code extension, website).

On the two non-findings in your notes: agreed on both, and neither is changed here. The onblocked handle self-heals as you describe, and utf8Decode's leniency is only a differential against a non-Dormouse speaker of these bytes, which does not exist today — both worth revisiting if a later stage ever puts a foreign decoder on the far end.

One caveat on the local run worth recording: the first full-suite attempt stalled with server/test/handshake.test.mjs idle for 20+ minutes (listening socket, no connections, 0.5s of CPU). It did not reproduce — the file passes in 0.9s alone and the whole server suite passes in 30s — and the machine was running several other builds at the time, so I am treating it as contention rather than a deadlock. Flagging it because node --test runs here with --test-timeout=0, so if it ever is real it hangs the job instead of failing it.

Chromatic's UI Tests reports one unaccepted change, SettingsDialog:With Remote Control. It does not originate here: build 666 baselines against build 662, which is main at fdfe205a and already carries that exact same single unreviewed change. This branch adds no story, no component, and no rendered-path code — the lib files it touches are remote/client/{device-key,pocket-db}, remote/host/{enrollment,remote-host}, and package.json. The baseline needs accepting on main by a maintainer.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Build & Test is green on 3abb214f, and this time Test and Build actually ran — the two steps that were skipped on f1be971. Test 23:12:46 → 23:14:07 (81s), Build 27s, whole job 3m07s (run). So the PR body's claim is CI-confirmed now, not just local.

UI Tests also flipped to pass on this head — Chromatic build 666 reports "249 tests unchanged", so the SettingsDialog:With Remote Control baseline got accepted on main. Nothing left there.

On the handshake.test.mjs stall, one fact worth having in case it ever is real: build-and-test sets no timeout-minutes — only the zsh install step and standalone-platform-check do — so a genuinely hung node --test runs to GitHub's 360-minute job default and then fails with a bare timeout and no test output. Your read on --test-timeout matches the tree: nothing sets it anywhere, and server's script is a bare node --test test/*.test.mjs, so there is no per-test cap either. It didn't reproduce in CI — the whole server suite fit inside that 81s Test step.

@nedtwigg
nedtwigg merged commit 84de443 into main Sep 2, 2026
13 checks passed
@nedtwigg
nedtwigg deleted the e2e-client-host branch September 2, 2026 15:31
nedtwigg added a commit that referenced this pull request Sep 2, 2026
main's Noise rewrite (#515, #517, #518, #522, #523, #524) already landed
this PR's change: `test-remote-host-link.ts` now imports
`DEFAULT_PAIRING_TTL_MS` from `server-lib-common` and builds the fixture's
`expiresAt` from it, exactly as this branch intended. The surrounding QR
grammar it was written against is gone — the fixture composes a positional
`#pair?` invitation via `formatPairingInvitationUrl`, and `SetupTokenResponse`
no longer carries `mintId`.

Both conflict hunks resolved wholly on main's side, so the branch is now an
exact no-op against main: nothing pre-Noise is reintroduced, and the intent
this PR chased is already enforced by main's import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v3meFVinnMLS6Jrffvxbj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants