216aa7d01f58
chore: version bump 4.0.2 → 4.0.3 + build — desktop helper pairing; trash integrated DESKTOP-PAIRING drafts (v4.0.3)
- time
- 2026-06-17 13:21 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- desktop helper pairing integration
- parent
- d04f5b124fc2
↓ peach-browser-216aa7d0.tar.gzatlas at this save
files changed15 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/drafts/DESKTOP-PAIRING/01-vault-id-in-pair-identity.md+0 −151src/drafts/DESKTOP-PAIRING/02-desktop-device-type.md+0 −66src/drafts/DESKTOP-PAIRING/03-desktop-pairing-handler.md+0 −435src/drafts/DESKTOP-PAIRING/04-desktop-pairing-ui.md+0 −495src/drafts/DESKTOP-PAIRING/05-subnet-probe.md+0 −159src/drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md+0 −376src/drafts/DESKTOP-PAIRING/INTEGRATION-CONTRACT.md+0 −283src/drafts/DESKTOP-PAIRING/README.md+0 −53src/drafts/DESKTOP-PAIRING/_GROUND-TRUTH.md+0 −322src/drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts+0 −127src/drafts/DESKTOP-PAIRING/tests/03-desktop-pairing.test.ts+0 −268src/drafts/DESKTOP-PAIRING/tests/04-desktop-pairing-ui.test.tsx+0 −226manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.2",5+ "version": "4.0.3",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.2",4+ "version": "4.0.3",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.0.2",3+ "version": "4.0.3",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/drafts/DESKTOP-PAIRING/01-vault-id-in-pair-identity.md+0 −151
@@ -1,151 +0,0 @@1-# Change 1 — Add `vault_id` to the PAIR_IDENTITY frame2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Target file:4-> `/Users/june/projects/peach-browser/src/shared/chirp-handshake.ts`.5-6-## Goal7-Have the extension **send** `vault_id` in the `PAIR_IDENTITY` frame so the8-desktop helper can persist which vault it was paired to. peach-sync already9-accepts `vault_id` as an **optional** field10-(`peach-sync/src/pairing.zig:54`, tolerated/ignored when absent), so this is11-purely additive and backwards-compatible.12-13-## Backwards-compatibility statement14-- `vault_id` is `?[]const u8 = null` in peach-sync and parsed with15- `ignore_unknown_fields` (`pairing.zig:54,57,167`); it is consumed defensively16- (`orelse ""`, persisted only when non-empty — `pairing.zig:209,212-214`).17-- Existing chirp peers (mobile Android/iOS) that **omit** `vault_id` keep18- working unchanged — the field is optional on both the TS interface and the19- helper struct.20-- The helper does not send `vault_id` back (`pairing.zig:173-180`), so the21- extension's `decodeFrame` does not require it on receive. The patch below only22- affects the OUTBOUND frame.23-24-## Privacy consideration (carry the existing note forward)25-`vault_id` is the same stable, LAN-broadcast identifier documented at26-`src/shared/sync-identity.ts:62-74` (PRIVACY NOTE / PCH-02-006): it is stable27-across sessions and lets a passive LAN observer link every device sharing a28-vault. Putting it in `PAIR_IDENTITY` keeps it inside the **encrypted** NK29-transport, so this change does not add network exposure beyond what mDNS sync30-discovery already leaks. The patch comment references that note rather than31-restating it.32-33-## Threading: where `vault_id` enters the handshake34-`computeVaultId` is async and needs the in-memory `masterKey`35-(`sync-identity.ts:75-82`), neither of which is in scope inside36-`runPairingInitiator`/`runPairingResponder`. The clean seam is the existing37-`LocalIdentity` argument already passed into both functions: add an optional38-`vaultId` field, computed by the caller that already holds the master key (the39-pairing-flow entry point in the popup/background that builds `LocalIdentity`).40-No master key is introduced into `chirp-handshake.ts`.41-42-- Caller obligation (documented, not patched here): the code that constructs43- `LocalIdentity` for pairing must call44- `vaultId = await computeVaultId(masterKey)` (`sync-identity.ts:75`) and set it45- on the identity. If the caller has no master key (locked vault), it leaves46- `vaultId` undefined and the field is omitted — still valid.47-48----49-50-## Patch (apply to `src/shared/chirp-handshake.ts`)51-52-### Hunk A — extend `LocalIdentity` with optional `vaultId`53-Context: lines 43-51.54-```diff55- export interface LocalIdentity {56- deviceId: string;57- deviceName: string;58- deviceType: DeviceType;59- longTermPublicKeyHex: string;60- signingPublicKeyHex?: string;61- /** Server-only field — Android/iOS/desktop sync hosts announce their port. */62- wsPort?: number;63-+ /**64-+ * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the65-+ * outbound PAIR_IDENTITY so a desktop helper can record which vault it66-+ * paired to. Optional: the caller omits it when the vault is locked (no67-+ * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE68-+ * at sync-identity.ts:62 (PCH-02-006).69-+ */70-+ vaultId?: string;71- }72-```73-74-### Hunk B — add `vault_id` to the `PairingFrame` interface75-Context: lines 53-61.76-```diff77- interface PairingFrame {78- type: "PAIR_IDENTITY";79- device_id: string;80- device_name: string;81- device_type: DeviceType;82- long_term_public_key_hex: string;83- signing_public_key_hex?: string;84- ws_port?: number;85-+ // Optional: present only when the sender knows its vault id. peach-sync86-+ // treats this as optional (pairing.zig:54); chirp mobile peers omit it.87-+ vault_id?: string;88- }89-```90-91-### Hunk C — emit `vault_id` from the INITIATOR frame92-Context: lines 106-114 (`runPairingInitiator`).93-```diff94- const ourFrame = encodeFrame({95- type: "PAIR_IDENTITY",96- device_id: ourIdentity.deviceId,97- device_name: ourIdentity.deviceName,98- device_type: ourIdentity.deviceType,99- long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,100- signing_public_key_hex: ourIdentity.signingPublicKeyHex,101- ws_port: ourIdentity.wsPort,102-+ vault_id: ourIdentity.vaultId,103- });104-```105-106-### Hunk D — emit `vault_id` from the RESPONDER frame107-Context: lines 209-217 (`runPairingResponder`).108-```diff109- const ourFrame = encodeFrame({110- type: "PAIR_IDENTITY",111- device_id: ourIdentity.deviceId,112- device_name: ourIdentity.deviceName,113- device_type: ourIdentity.deviceType,114- long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,115- signing_public_key_hex: ourIdentity.signingPublicKeyHex,116- ws_port: ourIdentity.wsPort,117-+ vault_id: ourIdentity.vaultId,118- });119-```120-121-> `encodeFrame` is `JSON.stringify` (line 63-65): when `vaultId` is `undefined`,122-> `JSON.stringify` **drops the key entirely**, so the omitted-field123-> backwards-compat behaviour is automatic — no extra guard needed.124-125-### (Optional) Hunk E — validate `vault_id` on decode if present126-`decodeFrame` (lines 67-85) currently ignores extra fields. The helper never127-sends `vault_id`, so receive-side validation is **not required** for desktop128-pairing. If a future symmetric flow has the peer send it, add a lenient guard:129-```diff130- if (131- parsed.signing_public_key_hex !== undefined &&132- !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)133- ) {134- throw new Error("signing_public_key_hex is not 32-byte hex");135- }136-+ // vault_id, when present, is a 64-char lowercase hex BLAKE3 digest137-+ // (computeVaultId). Lenient: only validate shape if the peer sent one.138-+ if (parsed.vault_id !== undefined && !/^[0-9a-f]{64}$/.test(parsed.vault_id)) {139-+ throw new Error("vault_id is not 32-byte hex");140-+ }141- return parsed as PairingFrame;142-```143-**Recommendation:** ship Hunks A–D only for the desktop-pairing milestone; defer144-Hunk E until a peer actually sends `vault_id`, to avoid over-constraining.145-146-## Notes for the implementer147-- `vault_id` format is the 64-char lowercase hex BLAKE3 output of148- `computeVaultId` (`sync-identity.ts:28,81`). Keep it lowercase hex to match149- `SYNC_HELLO`'s `vaultId` so the helper's later vault check150- (`sync_coordinator.zig:141`) compares equal.151-- Do not log `vault_id`. It is the LAN-linkable id (privacy note above).src/drafts/DESKTOP-PAIRING/02-desktop-device-type.md+0 −66
@@ -1,66 +0,0 @@1-# Change 2 — Add `"desktop"` DeviceType2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md`.4-5-## VERDICT: already done. This change is a NO-OP against current source.6-7-The brief assumed `"desktop"` was missing from `DeviceType` and that the type8-"likely" lives in `src/shared/types.ts`. **Both assumptions are wrong against the9-ground-truth source:**10-11-1. `DeviceType` is defined in **`src/shared/sync-types.ts:11`**, not `types.ts`12- (`types.ts` has no `DeviceType` symbol). `chirp-handshake.ts:29` and13- `paired-devices.ts:23` both import device types from `./sync-types`.14-15-2. `"desktop"` is **already a member**:16- ```ts17- // src/shared/sync-types.ts:1118- export type DeviceType = "extension" | "android" | "ios" | "desktop";19- ```20-21-3. The full desktop plumbing already exists:22- - `DEVICE_TYPE_DESKTOP = 0x04` chirp byte tag (`sync-types.ts:19`)23- - `DeviceTypeByte` includes it (`sync-types.ts:25`)24- - `deviceTypeFromByte(0x04) → "desktop"` (`sync-types.ts:36`)25- - `deviceTypeToByte("desktop") → 0x04` (`sync-types.ts:45`)26- - `chirp-payload.ts` accepts byte `0x04` on the wire (`chirp-payload.ts:5,89`)27-28-4. peach-sync announces exactly this string: `.device_type = "desktop"`29- (`peach-sync/src/pairing.zig:177`), which lands on the existing union member.30-31-**No edit to the type union is required.** If a downstream pass "adds" it again32-it will be a redundant no-op.33-34----35-36-## What ACTUALLY needs verifying: the storage gate (`paired-devices.ts`)37-38-The real question is whether the paired-device registry rejects a `"desktop"`39-device. **It does not — there is no enum gate.**40-41-`upsertPairedDevice` (`src/background/paired-devices.ts:56-70`) validates only42-*presence*, never membership against the `DeviceType` union:43-```ts44-// paired-devices.ts:6045-if (!device.deviceType) throw new Error("device.deviceType is required");46-```47-It checks the field is truthy, not that it equals a known literal. `"desktop"`48-(or any non-empty string) passes. Storage round-trips it verbatim49-(`isPairedDevice`, `paired-devices.ts:174-180`, only checks `deviceId` is a50-non-empty string and `pairedAt` is a number — `deviceType` is not gated).51-52-`PairedDevice.deviceType` is `DeviceType | undefined` (`sync-types.ts:89`), so53-`"desktop"` is statically valid; nothing to extend.54-55-### Conclusion / action for implementer56-- **No code change for Change 2.** Optionally, add a regression test asserting a57- `"desktop"` device upserts and round-trips through `paired-devices.ts`58- (kept minimal; the registry already supports it).59-- If the brief's reviewer insists on a literal "edit", the only honest one is a60- doc/comment — there is no missing union member and no gate to widen.61-62-## Discrepancy summary (for the report)63-- Brief said: add `"desktop"` to `DeviceType` in `src/shared/types.ts`.64-- Source truth: `"desktop"` already present in `src/shared/sync-types.ts:11`;65- `types.ts` is the wrong file; the storage layer already accepts it. **Change is66- unnecessary.**src/drafts/DESKTOP-PAIRING/03-desktop-pairing-handler.md+0 −435
@@ -1,435 +0,0 @@1-# Change 3 — Desktop helper pairing handler2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module:4-> `/Users/june/projects/peach-browser/src/background/desktop-pairing.ts`.5-> Reuses Change 1 (`vault_id` in `PAIR_IDENTITY`) and Change 5 (plain6-> port probe). Nothing here is applied — it is an apply-ready draft.7-8-## What this change adds9-10-A net-new browser-side **code pairing** client. The existing11-`chirp-handshake.ts` only implements the *audio chirp* NK flow where the12-**extension is the NK responder**. Desktop pairing is the OPPOSITE: the13-**browser is the NK initiator** against the helper's ephemeral key learned14-from `PEACH_HELLO_ACK` (`_GROUND-TRUTH.md` §3).15-16-## Verified dependencies (real exported symbols)17-18-| Need | Symbol | Location |19-| --- | --- | --- |20-| NK initiator primitive | `noiseNKInitiator(responderStaticPublicKey, send, receive)` | `src/shared/noise-js.ts:71` |21-| SAS derivation (REUSE) | `derivePairingSAS(handshakeHash: Uint8Array): string` | `src/shared/chirp-handshake.ts:171` |22-| Persist paired device | `upsertPairedDevice(device: PairedDevice)` | `src/background/paired-devices.ts:56` |23-| Cache peer IP (optional) | `setLastKnownIp(pubkeyHex, ip)` | `src/background/paired-devices.ts:120` |24-| Build local identity | `buildLocalIdentity(masterKey): Promise<LocalChirpIdentity>` | `src/background/chirp-identity.ts:79` |25-| Vault id for `vault_id` | `computeVaultId(masterKey): Promise<string>` | `src/shared/sync-identity.ts:75` |26-| Root key (for masterKey) | `getRootKeyHex()` / `isUnlocked()` | `src/background/vault.ts` (used at `messages.ts:1324`) |27-| Plain port probe (Change 5) | `probeSubnetForPort(subnet, port, opts?)` | `src/background/desktop-pairing-probe.ts` (NEW — see `05-subnet-probe.md`) |28-| PairedDevice / DeviceType | `PairedDevice`, `DeviceType` | `src/shared/sync-types.ts:86,11` (`"desktop"` already a member) |29-30-> **Why not reuse `probeSubnetForPeer`?** It is already port-parameterized31-> (`chirp-sync-transport.ts:238`), BUT it runs a full **Noise XX** handshake32-> against an *expected long-term pubkey* per host. In code pairing we have no33-> long-term pubkey yet (we only get the helper's *ephemeral* key, after the34-> plaintext HELLO). So Change 5 supplies a lighter "is there a WebSocket35-> server on this port" probe. See `05-subnet-probe.md`.36-37-## The PEACH_HELLO / PEACH_HELLO_ACK frames (PINNED, see §6 of ground truth)38-39-```ts40-interface PeachHelloFrame { type: "PEACH_HELLO"; code: string; }41-interface PeachHelloAck { type: "PEACH_HELLO_ACK"; ephemeralPublicKeyHex: string; }42-```43-44-There is **no** error/OK frame. After sending `PEACH_HELLO`, the browser45-expects EITHER a `PEACH_HELLO_ACK` binary frame OR the socket to close.46-Socket close before an ACK ⇒ wrong/expired code ⇒ `onError("invalid_code")`.47-48-## Transport notes (from ground truth §1)49-50-- Port **49142** (pairing). The persistent sync port arrives inside the51- encrypted `PAIR_IDENTITY` as `ws_port` (helper always announces `8765`).52-- WebSocket over TCP, **binary frames only**, one Noise (or one JSON) frame53- per WS binary message. The browser `WebSocket` client masks frames54- automatically — no manual masking needed on this side.55-- Pre-handshake HELLO/ACK frames are PLAINTEXT JSON, sent as binary WS56- messages (`new TextEncoder().encode(JSON.stringify(...))`).57-58----59-60-## Apply-ready module — `src/background/desktop-pairing.ts`61-62-```ts63-// Desktop helper code-pairing client (Change 3).64-//65-// Pairs the extension with the `peach-sync` desktop helper:66-// 1. probe the /24 for a host accepting WebSocket on the pairing port67-// 2. open ws://<host>:4914268-// 3. send PEACH_HELLO { code } in PLAINTEXT69-// 4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close70-// (socket close before ACK == wrong/expired code — peach-sync sends71-// NO error frame; it silently closes and rotates the code)72-// 5. run Noise NK as INITIATOR against the helper's ephemeral key73-// 6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)74-// 7. derive the SAS, surface it for user confirmation75-// 8. on confirm -> persist the PairedDevice (deviceType "desktop"); on76-// reject -> abort without persisting77-//78-// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio79-// chirp flow (chirp-handshake.ts), where the extension is the responder.80-// The helper's `ephemeralPublicKeyHex` is the NK responder static `s`.81-82-import { noiseNKInitiator } from "../shared/noise-js";83-import { derivePairingSAS } from "../shared/chirp-handshake";84-import type { LocalIdentity } from "../shared/chirp-handshake";85-import type { DeviceType, PairedDevice } from "../shared/sync-types";86-87-/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */88-export const DESKTOP_PAIRING_PORT = 49142;89-/** Default sync port the helper announces; used when ws_port is absent. */90-const DEFAULT_SYNC_PORT = 8765;91-92-export type DesktopPairingError =93- | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)94- | "no_host_found" // no WebSocket server answered on the pairing port95- | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed96- | "sas_mismatch" // user rejected the SAS97- | "vault_locked"; // no master key available98-99-/**100- * Minimal binary WebSocket transport seam. Production wires the real101- * `WebSocket`; tests inject a fake. One binary message per frame.102- */103-export interface DesktopPairingSocket {104- send(bytes: Uint8Array): void;105- /** Resolves with the next inbound binary frame, or rejects on close. */106- receive(): Promise<Uint8Array>;107- close(): void;108- /** Resolves true if the socket closed before the next frame arrived. */109- closedBeforeFrame(): boolean;110-}111-112-export interface DesktopPairingDeps {113- /** Change 5 — find a host answering WebSocket on the pairing port. */114- findHost(port: number): Promise<string | null>;115- /** Open a binary WebSocket to ws://<ip>:<port>/ . */116- openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;117- /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */118- getLocalIdentity(): Promise<LocalIdentity>;119- /** Persist on user confirm. */120- persistDevice(device: PairedDevice): Promise<void>;121- /** Optional fast-path cache for the next sync round. */122- cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;123-}124-125-export interface PairWithDesktopCallbacks {126- /** Show the 6-digit SAS; the popup must compare against the helper's. */127- onSasReady(sas: string): void;128- /** Pairing succeeded and the device was persisted. */129- onComplete(device: PairedDevice): void;130- /** Pairing failed at some stage. */131- onError(error: DesktopPairingError): void;132-}133-134-interface PairingFrame {135- type: "PAIR_IDENTITY";136- device_id: string;137- device_name: string;138- device_type: DeviceType;139- long_term_public_key_hex: string;140- signing_public_key_hex?: string;141- ws_port?: number;142- vault_id?: string; // Change 1 — additive, optional143-}144-145-function encodeJson(obj: unknown): Uint8Array {146- return new TextEncoder().encode(JSON.stringify(obj));147-}148-149-function decodeJson<T>(bytes: Uint8Array): T {150- return JSON.parse(new TextDecoder().decode(bytes)) as T;151-}152-153-function hexToBytes(hex: string): Uint8Array {154- if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");155- const out = new Uint8Array(32);156- for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);157- return out;158-}159-160-function decodePairIdentity(bytes: Uint8Array): PairingFrame {161- const p = decodeJson<Record<string, unknown>>(bytes);162- if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");163- if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");164- if (typeof p.device_name !== "string") throw new Error("device_name missing");165- if (typeof p.device_type !== "string") throw new Error("device_type missing");166- if (typeof p.long_term_public_key_hex !== "string" ||167- !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {168- throw new Error("long_term_public_key_hex is not 32-byte hex");169- }170- return p as unknown as PairingFrame;171-}172-173-/**174- * Drive a complete desktop-helper code pairing.175- *176- * `confirm` is a function the caller resolves AFTER the user has compared177- * the SAS shown here against the SAS the helper displays. Resolving false178- * (or rejecting) aborts before any device is persisted — this is the179- * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).180- */181-export async function pairWithDesktopHelper(182- code: string,183- cb: PairWithDesktopCallbacks,184- deps: DesktopPairingDeps,185- confirm: (sas: string) => Promise<boolean>,186-): Promise<void> {187- // The helper compares the code byte-for-byte against a [6]u8. Normalise to188- // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2).189- const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);190-191- let identity: LocalIdentity;192- try {193- identity = await deps.getLocalIdentity();194- } catch {195- cb.onError("vault_locked");196- return;197- }198-199- // 1) Find a host on the pairing port.200- const host = await deps.findHost(DESKTOP_PAIRING_PORT);201- if (!host) {202- cb.onError("no_host_found");203- return;204- }205-206- // 2) Open the WebSocket.207- let sock: DesktopPairingSocket;208- try {209- sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);210- } catch {211- cb.onError("no_host_found");212- return;213- }214-215- try {216- // 3) Send PEACH_HELLO (PLAINTEXT binary frame).217- sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));218-219- // 4) Await PEACH_HELLO_ACK OR socket close.220- // peach-sync sends NO error frame; a wrong/expired code closes the221- // socket. So "receive() rejected / closed before a frame" == invalid_code.222- let ackBytes: Uint8Array;223- try {224- ackBytes = await sock.receive();225- } catch {226- cb.onError("invalid_code");227- return;228- }229- if (sock.closedBeforeFrame() || ackBytes.length === 0) {230- cb.onError("invalid_code");231- return;232- }233-234- let ephemeralPublicKeyHex: string;235- try {236- const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);237- if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {238- // A non-ACK frame on a matching code should not happen; treat the239- // missing ACK exactly like a wrong code.240- cb.onError("invalid_code");241- return;242- }243- ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;244- } catch {245- cb.onError("invalid_code");246- return;247- }248-249- // 5) Noise NK as INITIATOR against the helper's ephemeral static key.250- const responderStatic = hexToBytes(ephemeralPublicKeyHex);251- const session = await noiseNKInitiator(252- responderStatic,253- async (msg) => sock.send(msg),254- () => sock.receive(),255- );256-257- // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.258- const ourFrame: PairingFrame = {259- type: "PAIR_IDENTITY",260- device_id: identity.deviceId,261- device_name: identity.deviceName,262- device_type: identity.deviceType,263- long_term_public_key_hex: identity.longTermPublicKeyHex,264- signing_public_key_hex: identity.signingPublicKeyHex,265- ws_port: identity.wsPort,266- vault_id: identity.vaultId, // Change 1267- };268- sock.send(session.encrypt(encodeJson(ourFrame)));269-270- const theirCipher = await sock.receive();271- const theirFrame = decodePairIdentity(session.decrypt(theirCipher));272-273- // 7) Derive the SAS (REUSED — same value the helper computes from the274- // shared handshake hash) and surface it for user confirmation.275- const sas = derivePairingSAS(session.handshakeHash);276- cb.onSasReady(sas);277-278- const confirmed = await confirm(sas);279- if (!confirmed) {280- cb.onError("sas_mismatch");281- return;282- }283-284- // 8) Persist. deviceType "desktop"; ws_port from the helper frame,285- // defaulting to 8765 (the helper always announces 8765, but guard it).286- const device: PairedDevice = {287- deviceId: theirFrame.device_id,288- deviceName: theirFrame.device_name,289- deviceType: (theirFrame.device_type as DeviceType) || "desktop",290- longTermPublicKeyHex: theirFrame.long_term_public_key_hex,291- signingPublicKeyHex: theirFrame.signing_public_key_hex,292- trustSource: "direct",293- wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,294- pairedAt: Date.now(),295- };296- await deps.persistDevice(device);297- if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {298- await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);299- }300- cb.onComplete(device);301- } catch {302- cb.onError("handshake_failed");303- } finally {304- sock.close();305- }306-}307-```308-309-### Production deps factory (sketch — lives alongside or in the wiring layer)310-311-```ts312-import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";313-import { buildLocalIdentity } from "./chirp-identity";314-import { computeVaultId } from "../shared/sync-identity";315-import { getRootKeyHex, isUnlocked } from "./vault";316-import { probeSubnetForPort } from "./desktop-pairing-probe"; // Change 5317-import { getLocalSubnet } from "../shared/local-network";318-319-export function productionDesktopPairingDeps(): DesktopPairingDeps {320- return {321- async findHost(port) {322- const subnet = await getLocalSubnet();323- if (!subnet) return null;324- return probeSubnetForPort(subnet, port);325- },326- async openSocket(ip, port) {327- return openBinaryWebSocket(ip, port); // small wrapper around WebSocket328- },329- async getLocalIdentity() {330- if (!isUnlocked()) throw new Error("vault_locked");331- const masterKeyHex = getRootKeyHex();332- if (!masterKeyHex) throw new Error("vault_locked");333- const masterKey = hexToBytesLocal(masterKeyHex);334- try {335- const base = await buildLocalIdentity(masterKey); // chirp-identity.ts:79336- const vaultId = await computeVaultId(masterKey); // sync-identity.ts:75337- return { ...base, vaultId }; // LocalIdentity + Change-1 field338- } finally {339- masterKey.fill(0);340- }341- },342- persistDevice: (d) => upsertPairedDevice(d),343- cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),344- };345-}346-```347-348----349-350-## Threading note — where new state comes from (mirrors Change 1)351-352-`LocalIdentity.vaultId` (added in Change 1) is **not** populated by353-`buildLocalIdentity` (`chirp-identity.ts:79` returns no `vaultId`). The354-production deps factory above threads it in:355-356-- `masterKey` is obtained the same way `CHIRP_GET_LOCAL_IDENTITY` does it357- (`messages.ts:1324-1326`): `getRootKeyHex()` → `unhexBytes` → `masterKey.fill(0)`358- in `finally`. **Use the stable ROOT key, not the password-derived key**359- (SEV-2-B), so pairing survives a master-password change.360-- `vaultId = await computeVaultId(masterKey)` (`sync-identity.ts:75`).361-- Both are zeroed after use.362-363-**INTEGRATOR TODO:** `buildLocalIdentity` returns `LocalChirpIdentity`, whose364-shape matches `LocalIdentity` minus `vaultId`/`wsPort`. Confirm the two types365-are assignable, or add `vaultId?` to `LocalChirpIdentity` directly so the366-spread is type-clean. The extension has no `wsPort` (it is never a sync host),367-so leaving `wsPort` undefined is correct.368-369----370-371-## Wiring — popup ⇄ background372-373-The handshake needs `getUserMedia`-free crypto only, so it CAN run entirely374-in the background service worker (unlike the audio chirp flow, which must run375-in the popup). Recommended split: **the background runs the whole pairing**,376-the popup drives the UI and the SAS confirmation round-trip.377-378-### New message types (propose)379-380-| Direction | `type` | Payload | Response |381-| --- | --- | --- | --- |382-| popup → bg | `PAIR_WITH_DESKTOP` | `{ code: string }` | `{ ok: true }` (started) or `{ ok:false, error }` |383-| bg → popup | `DESKTOP_PAIR_SAS` | `{ sas: string }` | — (push via `chrome.runtime.sendMessage`) |384-| popup → bg | `DESKTOP_PAIR_CONFIRM` | `{ confirmed: boolean }` | `{ ok: true }` |385-| bg → popup | `DESKTOP_PAIR_RESULT` | `{ status:"complete", device } \| { status:"error", error }` | — (push) |386-387-### SAS / confirm round-trip388-389-Because `pairWithDesktopHelper` takes a `confirm(sas) => Promise<boolean>`390-callback that must block on the user, the background handler bridges it to a391-message round-trip:392-393-1. `PAIR_WITH_DESKTOP` handler calls `pairWithDesktopHelper(code, cb, deps, confirm)`.394-2. `cb.onSasReady(sas)` and the `confirm` callback both fire `DESKTOP_PAIR_SAS`395- to the popup and then **await a deferred Promise** that a later396- `DESKTOP_PAIR_CONFIRM` message resolves.397-3. The popup shows the SAS, the user compares it to the helper's display,398- taps confirm/reject, and the popup sends `DESKTOP_PAIR_CONFIRM { confirmed }`.399-4. The handler resolves the deferred Promise with `confirmed`, completing the400- `confirm()` call inside `pairWithDesktopHelper`.401-5. `cb.onComplete`/`cb.onError` push `DESKTOP_PAIR_RESULT` to the popup.402-403-Keep one in-flight pairing per worker (store the pending `confirm` resolver404-in a module-level variable, cleared on completion/error). This mirrors how405-`chirp-pair.ts`'s `confirmSAS(sas): Promise<boolean>` dep gates persistence406-(`chirp-pair.ts` `confirmSAS` doc, lines ~62-71), just relocated to the407-background since no microphone is involved.408-409-**INTEGRATOR TODO (handler placement):** add the three `case` blocks to the410-`switch` in `messages.ts` (alongside `CHIRP_*` at lines 1318-1384). Gate on411-`vault.isUnlocked()` and `requirePro("sync")` exactly like412-`CHIRP_GET_LOCAL_IDENTITY` (`messages.ts:1319-1321`). Validate the persisted413-device with the same shape check `CHIRP_COMPLETE_PAIRING` uses414-(`messages.ts:1344-1360`) — except here the background builds the415-`PairedDevice` itself, so the validation can be skipped (the device comes from416-our own decode path, not from the popup).417-418----419-420-## Open integration TODOs421-422-1. **`openBinaryWebSocket` helper** — a small adapter producing a423- `DesktopPairingSocket` from `WebSocket` (set `binaryType="arraybuffer"`,424- queue inbound frames, expose `closedBeforeFrame()`). Pattern already exists425- in `chirp-sync-transport.ts:58-121` (inbox/readers + onclose); extract a426- trimmed version. Critically, `closedBeforeFrame()` must distinguish "socket427- closed with no ACK" from "ACK received" — this is the `invalid_code` signal.428-2. **`LocalChirpIdentity` vs `LocalIdentity`** type reconciliation (see429- threading note).430-3. **Pro gating + lock checks** in the new `messages.ts` cases.431-4. **Single-flight guard** for the pending-confirm resolver in the background.432-5. **Timeout** — wrap `findHost`/`receive` in a ceiling (e.g. 10s probe, 5s433- per receive) so a stalled helper surfaces `no_host_found`/`handshake_failed`434- instead of hanging the popup. `connectWebSocket` in435- `chirp-sync-transport.ts:174` already shows the timeout idiom.src/drafts/DESKTOP-PAIRING/04-desktop-pairing-ui.md+0 −495
@@ -1,495 +0,0 @@1-# Change 4 — Desktop helper pairing UI2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md` and `03-desktop-pairing-handler.md`.4-> Proposed new component:5-> `/Users/june/projects/peach-browser/src/popup/components/DesktopPairingFlow.tsx`.6-> Nothing here is applied — apply-ready draft only.7-8-This is the popup-side counterpart to the background pairing client in Change 3.9-It is a **sibling of `ChirpPairingFlow.tsx`** — same file layout (a top-level10-`Component`, a small phase state machine, a SAS-confirmation gate, friendly error11-copy, an `aria-live` region) — but driven by a typed **code** the user reads off12-their computer instead of an audio chirp.13-14----15-16-## 0. Reality vs the brief (READ FIRST)17-18-The brief was written against React idioms. This repo is **Solid.js**. The draft19-below uses Solid, not React. Concrete deviations:20-21-| Brief said | Reality in this repo | What the draft uses |22-| --- | --- | --- |23-| React, `.tsx` with hooks (`useState`/`useEffect`) | Solid.js (`solid-js`) | `createSignal`, `createEffect`, `<Show>`, `onCleanup`, `Component` |24-| `OutlinedTextField` | No such component; Peach has no Material inputs | `peach-underline-input` `<input>` (the real "PeachField") |25-| `PeachField` component | **No component named `PeachField` exists** (verified — `grep -rl PeachField src` returns nothing). The underline-style field the brief means is a plain `<input class="peach-underline-input …">` | `peach-underline-input` class |26-| `@solidjs/testing-library` for tests | Repo does **not** use it. Component tests use a tiny local helper `renderView` from `src/popup/views/view-test-utils.tsx` + raw jsdom | `renderView`, `inputText`, real DOM queries |27-28-### The "PeachField" underline input — what it actually is29-30-There is no `PeachField` component. The underline-style input the brief calls for31-is a bare `<input>` carrying the `peach-underline-input` class, defined at32-`src/popup/styles/popup.css:1006`:33-34-```css35-.peach-underline-input {36- width: 100%; border: 0; border-bottom: 1px solid var(--peach-t3);37- padding: 6px 0; background: transparent; color: var(--peach-t0);38- outline: none;39- font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;40- font-size: 0.8125rem;41- caret-color: var(--peach-accent);42-}43-```44-45-It is used as a single class on a native `<input>` — see `Lock.tsx:326`,46-`Lock.tsx:452`, `Lock.tsx:487`, and `RecoveryWordEntry.tsx:297,314`. The47-big-glyph variant (`peach-unlock-input`, `popup.css:1014`) gives `font-size:22px;48-letter-spacing:0.24em`, which is the right look for a 6-digit code. The draft49-reuses `peach-underline-input` + a code-specific class `peach-code-input`.50-51-> A **single** field (not 6 auto-advancing boxes) is correct here. The repo's52-> existing 6-digit SAS surface (`ChirpSync.tsx:445-472`) renders the code as one53-> mono string, and the master-password/recovery entry uses one underline field.54-> There is **no** existing auto-advance multi-box pattern to mirror, so a single55-> `inputmode="numeric"` `maxlength=6` field matches Peach's form style and is56-> simpler. (Documented per brief.)57-58----59-60-## 1. Message-name adjustments vs file 0361-62-File 03 §"New message types" defines four messages. The brief named three63-(`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_CONFIRM`). This UI uses64-**all four from 03**, because the popup needs the terminal push to know whether65-pairing finished or failed. Reconciliation:66-67-| Brief name | 03 name | Used here | Notes |68-| --- | --- | --- | --- |69-| `PAIR_WITH_DESKTOP` (popup→bg) | `PAIR_WITH_DESKTOP` | yes | payload `{ code }`; resp `{ ok }` or `{ ok:false, error }` |70-| `DESKTOP_PAIR_SAS` (bg→popup push) | `DESKTOP_PAIR_SAS` | yes | payload `{ sas }` |71-| `DESKTOP_PAIR_CONFIRM` (popup→bg) | `DESKTOP_PAIR_CONFIRM` | yes | **payload `{ confirmed: boolean }`** — 03 uses `confirmed`, the brief wrote `{accept:true}`. The draft follows 03 (`confirmed`). |72-| — | `DESKTOP_PAIR_RESULT` (bg→push) | yes | 03-only; payload `{ status:"complete", device } \| { status:"error", error }`. The brief had no terminal push; without it the popup can't distinguish "Paired!" from a late error, so it is required. |73-74-**Net adjustment:** brief's `{accept:true}` → `{confirmed:true}` (match 03);75-brief's three messages → 03's four (add `DESKTOP_PAIR_RESULT`). Names otherwise76-identical to both 03 and the brief.77-78-### Error-code mapping (per ground truth §0.3 and 03 `DesktopPairingError`)79-80-The background's `DesktopPairingError` union (03, lines ~92-97) maps to copy:81-82-| Background error | Friendly copy |83-| --- | --- |84-| `invalid_code` | **"Code not found or expired"** |85-| `no_host_found` | "Couldn't find your computer" |86-| `handshake_failed` | "Pairing failed — try again" |87-| `sas_mismatch` | "Codes don't match" |88-| `vault_locked` | "Unlock Peach first, then pair" |89-90-> **invalid_code = "not found or expired", combined on purpose.** Ground truth91-> §0.3 / §2: peach-sync sends **no error frame** — a wrong *or* expired code both92-> surface identically as a socket close before `PEACH_HELLO_ACK`. The wire cannot93-> distinguish them, so the background collapses both to `invalid_code` and the UI94-> shows a **single** "Code not found or expired" message. The brief's separate95-> "Code not found" (invalid_code) and "Code expired" (expired) cannot be told96-> apart on this wire and are merged. If a future protocol revision adds an97-> explicit expiry frame, split the copy then.98-99----100-101-## 2. Where this slots into the Add-Device UI102-103-### Parent file and exact spot104-105-The existing pairing entry point is **`src/popup/views/ChirpSync.tsx`** — the106-Settings → Sync surface. Its `restView()` (`ChirpSync.tsx:300-426`) is the107-"rest" mode that lists paired devices and offers pairing. The single pairing CTA108-is the **"Pair a device" button block** at `ChirpSync.tsx:412-423`:109-110-```tsx111-<div class="peach-chirp-action">112- <p class="peach-chirp-mic-notice">…</p>113- <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>114- …<span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>115- </button>116-</div>117-```118-119-Today that button is the *only* "Add Device" affordance and it goes straight into120-the chirp flow. The Desktop Helper option slots in here as a **second choice121-alongside the chirp button**, inside this same `peach-chirp-action` block. Two122-ways to wire it (pick one at integration):123-124-- **(A) Two buttons.** Keep "Pair a device" (chirp) and add a second125- `peach-chirp-pair-start`-styled button labelled **"Desktop Helper (enter126- code)"** directly beneath it. Cheapest, matches the brief's "mode select127- entry point … alongside the existing Chirp option".128-- **(B) Mode select.** Replace the single button with a tiny chooser (chirp vs129- code) that sets a `pairKind` signal. More work; same UX.130-131-The draft component `DesktopPairingFlow` itself **also includes a built-in132-mode-select header** (chirp vs desktop) so it can be the whole surface if the133-integrator routes "Pair a device" into it. Either way, the **insertion point is134-`ChirpSync.tsx` `restView()` action block (lines 412-423)**, and the135-**rendering point is `ChirpSync.tsx` `pairingView()` (lines 428-474)**, which136-already does `<Show when={sasCode()} fallback={<ChirpPairingFlow .../>}>`. The137-desktop flow is rendered the same way: when the user picks Desktop Helper,138-`pairingView()` renders `<DesktopPairingFlow .../>` instead of (or in addition139-to) `<ChirpPairingFlow .../>`.140-141-### Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied)142-143-```tsx144-// new signal alongside the others (ChirpSync.tsx ~line 51)145-const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp");146-147-// in restView()'s action block (ChirpSync.tsx 412-423), add beneath the148-// existing chirp button:149-<button150- class="peach-chirp-pair-start peach-chirp-pair-start--desktop"151- onClick={() => { setPairKind("desktop"); setMode("pairing"); }}152->153- <span>Desktop Helper (enter code)</span>154-</button>155-156-// in the render switch (ChirpSync.tsx 482-493), when mode()==="pairing" and157-// pairKind()==="desktop", render <DesktopPairingFlow/> instead of the chirp view:158-<Show when={pairKind() === "desktop"} fallback={pairingView()}>159- <DesktopPairingFlow onCancel={() => setMode("rest")} onDone={() => { setMode("rest"); void refresh(); }} />160-</Show>161-```162-163-`DesktopPairingFlow` is **self-contained** for the message round-trip: it sends164-`PAIR_WITH_DESKTOP`, listens for `DESKTOP_PAIR_SAS` / `DESKTOP_PAIR_RESULT`165-pushes via `chrome.runtime.onMessage`, and replies `DESKTOP_PAIR_CONFIRM`. It166-does **not** reuse ChirpSync's `sasResolve` plumbing (that one is in-popup; this167-SAS arrives as a push from the background, per 03's split where the background168-runs the whole pairing).169-170----171-172-## 3. Popup ⇄ background message contract (must match 03)173-174-```ts175-// popup → bg : start pairing with the typed code176-{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }177-// resp: { ok: true } | { ok: false, error: DesktopPairingError }178-179-// bg → popup (push) : SAS ready, show confirm gate180-{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }181-182-// popup → bg : user accepted/rejected the SAS183-{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }184-// resp: { ok: true }185-186-// bg → popup (push) : terminal result187-{ type: "DESKTOP_PAIR_RESULT",188- payload: { status: "complete", device: PairedDevice }189- | { status: "error", error: DesktopPairingError } }190-```191-192-`sendMessage` is the existing bridge (`src/shared/bridge.ts:13`); pushes are193-received with `chrome.runtime.onMessage.addListener` exactly as `App.tsx:601`194-does. `DesktopPairingError` is defined in 03 (`desktop-pairing.ts`).195-196----197-198-## 4. Apply-ready component — `src/popup/components/DesktopPairingFlow.tsx`199-200-```tsx201-// DesktopPairingFlow — the desktop-helper *code* pairing screen.202-//203-// Sibling of ChirpPairingFlow.tsx, but driven by a 6-digit code the user204-// reads off their computer rather than an audio chirp. The whole pairing205-// runs in the BACKGROUND (03); this component:206-// 1. takes a 6-digit code in a single underline field (the "PeachField"),207-// 2. on Connect -> sends PAIR_WITH_DESKTOP, shows "Searching for helper…",208-// 3. on the DESKTOP_PAIR_SAS push -> shows the SAS + Confirm / Cancel,209-// 4. on Confirm -> DESKTOP_PAIR_CONFIRM { confirmed:true } -> "Paired!",210-// on Cancel -> DESKTOP_PAIR_CONFIRM { confirmed:false } -> error,211-// 5. maps background errors (DESKTOP_PAIR_RESULT) to friendly copy.212-//213-// Wire contract: _GROUND-TRUTH.md §6 + 03-desktop-pairing-handler.md.214-215-import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";216-import { sendMessage } from "../../shared/bridge";217-import type { PairedDevice } from "../../shared/sync-types";218-219-// Mirrors DesktopPairingError in 03 (src/background/desktop-pairing.ts).220-type DesktopPairingError =221- | "invalid_code"222- | "no_host_found"223- | "handshake_failed"224- | "sas_mismatch"225- | "vault_locked";226-227-// Local UI phase machine (the analogue of PairingPhase for the chirp flow).228-type DesktopPhase =229- | "enter" // user is typing the code230- | "searching" // PAIR_WITH_DESKTOP sent, awaiting SAS/result231- | "confirm" // DESKTOP_PAIR_SAS received, awaiting user confirm232- | "paired" // DESKTOP_PAIR_RESULT status:complete233- | "error"; // DESKTOP_PAIR_RESULT status:error (or a start failure)234-235-export interface DesktopPairingFlowProps {236- /** Back/cancel out of the desktop flow (return to the device list). */237- onCancel: () => void;238- /** Pairing finished and the device was persisted; dismiss the surface. */239- onDone: (device?: PairedDevice) => void;240-}241-242-const PAIRED_DISMISS_MS = 2_000;243-const CODE_LEN = 6;244-245-function errorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {246- switch (error) {247- case "invalid_code":248- // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.249- return { headline: "Code not found or expired", sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while." };250- case "no_host_found":251- return { headline: "Couldn't find your computer", sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi." };252- case "sas_mismatch":253- return { headline: "Codes don't match", sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully." };254- case "vault_locked":255- return { headline: "Unlock Peach first", sub: "Your vault needs to be unlocked before you can pair a device." };256- case "handshake_failed":257- default:258- return { headline: "Pairing failed", sub: "Something went wrong while connecting. Please try again." };259- }260-}261-262-export const DesktopPairingFlow: Component<DesktopPairingFlowProps> = (props) => {263- const [phase, setPhase] = createSignal<DesktopPhase>("enter");264- const [code, setCode] = createSignal("");265- const [sas, setSas] = createSignal<string | null>(null);266- const [error, setError] = createSignal<DesktopPairingError | "unknown">("unknown");267- const [device, setDevice] = createSignal<PairedDevice | undefined>(undefined);268- const [busy, setBusy] = createSignal(false);269-270- const codeReady = () => /^\d{6}$/.test(code());271-272- // Listen for the two background pushes (mirrors App.tsx:601 idiom).273- onMount(() => {274- const handler = (msg: { type?: string; payload?: unknown }) => {275- if (msg?.type === "DESKTOP_PAIR_SAS") {276- const p = msg.payload as { sas?: string } | undefined;277- if (typeof p?.sas === "string") { setSas(p.sas); setPhase("confirm"); }278- } else if (msg?.type === "DESKTOP_PAIR_RESULT") {279- const p = msg.payload as280- | { status: "complete"; device: PairedDevice }281- | { status: "error"; error: DesktopPairingError }282- | undefined;283- if (p?.status === "complete") { setDevice(p.device); setPhase("paired"); }284- else if (p?.status === "error") { setError(p.error); setPhase("error"); }285- }286- };287- chrome.runtime.onMessage.addListener(handler);288- onCleanup(() => chrome.runtime.onMessage.removeListener(handler));289- });290-291- // Auto-dismiss after the paired state settles, like ChirpPairingFlow.292- createEffect(() => {293- if (phase() !== "paired") return;294- const t = setTimeout(() => props.onDone(device()), PAIRED_DISMISS_MS);295- onCleanup(() => clearTimeout(t));296- });297-298- async function handleConnect(): Promise<void> {299- if (!codeReady() || busy()) return;300- setBusy(true);301- setPhase("searching");302- try {303- const res = (await sendMessage({304- type: "PAIR_WITH_DESKTOP",305- payload: { code: code() },306- })) as { ok: boolean; error?: DesktopPairingError };307- // A synchronous failure (e.g. vault_locked) comes back here; the SAS and308- // terminal result arrive later as pushes.309- if (!res.ok) { setError(res.error ?? "unknown"); setPhase("error"); }310- } catch {311- setError("unknown");312- setPhase("error");313- } finally {314- setBusy(false);315- }316- }317-318- async function respondConfirm(confirmed: boolean): Promise<void> {319- if (busy()) return;320- setBusy(true);321- // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the322- // final phase (paired vs sas_mismatch error).323- if (confirmed) setPhase("searching"); else { setError("sas_mismatch"); setPhase("error"); }324- try {325- await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });326- } finally {327- setBusy(false);328- }329- }330-331- function reset(): void {332- setPhase("enter");333- setSas(null);334- setDevice(undefined);335- setError("unknown");336- }337-338- function onCodeInput(value: string): void {339- setCode(value.replace(/\D/g, "").slice(0, CODE_LEN));340- }341-342- return (343- <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a desktop helper">344- <header class="peach-chirp-head">345- <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onCancel()}>346- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">347- <path d="M10 3 L5 8 L10 13" />348- </svg>349- </button>350- <div class="peach-chirp-title">Desktop Helper</div>351- <div class="peach-chirp-head-spacer" />352- </header>353-354- {/* ---- enter: 6-digit code field ---- */}355- <Show when={phase() === "enter"}>356- <div class="peach-desktop-enter">357- <div class="peach-chirp-headline">Enter the code from your computer</div>358- <div class="peach-chirp-sub">359- Open the Peach helper on your computer and type the 6-digit code it shows.360- </div>361- <label class="peach-label" for="desktop-pair-code">pairing code</label>362- <input363- id="desktop-pair-code"364- class="peach-underline-input peach-unlock-input peach-code-input"365- type="text"366- inputmode="numeric"367- autocomplete="one-time-code"368- maxlength={CODE_LEN}369- placeholder="000000"370- aria-label="6-digit pairing code"371- value={code()}372- ref={(el) => setTimeout(() => el.focus(), 80)}373- onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}374- onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}375- />376- <div class="peach-chirp-action">377- <button class="peach-chirp-cancel peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>378- Cancel379- </button>380- <button381- class="peach-btn peach-btn-primary peach-desktop-connect"382- disabled={!codeReady() || busy()}383- onClick={() => void handleConnect()}384- >385- Connect386- </button>387- </div>388- </div>389- </Show>390-391- {/* ---- searching ---- */}392- <Show when={phase() === "searching"}>393- <div class="peach-desktop-searching">394- <div class="peach-chirp-headline">Searching for helper…</div>395- <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>396- <div class="peach-chirp-action-handshake">397- <div class="peach-chirp-meter"><span class="peach-chirp-meter-bar" /></div>398- <span class="peach-chirp-meter-label">connecting · noise NK</span>399- </div>400- </div>401- </Show>402-403- {/* ---- confirm: SAS gate ---- */}404- <Show when={phase() === "confirm"}>405- <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px 18px;text-align:center;">406- <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>407- <div408- class="peach-desktop-sas"409- style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"410- >411- {sas()}412- </div>413- <div class="peach-chirp-sub" style="max-width:240px;">414- Only continue if this 6-digit code is identical on both screens.415- </div>416- <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">417- <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>418- Confirm419- </button>420- <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>421- Cancel422- </button>423- </div>424- </div>425- </Show>426-427- {/* ---- paired ---- */}428- <Show when={phase() === "paired"}>429- <div class="peach-desktop-paired">430- <div class="peach-chirp-headline">Paired!</div>431- <div class="peach-chirp-sub">432- {device()?.deviceName ? `Connected to ${device()!.deviceName}` : "Connected to your computer"}433- </div>434- <div class="peach-chirp-action">435- <button class="peach-btn peach-btn-primary" onClick={() => props.onDone(device())}>Done</button>436- </div>437- </div>438- </Show>439-440- {/* ---- error ---- */}441- <Show when={phase() === "error"}>442- <div class="peach-desktop-error">443- <div class="peach-chirp-headline">{errorCopy(error()).headline}</div>444- <div class="peach-chirp-sub">{errorCopy(error()).sub}</div>445- <div class="peach-chirp-action">446- <button class="peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>Cancel</button>447- <button class="peach-btn peach-btn-primary" onClick={() => reset()}>Try again</button>448- </div>449- </div>450- </Show>451-452- {/* Polite live region so phase changes reach assistive tech. */}453- <div class="peach-sr-only" aria-live="polite">{liveText(phase(), error())}</div>454- </div>455- );456-};457-458-function liveText(phase: DesktopPhase, error: DesktopPairingError | "unknown"): string {459- switch (phase) {460- case "enter": return "Enter the pairing code from your computer";461- case "searching": return "Searching for the helper";462- case "confirm": return "Confirm the code matches on your computer";463- case "paired": return "Paired";464- case "error": return errorCopy(error).headline;465- }466-}467-```468-469-> Reused class hooks (`peach-chirp-pair`, `peach-chirp-head`, `peach-chirp-back`,470-> `peach-chirp-headline`, `peach-chirp-sub`, `peach-chirp-action`,471-> `peach-chirp-meter*`, `peach-btn*`, `peach-sr-only`, `peach-underline-input`,472-> `peach-unlock-input`, `peach-label`) all already exist in `popup.css`. New473-> hooks (`peach-desktop-pair`, `peach-code-input`, `peach-desktop-*`) are474-> additive and need a small CSS block at integration (out of scope for this475-> draft — drafts touch only this folder).476-477----478-479-## 5. Integration TODOs (NOT applied — flagged for the integrator)480-481-1. **Add the three message cases to `messages.ts`** per 03 §"Wiring" (the482- background side). This component assumes those handlers exist.483-2. **Insert the Desktop Helper affordance** into `ChirpSync.tsx` `restView()`484- action block (lines 412-423) and render `<DesktopPairingFlow/>` from485- `pairingView()` — see §2.486-3. **CSS** for the new `peach-desktop-*` / `peach-code-input` hooks in487- `popup.css` (inline styles cover the load-bearing SAS/confirm layout in the488- meantime, matching ChirpSync's inline-styled SAS gate at lines 445-472).489-4. **Pro gate + lock check.** ChirpSync already gates on `props.isPro`; the490- desktop button should live behind the same gate, and `vault_locked` from the491- background is surfaced as friendly copy here.492-5. **Single in-flight pairing.** 03 keeps one pairing per worker; if the user493- backs out mid-flow (`onCancel`), the integrator may want a494- `DESKTOP_PAIR_CONFIRM { confirmed:false }` or a cancel message to release the495- background resolver. Left as a TODO since 03 owns that lifecycle.src/drafts/DESKTOP-PAIRING/05-subnet-probe.md+0 −159
@@ -1,159 +0,0 @@1-# Change 5 — Subnet probe for the pairing port2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module:4-> `/Users/june/projects/peach-browser/src/background/desktop-pairing-probe.ts`.5-> Consumed by Change 3 (`findHost`).6-7-## Verdict: the EXISTING probe cannot be reused as-is8-9-There IS an existing /24 probe, and its **port is already parameterized**:10-11-```ts12-// src/shared/chirp-sync-transport.ts:23813-export async function probeSubnetForPeer(14- subnet: string, // e.g. "192.168.1"15- port: number, // ← already a parameter (used at lines 256, 261-263)16- ourLongTermPrivateKey: Uint8Array,17- ourLongTermPublicKey: Uint8Array,18- expectedRemotePubkeyHex: string,19- options?: { batch?; timeoutMs?; cooldownMs?; now? },20-): Promise<SyncEndpoint | null>21-```22-23-So "just call it with 49142" is tempting — but it is **wrong for pairing**:24-25-1. `probeSubnetForPeer` runs a **full Noise XX handshake** against an26- *expected long-term static key* per host (`probeOne` →27- `openChirpSyncSession`, `chirp-sync-transport.ts:272-292, 51-156`). It only28- returns a host that completes Noise XX with `expectedRemotePubkeyHex`.29-2. During **code pairing we have no long-term pubkey yet** — we only obtain30- the helper's *ephemeral* key, and only AFTER the plaintext `PEACH_HELLO` /31- `PEACH_HELLO_ACK` exchange (ground truth §2-3). The helper on port 4914232- speaks the HELLO protocol, **not** Noise XX. `openChirpSyncSession` would33- send a Noise XX msg1 the pairing server never expects and fail every host.34-3. `probeSubnetForPeer` is also rate-limited per-pubkey35- (`subnetProbeOnCooldown`, `chirp-sync-transport.ts:210-217`); there is no36- pubkey to key the cooldown on during pairing.37-38-Therefore Change 5 supplies a **lighter, handshake-free port probe**: "is39-there a WebSocket server answering on this port?" — nothing more. The HELLO40-handshake then happens against the single found host in Change 3.41-42-## Design43-44-A plain `/24` scan that returns the first IP whose `ws://<ip>:<port>/`45-WebSocket reaches `onopen` within a tight timeout, then immediately closes it.46-No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of47-`probeSubnetForPeer` (batches of 16, ~500ms timeout) so worst-case scan time48-stays ~comparable.49-50-> NOTE: a bare port probe is intentionally undiscriminating — it returns the51-> FIRST host with an open WebSocket on 49142, which during an active52-> `peach-sync pair` session is the helper. The security boundary is NOT the53-> probe: it is (a) the 6-digit code in `PEACH_HELLO` (a non-matching host54-> closes the socket) and (b) the user-confirmed SAS. The probe only needs to55-> find a candidate; it does not need to authenticate it.56-57----58-59-## Apply-ready module — `src/background/desktop-pairing-probe.ts`60-61-```ts62-// Handshake-free /24 port probe for desktop pairing (Change 5).63-//64-// Unlike `probeSubnetForPeer` (chirp-sync-transport.ts), this does NOT run65-// Noise — during code pairing we have no long-term pubkey, and the pairing66-// server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise67-// XX. This probe answers only "is a WebSocket server reachable here?"; the68-// 6-digit code + user-confirmed SAS are the actual trust boundary.69-70-const DEFAULT_PROBE_TIMEOUT_MS = 500;71-const DEFAULT_PROBE_BATCH = 16;72-73-export interface PortProbeOptions {74- batch?: number;75- timeoutMs?: number;76- /** Injection seam for tests — defaults to the real WebSocket open check. */77- probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;78-}79-80-/**81- * Scan `subnet` (e.g. "192.168.1") for the first host accepting a WebSocket82- * connection on `port`. Returns the IP string, or null if none answered.83- */84-export async function probeSubnetForPort(85- subnet: string,86- port: number,87- options: PortProbeOptions = {},88-): Promise<string | null> {89- const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;90- const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;91- const probe = options.probeOne ?? wsPortOpen;92-93- for (let start = 1; start <= 254; start += batchSize) {94- const end = Math.min(start + batchSize - 1, 254);95- const ips: string[] = [];96- for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);97-98- const results = await Promise.all(99- ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),100- );101- const hit = results.find((r): r is string => r !== null);102- if (hit) return hit;103- }104- return null;105-}106-107-/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */108-function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {109- return new Promise((resolve) => {110- let ws: WebSocket;111- let settled = false;112- const done = (ok: boolean) => {113- if (settled) return;114- settled = true;115- try { ws.close(); } catch { /* ignore */ }116- resolve(ok);117- };118- try {119- ws = new WebSocket(`ws://${ip}:${port}/`);120- } catch {121- resolve(false);122- return;123- }124- const t = setTimeout(() => done(false), timeoutMs);125- ws.onopen = () => { clearTimeout(t); done(true); };126- ws.onerror = () => { clearTimeout(t); done(false); };127- ws.onclose = () => { clearTimeout(t); done(false); };128- });129-}130-```131-132-## Why a new module rather than extracting from `chirp-sync-transport.ts`133-134-Extracting a shared scan core from `probeSubnetForPeer` would force the Noise135-flow and the pairing flow to share a helper whose only common part is the136-nested batch loop (~10 lines). The per-host predicate differs fundamentally137-(full Noise XX vs bare WS open), as does the cooldown handling. A standalone138-module keeps the pairing path free of the sync transport's pubkey/cooldown139-machinery and avoids touching the load-bearing, already-tested sync probe.140-**No edit to `chirp-sync-transport.ts` is required.**141-142-## Call sites143-144-- **Only** new caller: Change 3's `productionDesktopPairingDeps().findHost`145- (`03-desktop-pairing-handler.md`), which calls146- `probeSubnetForPort(subnet, 49142)` with `subnet` from `getLocalSubnet()`147- (`src/shared/local-network.ts`, already used at `chirp-sync-runner.ts:69`).148-- No existing call site changes.149-150-## Integration TODO151-152-1. Consider a hard ceiling on total scan time (the audio-chirp flow uses a153- 90s chirp window; a pairing probe should resolve much faster — a full /24154- at 16-wide batches of 500ms is ~8s worst case, acceptable).155-2. The probe finds the FIRST open WS on the port. If multiple hosts answer156- (unlikely on a home LAN during an active pairing), only the first is tried;157- the wrong-code silent-close in Change 3 then surfaces as `invalid_code`.158- A future enhancement could try each candidate in turn until one ACKs —159- out of scope for this draft.src/drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md+0 −376
@@ -1,376 +0,0 @@1-# Change 6 — Include `"desktop"` peers in sync sessions2-3-> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Target file (apply-ready patch):4-> `/Users/june/projects/peach-browser/src/background/sync-trigger.ts`.5-> Nothing here is applied — it is an apply-ready draft.6-7-## VERDICT: NOT a pure no-op. One small, additive host-filter widening is required.8-9-The brief assumed the sync runner/coordinator might branch on device type and10-exclude `"desktop"`. The **runner and coordinator are in fact generic** — they11-never look at `deviceType` — BUT the **dispatcher's host filter12-(`sync-trigger.ts:104-108`) hard-codes `"android" | "ios"` and silently drops a13-`"desktop"` peer.** So a desktop-paired device would be persisted correctly and14-would sync correctly *if reached*, but `triggerSyncIfReachable` never hands it to15-the runner. **Change 6 = add `"desktop"` to that one filter.** Everything else is16-verification-only.17-18-### Deciding citation19-20-```ts21-// src/background/sync-trigger.ts:103-10822-const devices = await listPairedDevices();23-const hosts = devices.filter(24- (d) =>25- (d.deviceType === "android" || d.deviceType === "ios") && // ← desktop excluded26- d.trustSource === "direct",27-);28-```29-30-`"desktop"` is not in the disjunction, so a desktop peer is filtered out before31-`runSyncRounds` (`sync-trigger.ts:128`) ever iterates. This is the **only**32-device-type gate on the sync path.33-34----35-36-## What is already generic (verification-only — no change)37-38-### 1. The runner iterates whatever hosts it is given, with no type gate39-40-`runSyncRounds` simply loops the filtered hosts and calls the runner per host41-(`sync-trigger.ts:128-144`). It reads only `host.deviceId` and passes the whole42-`PairedDevice` to `state.runner(host)`. No `deviceType` reference.43-44-### 2. `productionSyncRunner` / `runWithDeps` are type-agnostic45-46-`runWithDeps` (`chirp-sync-runner.ts:80-127`) needs only:47-- `device.longTermPublicKeyHex` (`:85,88`) — the desktop helper supplies this in48- its `PAIR_IDENTITY` (`_GROUND-TRUTH.md` §4), persisted by Change 3.49-- `device.wsPort ?? DEFAULT_PORT` where `DEFAULT_PORT = 8765`50- (`chirp-sync-runner.ts:40,89`). The helper announces `ws_port: 8765`51- (`peach-sync/src/pairing.zig:179`); Change 3 persists that, defaulting to52- `8765` if absent (`03-desktop-pairing-handler.md` hunk 8). **The runner's53- default and the helper's announced port are the same 8765.**54-55-There is **no** `deviceType` branch anywhere in `chirp-sync-runner.ts`.56-57-### 3. The coordinator never inspects device type58-59-`runChirpSyncInitiator` (`chirp-sync-coordinator.ts:88-244`) is driven entirely60-by the `ChirpSyncVaultAdapter` and the wire protocol. It contains zero references61-to `deviceType` or to any peer identity beyond the `vaultId`/`deviceId` exchanged62-in `SYNC_HELLO`.63-64----65-66-## Exact connection path a desktop peer takes (post-pairing, for SYNC)67-68-This is **phase 2 / sync** — distinct from pairing. The handshake used here is69-**Noise XX over the persistent sync port**, NOT the Noise NK / port-49142 path70-used during pairing.71-72-1. `triggerSyncIfReachable` → filter includes the desktop host (after this73- change) → `runSyncRounds` → `state.runner(host)` → `productionSyncRunner`74- → `runWithDeps` (`chirp-sync-runner.ts:73-127`).75-2. `port = device.wsPort ?? 8765` (`chirp-sync-runner.ts:89`).76-3. `connect()` (`chirp-sync-runner.ts:129-173`):77- - **fast path:** `getLastKnownIp(longTermPubkeyHex)` → if cached, open a78- session straight to `ws://<cachedIp>:8765` (`:136-145`). Change 3 already79- primes this cache via `setLastKnownIp` on a successful pairing80- (`03-desktop-pairing-handler.md` `cacheLastKnownIp`), so the first sync can81- skip the probe.82- - **fallback:** `probeSubnetForPeer(subnet, 8765, …)` over the /2483- (`:156-162`) — this is the **Noise-XX** probe (`chirp-sync-transport.ts`),84- authenticating each host against `expectedRemoteHex =85- device.longTermPublicKeyHex`. (Note: this is the sync-transport probe, NOT86- the handshake-free pairing probe from Change 5 — different phase, different87- port, different handshake.)88- - on success, `setLastKnownIp(...)` caches the IP (`:171`).89-4. `openChirpSyncSession(endpoint, ourPriv, ourPub, expectedRemoteHex)` runs90- **Noise XX** and verifies the responder's static key equals91- `expectedRemoteHex` (`chirp-sync-runner.ts:64-66,139-144,165-170`).92-5. `runChirpSyncInitiator(session, adapter)` drives `SYNC_HELLO → … → SYNC_DONE`.93-94-### Which handshake for which phase (pinned)95-96-| Phase | Port | Handshake | Browser role | Citation |97-| --- | --- | --- | --- | --- |98-| **Pairing** | 49142 | Noise **NK** | initiator | `_GROUND-TRUTH.md` §3; `pairing.zig:153-154` |99-| **Sync** | 8765 | Noise **XX** | initiator (WS client) | `chirp-sync-runner.ts:5,64-66`; `chirp-sync-coordinator.ts:88-90` |100-101-Verified: SYNC uses **Noise XX** (mutual static-key auth, both ends know each102-other's long-term key from pairing), NOT NK. The runner header documents "Noise103-XX session" (`chirp-sync-runner.ts:3`).104-105----106-107-## `vault_id` flows correctly into SYNC_HELLO validation108-109-End-to-end chain, verified across both repos:110-111-1. **Pairing (Change 1):** the browser sends `PAIR_IDENTITY.vault_id =112- computeVaultId(masterKey)` — 64-char lowercase hex113- (`sync-identity.ts:75-82`; `01-vault-id-in-pair-identity.md`).114-2. **Helper persists it:** `peach-sync/src/pairing.zig:212` stores the115- browser-sent `vault_id`; `saveVaultId` writes `{ "vaultId": vault_id }` to116- disk (`pairing.zig:241-244`). On the helper's next start, `main.zig:63`117- reads it back and passes it as `opts.vault_id` into the sync coordinator118- (`main.zig:80`).119-3. **Sync HELLO compare:** the browser sends120- `SYNC_HELLO { vaultId: await vault.computeVaultId(), … }`121- (`chirp-sync-coordinator.ts:102,107-113`). The helper compares it122- byte-for-byte against its stored value:123- ```zig124- // peach-sync/src/sync_coordinator.zig:141125- if (!std.mem.eql(u8, parsed.value.vaultId, opts.vault_id)) {126- sendError(... "vault_id_mismatch") ...;127- }128- ```129- The helper's `HelloMsg.vaultId` field is **camelCase** (`sync_coordinator.zig:59`),130- matching the browser's `SYNC_HELLO` JSON key `vaultId`131- (`chirp-sync-coordinator.ts:108`).132-4. **Browser-side check too:** the browser independently rejects a mismatched133- peer HELLO (`chirp-sync-coordinator.ts:118-122`).134-135-**Both sides compute the SAME `vaultId` for the same vault** because both call136-`computeVaultId(masterKey)` over the identical master key material — the browser137-sent the value at pairing, the helper stored exactly those bytes, and the138-browser recomputes the identical value at sync time. **They match by139-construction.** No transformation, no case folding, no truncation differs140-between the pairing value and the SYNC_HELLO value (both are the full 64-char141-lowercase-hex BLAKE3 digest).142-143-> Subtlety worth noting for the integrator: the browser's `SYNC_HELLO.vaultId`144-> is `computeVaultId(masterKey)` recomputed at sync time, NOT a value read back145-> from the paired-device record. The paired-device record stores the *peer's*146-> identity, not our vault id. So the match relies on the same master key being147-> present at both pairing time and sync time — which holds for the local vault.148-> The desktop helper, conversely, replays the value we GAVE it. The equality is149-> therefore "our recomputed id == the id we previously sent the helper" — stable150-> as long as `computeVaultId` is deterministic over the root key (it is —151-> `sync-identity.ts:75-82`, SEV-2-B uses the stable root key).152-153----154-155-## The patch (apply to `src/background/sync-trigger.ts`)156-157-### Hunk — widen the host filter to include `"desktop"`158-Context: lines 103-108.159-```diff160- const devices = await listPairedDevices();161- const hosts = devices.filter(162- (d) =>163-- (d.deviceType === "android" || d.deviceType === "ios") &&164-+ (d.deviceType === "android" ||165-+ d.deviceType === "ios" ||166-+ d.deviceType === "desktop") &&167- d.trustSource === "direct",168- );169-```170-171-That is the entire functional change for Change 6.172-173-### Why `trustSource === "direct"` still holds for desktop174-Change 3 persists the desktop device with `trustSource: "direct"`175-(`03-desktop-pairing-handler.md`, the `PairedDevice` built in step 8 sets176-`trustSource: "direct"`). So the desktop peer passes the second predicate177-unchanged — no further edit needed.178-179-### Why `"extension"` stays excluded (and SHOULD)180-The filter still omits `"extension"`: two browser extensions do not run a sync181-*server* (no `wsPort`, never a host). This change adds only `"desktop"`, the182-third device type that runs a listening sync server (port 8765), alongside the183-two mobile types. This matches the existing intent: the filter selects184-*reachable sync hosts*, and desktop is one.185-186----187-188-## Does any EXISTING sync-trigger test break? — NO.189-190-`src/background/sync-trigger.test.ts` uses three fixtures: `ANDROID`, `IOS`191-(`:44-58`) and `EXT` (extension, `:61-67`). **There is no `desktop` fixture.**192-The two assertions that pin the filter are:193-194-- `:75-81` — "does not call the runner when no mobile hosts are paired": pairs195- only `EXT`, asserts the runner is NOT called. Adding `"desktop"` to the filter196- does not change this (EXT is still excluded; no desktop device present).197-- `:83-94` — "calls the runner once per mobile host": pairs `ANDROID`, `IOS`,198- `EXT` and asserts `seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId])`.199- Adding `"desktop"` to the filter does not change this either — EXT remains200- excluded and no desktop device is present, so the seen set is unchanged.201-202-**Conclusion: the filter widening is additive and breaks no existing test.** The203-new behaviour is covered by the new spec below, which adds a `desktop` fixture.204-205----206-207-## Draft test file — `tests/06-sync-coordinator-desktop.test.ts`208-209-Two specs, matching the repo's `sync-trigger.test.ts` idiom (the injected-runner210-seam via `setSyncRunner`, `upsertPairedDevice`, the storage stub). On apply this211-relocates to `src/background/sync-trigger.desktop.test.ts` (or is merged into212-`sync-trigger.test.ts`); the `../../` import prefixes become `../` then.213-214-```ts215-// Tests for Change 6 — desktop peers participate in sync sessions.216-//217-// DRAFT / apply-ready. On apply, relocate to218-// src/background/sync-trigger.desktop.test.ts219-// and change the "../../background/..." / "../../shared/..." prefixes to220-// "./..." / "../shared/...". The fixtures and idiom mirror the existing221-// sync-trigger.test.ts (injected runner via setSyncRunner, storage stub).222-//223-// Spec 1 asserts the host filter (sync-trigger.ts:104) includes "desktop".224-// Spec 2 asserts the browser's SYNC_HELLO carries a vaultId equal to the225-// desktop helper's stored vault_id (== computeVaultId of the same master key),226-// which is what peach-sync compares byte-for-byte (sync_coordinator.zig:141).227-228-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";229-230-import {231- triggerSyncIfReachable,232- setSyncRunner,233- _resetSyncTriggerForTest,234-} from "../../background/sync-trigger";235-import {236- upsertPairedDevice,237- _resetPairedDevicesForTest,238-} from "../../background/paired-devices";239-import { computeVaultId } from "../../shared/sync-identity";240-import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "../../background/chirp-sync-coordinator";241-import type { ChirpSyncSession } from "../../shared/chirp-sync-transport";242-import type { ChirpSyncMessage } from "../../shared/sync-types";243-import type { PairedDevice } from "../../shared/sync-types";244-245-// NOTE (integrator): sync-trigger.test.ts already exports a makeStorageStub()246-// helper inline. Reuse it (or its chrome.storage.local fake) here rather than247-// re-declaring; reproduced minimally below so this draft is self-contained.248-function makeStorageStub(): void {249- const store: Record<string, unknown> = {};250- (globalThis as unknown as { chrome: unknown }).chrome = {251- storage: {252- local: {253- get: async (keys?: string | string[]) => {254- if (!keys) return { ...store };255- const list = Array.isArray(keys) ? keys : [keys];256- const out: Record<string, unknown> = {};257- for (const k of list) if (k in store) out[k] = store[k];258- return out;259- },260- set: async (obj: Record<string, unknown>) => { Object.assign(store, obj); },261- remove: async (keys: string | string[]) => {262- for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];263- },264- },265- },266- runtime: { lastError: undefined },267- };268-}269-270-const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));271-272-const DESKTOP: PairedDevice = {273- deviceId: "desktop-001",274- deviceName: "June's iMac",275- deviceType: "desktop",276- longTermPublicKeyHex: "a".repeat(64),277- trustSource: "direct",278- wsPort: 8765,279- pairedAt: 1700000000000,280-};281-282-beforeEach(() => {283- makeStorageStub();284- _resetSyncTriggerForTest();285- _resetPairedDevicesForTest?.();286-});287-288-afterEach(() => {289- vi.restoreAllMocks();290-});291-292-describe("Change 6 — desktop peers in sync", () => {293- it("desktop peer is included in sync session attempts", async () => {294- await upsertPairedDevice(DESKTOP);295- const seen: string[] = [];296- setSyncRunner(async (device) => {297- seen.push(device.deviceId);298- return { ok: true, entriesReceived: 0, entriesSent: 0 };299- });300-301- await triggerSyncIfReachable({ reason: "manual" });302-303- // Before Change 6, the filter (sync-trigger.ts:104) only matched304- // android|ios, so the runner was never called for a desktop host.305- expect(seen).toEqual([DESKTOP.deviceId]);306- });307-308- it("SYNC_HELLO vaultId matches the desktop helper's stored vault_id", async () => {309- // The helper stores exactly the vault_id the browser sent at pairing310- // (peach-sync pairing.zig:212), which is computeVaultId(masterKey). At311- // sync time the browser sends SYNC_HELLO { vaultId: computeVaultId(...) }.312- // peach-sync compares the two byte-for-byte (sync_coordinator.zig:141).313- // Here we capture the browser's outbound SYNC_HELLO and assert its vaultId314- // equals the canonical computeVaultId(MASTER) — i.e. the value the helper315- // holds.316- const ourVaultId = await computeVaultId(MASTER);317-318- // Capture frames the coordinator sends; reply just enough to reach the319- // equal-version SYNC_DONE shortcut so the round completes cleanly.320- const sent: ChirpSyncMessage[] = [];321- const inbound: ChirpSyncMessage[] = [322- { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: "desktop-001", version: 7, deviceTime: Date.now() },323- { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 },324- ];325- let rx = 0;326- const session: ChirpSyncSession = {327- send: (bytes: Uint8Array) => { sent.push(JSON.parse(new TextDecoder().decode(bytes))); },328- receive: async () => new TextEncoder().encode(JSON.stringify(inbound[rx++])),329- close: async () => {},330- } as unknown as ChirpSyncSession;331-332- const adapter: ChirpSyncVaultAdapter = {333- computeVaultId: async () => ourVaultId,334- getOurDeviceId: async () => "ext-local",335- getLocalVersion: async () => 7, // equal version → SYNC_DONE shortcut336- buildEntryManifest: async () => ({}),337- buildTombstoneManifest: async () => ({}),338- getEncryptedEntries: async () => ({}),339- applyEntries: async () => {},340- applyTombstones: async () => {},341- };342-343- const outcome = await runChirpSyncInitiator(session, adapter);344-345- const hello = sent.find((m) => m.type === "SYNC_HELLO") as346- | { type: "SYNC_HELLO"; vaultId: string }347- | undefined;348- expect(hello).toBeTruthy();349- // This is the exact value peach-sync compares against opts.vault_id.350- expect(hello!.vaultId).toBe(ourVaultId);351- expect(hello!.vaultId).toMatch(/^[0-9a-f]{64}$/);352- expect(outcome.status).toBe("completed");353- });354-});355-```356-357-> **INTEGRATOR TODO (test wiring):**358-> 1. `paired-devices.ts` may not export `_resetPairedDevicesForTest`; the359-> existing `sync-trigger.test.ts` resets via `makeStorageStub()` re-init each360-> `beforeEach`. Drop the optional reset call if the symbol does not exist.361-> 2. Reuse the real `makeStorageStub` from `sync-trigger.test.ts` instead of the362-> inline copy when merging into that file.363-> 3. `ChirpSyncSession` is a class/interface from `chirp-sync-transport.ts`; the364-> cast above stubs only the three methods the coordinator uses365-> (`send`/`receive`/`close`). Confirm those are the only members touched366-> (verified against `chirp-sync-coordinator.ts:269-289`).367-368----369-370-## Open integration TODOs (Change 6)371-372-1. The one-line filter widening above — the only functional change.373-2. Confirm `paired-devices` test-reset export name when wiring the spec.374-3. (Optional) a CONTINUITY note that desktop now participates in the same375- 30s-timeout sync round (`chirp-sync-coordinator.ts:25`) as mobile — no376- per-type timeout tuning is introduced.src/drafts/DESKTOP-PAIRING/INTEGRATION-CONTRACT.md+0 −283
@@ -1,283 +0,0 @@1-# Desktop Helper Pairing — Integration Contract2-3-> **AUTHORITATIVE.** The single document an integrator follows to apply the4-> `DESKTOP-PAIRING` draft set to the live `peach-browser` source. It reconciles5-> every individual draft (01–06) into one apply plan: file map, order, message6-> contract, new types, deps, CSS, test count, and the consolidated TODO list.7->8-> All `file:line` citations are against source as read 2026-06-16. Where a draft9-> and this contract disagree, **this contract wins** (it is the reconciliation).10-11----12-13-## 1. File map — each draft → its destination on apply14-15-| Draft file | What it describes | Destination on apply | Action |16-| --- | --- | --- | --- |17-| `_GROUND-TRUTH.md` | Wire contract pin | (none — reference only) | keep in repo as design record |18-| `README.md` | Orientation | (none — reference only) | keep as design record |19-| `INTEGRATION-CONTRACT.md` | This document | (none — reference only) | keep as design record |20-| `01-vault-id-in-pair-identity.md` | Add `vault_id` to outbound `PAIR_IDENTITY` | **edit** `src/shared/chirp-handshake.ts` (Hunks A–D; defer Hunk E) | apply patch |21-| `02-desktop-device-type.md` | `"desktop"` DeviceType | **NO-OP** — already in `src/shared/sync-types.ts:11`; storage already accepts it | optional regression test only |22-| `03-desktop-pairing-handler.md` | Background code-pairing client | **new file** `src/background/desktop-pairing.ts` + 3 `case` blocks in `src/background/messages.ts` + `openBinaryWebSocket` helper | create + wire |23-| `04-desktop-pairing-ui.md` | Popup pairing UI | **new file** `src/popup/components/DesktopPairingFlow.tsx` + entry-point edits in `src/popup/views/ChirpSync.tsx` + CSS in `src/popup/styles/popup.css` | create + wire |24-| `05-subnet-probe.md` | Handshake-free port probe | **new file** `src/background/desktop-pairing-probe.ts` | create |25-| `06-sync-coordinator-desktop.md` | Include desktop in sync | **edit** `src/background/sync-trigger.ts` (one filter line) | apply patch |26-| `tests/01-pair-identity-vault-id.test.ts` | Change 1 spec | `src/shared/chirp-pair-identity-vault-id.test.ts` (or merge into `chirp-handshake.test.ts`) | relocate |27-| `tests/03-desktop-pairing.test.ts` | Change 3 spec | `src/background/desktop-pairing.test.ts` | relocate, fix `../../`→`../` |28-| `tests/04-desktop-pairing-ui.test.tsx` | Change 4 spec | `src/popup/components/DesktopPairingFlow.test.tsx` | relocate (paths already written for that location) |29-| `tests/06-sync-coordinator-desktop.test.ts` | Change 6 spec | `src/background/sync-trigger.desktop.test.ts` (or merge into `sync-trigger.test.ts`) | relocate, fix `../../`→`../` |30-31-> On apply, the entire `src/drafts/DESKTOP-PAIRING/` tree can be deleted (use32-> `trash`, never `rm`) once the destinations above exist — it is design-time33-> scaffolding, not shipped code.34-35----36-37-## 2. Integration order (dependency-correct)38-39-Apply in this sequence; each step compiles on its own.40-41-1. **Change 1** — `vault_id` into `PAIR_IDENTITY` (`chirp-handshake.ts`,42- Hunks A–D). Adds `LocalIdentity.vaultId` + `PairingFrame.vault_id`. Nothing43- downstream type-checks against `vaultId` until this lands.44-2. **Change 2** — verify-only. Confirm `"desktop"` ∈ `DeviceType`45- (`sync-types.ts:11`); optionally add the round-trip regression test. No code.46-3. **Change 5** — `desktop-pairing-probe.ts` (`probeSubnetForPort`). Standalone;47- Change 3 imports it.48-4. **Change 3** — `desktop-pairing.ts` (the client + `DesktopPairingError`,49- `DesktopPairingSocket`, message-payload shapes) + the `openBinaryWebSocket`50- helper. Depends on Change 1 (`vaultId` on identity) and Change 5 (`findHost`).51-5. **`messages.ts` cases** — the three `PAIR_WITH_DESKTOP` /52- `DESKTOP_PAIR_CONFIRM` handlers and the two background pushes. Depends on53- Change 3's module. Gate exactly like `CHIRP_GET_LOCAL_IDENTITY`.54-6. **Change 4** — `DesktopPairingFlow.tsx` + `ChirpSync.tsx` entry point + the55- `peach-desktop-*` / `peach-code-input` CSS. Depends on the message contract56- (step 5) existing.57-7. **Change 6** — widen the `sync-trigger.ts` host filter to include58- `"desktop"`. Independent of the UI; can land any time after pairing persists59- a desktop device, but logically last (it's the "now actually sync it" step).60-61----62-63-## 3. The reconciled popup ↔ background message contract (CANONICAL)64-65-Reconciled across `03` (defined four messages) and `04` (uses all four; pinned66-`{ confirmed }` not the brief's `{ accept }`). **03 and 04 AGREE** — 04 adopts67-03's `DESKTOP_PAIR_RESULT` terminal push and 03's `{ confirmed: boolean }`68-payload. The brief's three-message / `{accept:true}` shape is superseded. Final69-canonical shapes:70-71-```ts72-// ── popup → background ─────────────────────────────────────────────────────73-74-/** Start pairing with the typed 6-digit code. */75-{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }76-// response: { ok: true } | { ok: false, error: DesktopPairingError }77-// (synchronous start failures — e.g. vault_locked — come back HERE;78-// the SAS and the terminal result arrive LATER as pushes.)79-80-/** User accepted/rejected the SAS shown by DESKTOP_PAIR_SAS. */81-{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }82-// response: { ok: true }83-84-// ── background → popup (pushes via chrome.runtime.sendMessage) ──────────────85-86-/** SAS ready — show the confirm gate. */87-{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }88-89-/** Terminal outcome. */90-{ type: "DESKTOP_PAIR_RESULT",91- payload:92- | { status: "complete", device: PairedDevice }93- | { status: "error", error: DesktopPairingError } }94-```95-96-`DesktopPairingError` (defined in `desktop-pairing.ts`, Change 3):97-```ts98-type DesktopPairingError =99- | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)100- | "no_host_found"101- | "handshake_failed"102- | "sas_mismatch"103- | "vault_locked";104-```105-106-Bridge: `sendMessage` (`src/shared/bridge.ts:13`) for popup→bg; pushes received107-via `chrome.runtime.onMessage.addListener` (`App.tsx:601` idiom).108-109-### Reconciliation flags (resolved)110-- **`{ confirmed }` vs `{ accept }`:** use `{ confirmed: boolean }` (03 + 04).111-- **Three vs four messages:** four — `DESKTOP_PAIR_RESULT` is required so the112- popup can distinguish "Paired!" from a late error (04 §1).113-- **`invalid_code` collapses wrong+expired:** the wire cannot distinguish them114- (`_GROUND-TRUTH.md` §0.3) — one error, one copy string.115-116----117-118-## 4. New message types — where the union lives119-120-The three new `type` strings (`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_CONFIRM`121-inbound; `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_RESULT` outbound) are handled in the122-background `switch` in **`src/background/messages.ts`**, alongside the `CHIRP_*`123-cases (`messages.ts:1318-1384`). 124-125-**Where to add the type literals:** the existing `CHIRP_*` messages are dispatched126-by string `case` in that switch; the integrator should follow the same pattern127-the repo already uses for chirp messages — add the four `case` labels (two128-inbound handlers in the switch; the two outbound are `chrome.runtime.sendMessage`129-pushes, not switch cases). If the repo maintains a discriminated message-union130-type for `sendMessage` payloads, extend it where the `CHIRP_*` variants are131-declared (co-located with the chirp definitions — verify the exact union file at132-integration; the chirp messages are the template). **No change to133-`src/shared/types.ts` is required** — the brief's guess that device/message types134-live there is wrong (`_GROUND-TRUTH.md` §0.1); message handling is in135-`messages.ts` and sync types are in `sync-types.ts`.136-137----138-139-## 5. New dependencies — NONE.140-141-Confirmed: no new npm packages. Everything reuses shipped modules:142-- `noiseNKInitiator` (`src/shared/noise-js.ts:71`) — already present.143-- `derivePairingSAS` (`src/shared/chirp-handshake.ts:171`) — already present.144-- `computeVaultId` (`src/shared/sync-identity.ts:75`) — already present.145-- `WebSocket` / `TextEncoder` / `TextDecoder` — platform globals.146-- Solid (`solid-js`), `vitest`, the local `view-test-utils` — already in repo.147-148-No `package.json` change.149-150----151-152-## 6. New CSS hooks (Change 4 → `src/popup/styles/popup.css`)153-154-The UI **reuses** existing classes (`peach-chirp-pair`, `peach-chirp-head`,155-`peach-chirp-back`, `peach-chirp-headline`, `peach-chirp-sub`,156-`peach-chirp-action`, `peach-chirp-meter*`, `peach-btn*`, `peach-sr-only`,157-`peach-underline-input`, `peach-unlock-input`, `peach-label`). New **additive**158-hooks to add a small block for in `popup.css`:159-160-| New class | Purpose |161-| --- | --- |162-| `peach-desktop-pair` | root container modifier on the flow |163-| `peach-code-input` | the 6-digit code field (mono, wide letter-spacing) |164-| `peach-desktop-enter` / `-searching` / `-confirm` / `-paired` / `-error` | per-phase containers |165-| `peach-desktop-sas` | the big SAS glyph (load-bearing layout is inline for now) |166-| `peach-desktop-connect` | Connect button modifier |167-| `peach-chirp-pair-start--desktop` | the "Desktop Helper" entry button in `ChirpSync.tsx` |168-169-Inline styles in the draft cover the SAS/confirm layout in the interim170-(mirroring `ChirpSync.tsx`'s inline-styled SAS gate at lines 445-472), so the171-CSS block is polish, not a blocker.172-173----174-175-## 7. Estimated test count across the four draft test files176-177-| Test file | `it()` specs |178-| --- | --- |179-| `tests/01-pair-identity-vault-id.test.ts` | **3** |180-| `tests/03-desktop-pairing.test.ts` | **9** |181-| `tests/04-desktop-pairing-ui.test.tsx` | **12** |182-| `tests/06-sync-coordinator-desktop.test.ts` | **2** |183-| **Total** | **26** |184-185-(Change 2 ships no required test; an optional `paired-devices` round-trip186-regression would add 1 if the integrator wants it.)187-188----189-190-## 8. Do existing tests need updating? — NO.191-192-Desktop pairing is **additive**. Verified against the existing suites:193-194-- **`DeviceType` is an OPEN-ENOUGH union already.** `"desktop"` is a member195- (`sync-types.ts:11`); no existing test asserts a closed union *without*196- desktop. `paired-devices.ts` validates only field *presence*, not membership197- (`02-desktop-device-type.md` §"storage gate"), so existing198- `paired-devices.test.ts` specs are unaffected.199-- **No existing chirp/pairing test asserts the ABSENCE of `vault_id`.** Change 1200- emits `vault_id` only when `identity.vaultId` is set; `JSON.stringify` drops it201- when `undefined`. The existing `chirp-handshake.test.ts` identities don't set202- `vaultId`, so their serialized frames are byte-identical to before — no203- assertion flips.204-- **`sync-trigger.test.ts` does NOT break (Change 6).** Its fixtures are205- `ANDROID`, `IOS`, `EXT` — no `desktop`. The two filter-pinning specs206- (`:75-81` "no mobile hosts", `:83-94` "once per mobile host →207- `[ANDROID, IOS]`") are unchanged by adding `"desktop"` to the filter: EXT208- stays excluded and no desktop fixture is present. The new desktop behaviour is209- covered by the NEW spec (`tests/06-...`), not by mutating an existing one.210-211-**No existing test file requires edits.** All four draft test files are net-new212-additions.213-214----215-216-## 9. Consolidated remaining-integration TODO checklist217-218-Carried forward from every pass (01–06):219-220-- [ ] **`openBinaryWebSocket` helper** (Change 3 TODO 1) — adapt `WebSocket` to221- `DesktopPairingSocket`: `binaryType="arraybuffer"`, queued inbound frames,222- and a `closedBeforeFrame()` that distinguishes "closed, no ACK"223- (`invalid_code`) from "ACK received". Pattern at224- `chirp-sync-transport.ts:58-121`.225-- [ ] **`LocalChirpIdentity` vs `LocalIdentity` reconciliation** (Change 3226- TODO 2) — `buildLocalIdentity` returns `LocalChirpIdentity`227- (`chirp-identity.ts:79`), which lacks `vaultId`/`wsPort`. Either confirm it's228- assignable to `LocalIdentity` after the spread `{ ...base, vaultId }`, or add229- `vaultId?` to `LocalChirpIdentity` directly. Extension never sets `wsPort`230- (never a host) — leaving it undefined is correct.231-- [ ] **`messages.ts` gating** (Change 3 §Wiring / Change 4 TODO 4) — gate the232- three cases on `vault.isUnlocked()` and `requirePro("sync")` exactly like233- `CHIRP_GET_LOCAL_IDENTITY` (`messages.ts:1319-1321`). Master key obtained via234- `getRootKeyHex()` (stable root key, SEV-2-B), zeroed in `finally`.235-- [ ] **Single-flight confirm guard** (Change 3 TODO 4 / Change 4 TODO 5) — one236- in-flight pairing per worker; store the pending `confirm` resolver in a237- module-level variable, cleared on complete/error. On popup `onCancel`238- mid-flow, release the resolver (a `DESKTOP_PAIR_CONFIRM { confirmed:false }`239- or a cancel message).240-- [ ] **Timeouts** (Change 3 TODO 5 / Change 5 TODO 1) — wrap241- `findHost`/`receive` with ceilings (e.g. ~10s probe, ~5s per receive) so a242- stalled helper surfaces `no_host_found`/`handshake_failed` instead of hanging243- the popup. Idiom at `chirp-sync-transport.ts:174`. Pairing-port probe worst244- case is ~8s for a /24 at 16-wide 500ms batches.245-- [ ] **Test path adjustments** — fix `../../`→`../` prefixes in the relocated246- 03 and 06 specs; reuse the real `makeStorageStub` from `sync-trigger.test.ts`;247- confirm `paired-devices` test-reset export name; the 04 spec's paths are248- already written for its destination.249-- [ ] **CSS block** for `peach-desktop-*` / `peach-code-input` in `popup.css`250- (Change 4 TODO 3) — polish; inline styles cover the load-bearing layout.251-- [ ] **ChirpSync.tsx entry point** (Change 4 TODO 2) — add the "Desktop Helper252- (enter code)" affordance to `restView()` action block (`ChirpSync.tsx:412-423`)253- and render `<DesktopPairingFlow/>` from `pairingView()`.254-- [ ] **Change 6 filter** — the one-line `sync-trigger.ts:104-108` widening.255-- [ ] **(Optional) Change 2 regression test** — desktop device round-trips256- through `paired-devices.ts`.257-258----259-260-## 10. Version note261-262-The brief said bump **4.0.1 → 4.0.2**. But dev is **already at 4.0.2**263-(`manifest.json` `"version": "4.0.2"`, verified 2026-06-16). Repeated versions264-are rejected by the stores (auto-memory: "every code change is a version bump").265-**Integration must bump 4.0.2 → 4.0.3** across all three manifest targets266-(`manifest.json`, `manifest.firefox.json`, and the mobile manifest) on apply —267-not 4.0.2. (Drafts touch only `src/drafts/`, so no version bump happens at draft268-time; this is an apply-time obligation.)269-270----271-272-## 11. Quality-bar checklist (from the brief), annotated273-274-| Quality bar | Status in drafts | Integrator must verify |275-| --- | --- | --- |276-| **SAS KAT `0fbab8cd… → 895245`** | ✅ pinned: `derivePairingSAS` reused (not re-implemented); KAT asserted in `tests/03` spec "derives the SAS via the real derivePairingSAS"; cross-checked against `peach-sync/src/pairing.zig:275-279` (`_GROUND-TRUTH.md` §5) | run the relocated `tests/03` spec — must stay green |277-| **`tsc` clean** | ⚠️ drafts are apply-ready TS but not compiled in place | after apply, `tsc --noEmit` clean — esp. the `LocalChirpIdentity` vs `LocalIdentity` reconciliation (TODO 2) and `ChirpSyncSession` cast in `tests/06` |278-| **All tests green** | ⚠️ 26 new specs written to repo idiom (vitest, `renderView`, injected deps) | relocate the 4 files, fix import prefixes, run the full suite — 26 new + existing unchanged |279-| **Additive only (no existing test edits)** | ✅ verified §8 — no closed-union assertion, no absence-of-`vault_id` assertion, no desktop fixture in `sync-trigger.test.ts` | confirm the suite passes with zero edits to existing test files |280-| **`vault_id` backwards-compatible** | ✅ optional both ends; `JSON.stringify` drops `undefined`; peach-sync `?[]const u8 = null` + `ignore_unknown_fields` (`pairing.zig:54,57`) | confirm a chirp (mobile) pairing still works after Change 1 |281-| **MITM defence = user-confirmed SAS** | ✅ `pairWithDesktopHelper` gates persistence on `confirm(sas) → true`; rejects → `sas_mismatch`, persists nothing | confirm the `messages.ts` round-trip actually blocks on the popup confirm |282-| **Correct ports / handshakes** | ✅ pairing 49142 / Noise NK / browser initiator; sync 8765 / Noise XX / browser initiator (`06-...md` §"which handshake for which phase") | confirm `wsPort` default 8765 matches helper announce |283-| **No new deps** | ✅ §5 — none | confirm `package.json` untouched |src/drafts/DESKTOP-PAIRING/README.md+0 −53
@@ -1,53 +0,0 @@1-# Desktop Helper Pairing — draft set2-3-> **DRAFT ARTIFACTS.** This folder is design-time scaffolding for pairing the4-> Peach browser extension (Solid.js + TypeScript) with the `peach-sync` desktop5-> helper, then syncing the vault to it over the LAN. Nothing here is wired into6-> the build. On apply, follow `INTEGRATION-CONTRACT.md` and then the whole folder7-> can be removed (use `trash`, never `rm`).8-9-## What this is10-11-A six-change plan, each pinned to verified source in both repos12-(`peach-browser` and `peach-sync`):13-14-1. **Change 1** — send `vault_id` in the `PAIR_IDENTITY` frame so the helper15- records which vault it paired to.16-2. **Change 2** — `"desktop"` DeviceType (**already present** — no-op).17-3. **Change 3** — background code-pairing client (`desktop-pairing.ts`).18-4. **Change 4** — popup pairing UI (`DesktopPairingFlow.tsx`).19-5. **Change 5** — handshake-free subnet port probe for the pairing port.20-6. **Change 6** — include `"desktop"` peers in sync sessions (**one-line filter21- widening** — not a no-op as first assumed; see below).22-23-## Corrected premises vs the original brief24-25-The brief was written before reading the source. These premises were wrong and26-are corrected throughout the draft set (binding pin: `_GROUND-TRUTH.md`):27-28-| Brief said | Reality (verified) |29-| --- | --- |30-| Add `"desktop"` to `DeviceType` in `src/shared/types.ts` | `"desktop"` already in `src/shared/sync-types.ts:11`; `types.ts` has no `DeviceType`. **Change 2 is a no-op.** |31-| Helper acks with `PEACH_HELLO_OK` | It's **`PEACH_HELLO_ACK`** (`pairing.zig:40`). No `_OK` frame exists. |32-| Helper sends `PEACH_HELLO_ERROR` on wrong code | **No error frame.** Wrong/expired code → silent socket close + code rotation (`pairing.zig:117,119-125`). |33-| (implied) browser is NK responder | For desktop pairing the **browser is the NK initiator** (`pairing.zig:153-154`) — opposite of the audio-chirp flow. |34-| React, hooks, `PeachField`, `OutlinedTextField` | Repo is **Solid.js**. No `PeachField` component — it's a `<input class="peach-underline-input">`. Tests use `renderView`, not `@solidjs/testing-library`. |35-| Bump 4.0.1 → 4.0.2 | Dev is **already 4.0.2**. Integration bumps **4.0.2 → 4.0.3**. |36-| Sync port assumption | Sync server is **8765** (Noise XX); pairing server is **49142** (Noise NK). Both verified. |37-| Change 6 just works | Runner/coordinator are generic, **but** `sync-trigger.ts:104` filters to `android|ios` only — desktop must be **added** to that one filter. |38-| `derivePairingSas` (lowercase) | Existing export is `derivePairingSAS` (capital). Reuse it. |39-| SAS KAT `0fbab8cd… → 895245` | **Correct** — confirmed against `pairing.zig:275-279`. |40-| `{ accept: true }` on confirm | Canonical payload is `{ confirmed: boolean }` (03 + 04). |41-42-## Reading order43-44-1. `_GROUND-TRUTH.md` — the binding wire contract. Read first.45-2. `01-vault-id-in-pair-identity.md`46-3. `02-desktop-device-type.md` (no-op verification)47-4. `05-subnet-probe.md` (Change 3 depends on it)48-5. `03-desktop-pairing-handler.md`49-6. `04-desktop-pairing-ui.md`50-7. `06-sync-coordinator-desktop.md`51-8. `tests/` — the four companion spec files (26 specs total).52-9. `INTEGRATION-CONTRACT.md` — the authoritative apply plan (file map, order,53- message contract, TODOs, version, quality bar). Read last, follow on apply.src/drafts/DESKTOP-PAIRING/_GROUND-TRUTH.md+0 −322
@@ -1,322 +0,0 @@1-# Desktop Helper Pairing — Ground Truth Contract2-3-> **DRAFT ARTIFACT.** Pins the canonical wire contract for pairing the Peach4-> browser extension with the `peach-sync` desktop helper. Every later drafting5-> pass MUST conform to this file. Where the brief and the `peach-sync` source6-> disagree, **the source wins** and the discrepancy is flagged below.7->8-> All citations are `file:line` against the two repos as read on 2026-06-16.9-> `S/` = `/Users/june/projects/peach-sync/`, `B/` = `/Users/june/projects/peach-browser/`.10-11----12-13-## 0. Discrepancies vs the brief (READ FIRST)14-15-1. **DeviceType / PairedDevice live in `sync-types.ts`, NOT `types.ts`.**16- The brief said DeviceType is "likely `src/shared/types.ts`". It is wrong.17- - `DeviceType` is defined at `B/src/shared/sync-types.ts:11`.18- - `B/src/shared/types.ts` has **no** `DeviceType` symbol.19- - `chirp-handshake.ts` imports it from `./sync-types` (`B/src/shared/chirp-handshake.ts:29`).20- - **`"desktop"` is ALREADY a member of `DeviceType`** (`sync-types.ts:11`) and21- already has a chirp byte tag `DEVICE_TYPE_DESKTOP = 0x04` (`sync-types.ts:19`),22- a `deviceTypeFromByte`/`deviceTypeToByte` case (`sync-types.ts:36,45`), and23- `chirp-payload.ts` already accepts byte `0x04` (`chirp-payload.ts:5,89`).24- **Change 2 is therefore largely a NO-OP for the type union itself** — see25- `02-desktop-device-type.md`. The brief's premise that `"desktop"` must be26- added is false against current source.27-28-2. **The PEACH_HELLO acknowledgement frame is named `PEACH_HELLO_ACK`, NOT29- `PEACH_HELLO_OK`.** The brief proposed `PEACH_HELLO_OK` / `PEACH_HELLO_ERROR`.30- The source sends `PEACH_HELLO_ACK` and has **no** `PEACH_HELLO_OK` and **no**31- `PEACH_HELLO_ERROR` frame at all. See §2.32-33-3. **There is no `PEACH_HELLO_ERROR` / error frame on code mismatch.** On a wrong34- code the helper **silently closes the connection and rotates the code**35- (`S/src/pairing.zig:144-147,119-125`). It does not send an error frame. A36- downstream browser implementation must treat connection close / no `*_ACK` as37- "wrong code", not wait for an error frame.38-39-4. **The `PEACH_HELLO_ACK` payload field is `ephemeralPublicKeyHex`40- (camelCase), NOT snake_case.** Unlike `PAIR_IDENTITY` (which is snake_case),41- the HELLO/ACK frames use camelCase field names. See §2.42-43-5. **The browser does not yet implement the code-pairing path at all.**44- `chirp-handshake.ts` only implements the *audio chirp* NK flow. The45- `PEACH_HELLO` / `PEACH_HELLO_ACK` exchange and 6-digit `deriveCode` are46- desktop-only and currently have **no** browser counterpart47- (`S/src/pairing.zig:16-23`, `S/src/noise_nk.zig:9-12`). The desktop pairing48- client is net-new browser code (out of scope for Changes 1 & 2, pinned here49- so downstream passes build the right thing).50-51----52-53-## 1. Ports54-55-| Purpose | Port | Source |56-| --- | --- | --- |57-| **Pairing server** (only while `peach-sync pair` runs) | **49142** | `S/src/pairing.zig:36` `pub const PAIRING_PORT: u16 = 49142;`; `S/PROJECT.md:14` |58-| **Sync server** (responder, always listening) | **8765** | `S/PROJECT.md:13`; `S/README.md:30`; announced by the helper as `ws_port: 8765` in its `PAIR_IDENTITY` (`S/src/pairing.zig:179`) |59-60-- Brief's claim of pairing port **49142 is CORRECT** (verified `pairing.zig:36`).61-- Brief's claim of default sync port **8765 is CORRECT** (verified `PROJECT.md:13`).62-- **`ws_port` semantics:** it is the peer's WebSocket *sync* port (phase-2),63- carried inside the encrypted `PAIR_IDENTITY` frame and cached on the paired64- device record (`B/src/shared/sync-types.ts:99-104`). The desktop helper always65- announces `8765` (`pairing.zig:179`). Mobile hosts instead pick a random high66- port. `ws_port` is **optional** on the wire (`pairing.zig:53`,67- `chirp-handshake.ts:60`).68-69-- **Transport:** the pairing port speaks **WebSocket over TCP** (HTTP upgrade,70- binary frames only, one Noise frame per WS message) — `S/src/websocket.zig:1-4`,71- used by the pairing server at `S/src/pairing.zig:92`. Client frames must be72- masked; server frames are never masked (`websocket.zig:136-137,274`).73-74----75-76-## 2. PEACH_HELLO / PEACH_HELLO_ACK frame shapes (pre-handshake, PLAINTEXT)77-78-These two frames are exchanged **before** the Noise handshake, in the clear.79-80-### `PEACH_HELLO` (browser → helper)81-Source struct: `S/src/pairing.zig:39`82-```zig83-const PeachHello = struct { type: []const u8, code: []const u8 };84-```85-Wire shape (JSON):86-```json87-{ "type": "PEACH_HELLO", "code": "<6-digit string>" }88-```89-- `type` MUST equal the literal `"PEACH_HELLO"` (`pairing.zig:146`).90-- `code` MUST equal the helper's current 6-digit code, **byte-for-byte as a91- string** (`pairing.zig:147` compares against `&code`, a `[6]u8`). Send the 692- ASCII digits, zero-padded, no separators.93-- Unknown fields are ignored (`PARSE_OPTS = .ignore_unknown_fields`, `pairing.zig:57`).94-95-### `PEACH_HELLO_ACK` (helper → browser, on code match)96-Source struct: `S/src/pairing.zig:40`97-```zig98-const PeachHelloAck = struct { type: []const u8, ephemeralPublicKeyHex: []const u8 };99-```100-Wire shape (JSON):101-```json102-{ "type": "PEACH_HELLO_ACK", "ephemeralPublicKeyHex": "<64 hex chars>" }103-```104-- Field name is **`ephemeralPublicKeyHex`** (camelCase) — `pairing.zig:40,151`.105-- Value is the helper's pairing **ephemeral X25519 public key**, lowercase hex,106- 64 chars / 32 bytes (`pairing.zig:150` `bytesToHex(..., .lower)`).107-- This key is the **NK responder static `s`** the browser runs the handshake108- against (`pairing.zig:8-9,153-154`; `noise_nk.zig:3-11`).109-110-### Code-mismatch / error behaviour111-- **There is no `PEACH_HELLO_ERROR` frame and no `PEACH_HELLO_OK` frame.**112-- On `type != "PEACH_HELLO"` or `code` mismatch the helper returns `false`,113- the connection is **closed** (`defer conn.close()`, `pairing.zig:117`) and the114- helper rotates to a new code and waits again (`pairing.zig:119-125`).115-- Browser contract: after sending `PEACH_HELLO`, expect either a116- `PEACH_HELLO_ACK` frame or a connection close. Connection close ⇒ wrong code.117-118----119-120-## 3. Noise NK handshake121-122-- **Protocol:** `Noise_NK_25519_ChaChaPoly_BLAKE2s` (`S/src/noise_nk.zig:22`).123-- **Roles (code pairing): BROWSER = INITIATOR, helper (peach-sync) = RESPONDER.**124- Confirmed: `S/src/pairing.zig:3` ("helper is the server / NK responder"),125- `pairing.zig:153-154` constructs `noise_nk.Responder`. The NK static `s` is the126- helper's pairing **ephemeral** keypair (`pairing.zig:154`,127- `noise_nk.zig:9-11`). The browser learns `s.public` from the128- `PEACH_HELLO_ACK` and runs the NK initiator against it.129-130- > NOTE — this is the OPPOSITE role assignment from the existing audio-chirp131- > flow, where the *extension* is the NK responder132- > (`B/src/shared/chirp-handshake.ts:6-7,185-196`). For DESKTOP code pairing the133- > browser is the initiator. Downstream code MUST use `noiseNKInitiator` on the134- > browser side (`B/src/shared/noise-js.ts`), mirroring `runPairingInitiator`.135-136-- **Message pattern** (`noise_nk.zig:3-7`):137- ```138- <- s (pre-message: both MixHash(responder static))139- -> e, es (msg1: initiator/browser → responder/helper)140- <- e, ee (msg2: responder/helper → initiator/browser)141- ```142- - msg1 = `e(32) || encrypted-payload`; helper expects it143- (`pairing.zig:155-158`, `noise_nk.zig:44-51`). Browser sends it via144- `noiseNKInitiator` writeMessage1.145- - msg2 = `e(32) || encrypted-payload`; helper sends empty payload `""`146- (`pairing.zig:159-161`, `noise_nk.zig:54-68`).147-- After the two messages both sides `Split` into a transport `Session`148- (`pairing.zig:162`, `noise_nk.zig:70-79`).149-- **Handshake hash is shared** between both ends (asserted in150- `noise_nk.zig:168,216`) — this is the SAS input.151-- **Cross-impl KAT (handshake hash):** responder static seed `0xc3` (32×),152- initiator ephemeral `fill(1)`, responder ephemeral `fill(2)` ⇒153- `0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655`154- (`noise_nk.zig:191-221`; same KAT in `B/src/shared/noise-blake2s-kat.test.ts`).155-156----157-158-## 4. PAIR_IDENTITY frame (over the NK transport, ENCRYPTED)159-160-After the handshake, both sides exchange ONE `PAIR_IDENTITY` JSON frame,161-Noise-encrypted. **Initiator (browser) sends first, then receives the helper's**162-(`pairing.zig:164-180`; mirrored by browser `runPairingInitiator`163-`chirp-handshake.ts:105-119`).164-165-Helper-accepted struct: `S/src/pairing.zig:46-55`166-```zig167-const PairIdentity = struct {168- type: []const u8,169- device_id: []const u8,170- device_name: []const u8,171- device_type: []const u8,172- long_term_public_key_hex: []const u8,173- signing_public_key_hex: ?[]const u8 = null, // OPTIONAL174- ws_port: ?u16 = null, // OPTIONAL175- vault_id: ?[]const u8 = null, // OPTIONAL ← Change 1 target176-};177-```178-179-Field-by-field truth:180-| Field | Required? | Source |181-| --- | --- | --- |182-| `type` | required, literal `"PAIR_IDENTITY"` | `pairing.zig:69` (decode in browser); helper sends it `pairing.zig:175` |183-| `device_id` | required | `pairing.zig:48` |184-| `device_name` | required | `pairing.zig:49` |185-| `device_type` | required, plain string | `pairing.zig:50` |186-| `long_term_public_key_hex` | required, 64 lowercase hex | `pairing.zig:51`; browser validates `/^[0-9a-f]{64}$/` `chirp-handshake.ts:75` |187-| `signing_public_key_hex` | **optional** (`?... = null`) | `pairing.zig:52` |188-| `ws_port` | **optional** (`?u16 = null`) | `pairing.zig:53` |189-| `vault_id` | **optional** (`?... = null`) | `pairing.zig:54` |190-191-### Is `vault_id` GENUINELY optional in peach-sync? — YES.192-- Declared `?[]const u8 = null` (`pairing.zig:54`).193-- Unknown/absent tolerated: `PARSE_OPTS = .ignore_unknown_fields` (`pairing.zig:57,167`).194-- Consumed defensively: `peer.value.vault_id orelse ""` (`pairing.zig:209`) and195- only persisted if present and non-empty (`pairing.zig:212-214`).196-- The helper **does NOT send `vault_id` in its own** `PAIR_IDENTITY`197- (`pairing.zig:173-180` omits it; `emit_null_optional_fields = false` at198- `pairing.zig:222` drops nulls on serialize). It only has a vault_id once a199- browser sends one. So adding `vault_id` to the browser-sent frame is purely200- **additive and backwards-compatible**: existing chirp peers (mobile) that omit201- it keep working, and the helper accepts it when present.202-203-### `device_type` string the helper announces: `"desktop"`204-- The helper sends `.device_type = "desktop"` in its own `PAIR_IDENTITY`205- (`S/src/pairing.zig:177`). This is a plain string; it maps onto the browser's206- existing `DeviceType` member `"desktop"` (`sync-types.ts:11`).207-208----209-210-## 5. SAS derivation — VERIFIED byte-identical across both repos211-212-### peach-sync (`S/src/pairing.zig:74-79`)213-```zig214-pub fn deriveSas(handshake_hash: [32]u8) [6]u8 {215- const n = std.mem.readInt(u32, handshake_hash[0..4], .big) % 1_000_000;216- // → "{d:0>6}" (zero-padded 6 digits)217-}218-```219-220-### peach-browser (`B/src/shared/chirp-handshake.ts:171-182`)221-```ts222-export function derivePairingSAS(handshakeHash: Uint8Array): string {223- if (handshakeHash.length < 4) return "000000";224- const n = ((handshakeHash[0] << 24) >>> 0) + (handshakeHash[1] << 16)225- + (handshakeHash[2] << 8) + handshakeHash[3];226- return String((n >>> 0) % 1_000_000).padStart(6, "0");227-}228-```229-230-**Formula (canonical):** `SAS = (BE_u32(handshakeHash[0..4]) mod 1_000_000)`,231-rendered as a zero-padded 6-digit decimal string. Big-endian byte order, modulus232-`1_000_000`, left-pad with `0` to width 6.233-234-### KAT confirmation — **0fbab8cd → 895245 HOLDS.** ✅235-- First 4 bytes of the KAT handshake hash are `0f ba b8 cd`.236-- `0x0fbab8cd = 263_895_245`.237-- `263_895_245 mod 1_000_000 = 895_245` → `"895245"` (6 digits, no padding needed).238-- This is **pinned in peach-sync's own test**: `S/src/pairing.zig:275-279`239- (`expectEqualStrings("895245", &deriveSas(nk_hash))` over the240- `0fbab8cd…` KAT). The browser's `derivePairingSAS` produces the identical value241- by construction (same BE-u32-mod-1e6).242-- The brief's claimed KAT (`0fbab8cd… → 895245`) is therefore **CORRECT**.243-244-> The browser ALREADY ships `derivePairingSAS` with the exact required signature245-> and algorithm (`chirp-handshake.ts:171`). **No new SAS function is needed** —246-> the desktop pairing client reuses `derivePairingSAS(session.handshakeHash)`,247-> exactly as the existing chirp flow does (`chirp-handshake.ts:122,223`).248-249----250-251-## 6. PINNED TypeScript contract (downstream passes MUST use VERBATIM)252-253-> These names/shapes are the binding contract. The HELLO frames are net-new254-> (the brief's `PeachHelloResponse`/`PeachHelloError` are renamed to match the255-> ACTUAL `peach-sync` wire: `PeachHelloAck`, and there is **no** error frame).256-257-```ts258-// --- pre-handshake plaintext frames (DESKTOP code pairing) -------------------259-260-/** Sent BROWSER → helper on port 49142, before the Noise handshake. */261-export interface PeachHelloFrame {262- type: "PEACH_HELLO";263- /** The 6-digit code the user read off the desktop, as a string. */264- code: string;265-}266-267-/** Helper → browser on code match. Field name is camelCase (matches the wire). */268-export interface PeachHelloAck {269- type: "PEACH_HELLO_ACK";270- /** Helper pairing ephemeral X25519 pubkey; 64 lowercase hex chars.271- * This is the NK responder static the browser runs the handshake against. */272- ephemeralPublicKeyHex: string;273-}274-275-// NOTE: peach-sync sends NO error/OK frame. A wrong code closes the socket.276-// Downstream MUST treat "connection closed without PEACH_HELLO_ACK" as a code277-// mismatch. Do NOT introduce a PeachHelloError type — there is no wire peer278-// that produces it.279-280-// --- new pairing message-type string literals (for any local discriminants) -281-// "PEACH_HELLO" (browser → helper)282-// "PEACH_HELLO_ACK" (helper → browser)283-// (PAIR_IDENTITY already exists; SYNC_* unchanged.)284-285-// --- SAS (ALREADY EXISTS — reuse, do not re-add) ----------------------------286-// src/shared/chirp-handshake.ts287-export function derivePairingSAS(handshakeHash: Uint8Array): string;288-// Brief's requested signature was `derivePairingSas(...)`; the EXISTING289-// exported name is `derivePairingSAS` (capital SAS). Reuse the existing name.290-291-// --- DeviceType (ALREADY INCLUDES "desktop" — no change needed) -------------292-// src/shared/sync-types.ts:11293-export type DeviceType = "extension" | "android" | "ios" | "desktop";294-295-// --- PAIR_IDENTITY browser-sent frame: ADD vault_id (Change 1) --------------296-// src/shared/chirp-handshake.ts interface PairingFrame297-interface PairingFrame {298- type: "PAIR_IDENTITY";299- device_id: string;300- device_name: string;301- device_type: DeviceType;302- long_term_public_key_hex: string;303- signing_public_key_hex?: string;304- ws_port?: number;305- vault_id?: string; // ← ADDED, optional, hex string (Change 1)306-}307-```308-309----310-311-## 7. Where vaultId comes from (for Change 1 threading)312-313-- `computeVaultId(masterKey: Uint8Array): Promise<string>` →314- `BLAKE3(masterKey || "peach-vault-id")` sliced to 64 hex chars315- (`B/src/shared/sync-identity.ts:75-82`). It is the SYNC vault id already used316- in `SYNC_HELLO` (`sync_coordinator.zig:59,141`).317-- It is **async** and needs the in-memory `masterKey`. It is NOT currently318- available inside `runPairingInitiator`/`runPairingResponder`319- (`chirp-handshake.ts` has no master key / vault id in scope). Therefore Change 1320- must **thread the vault_id in via `LocalIdentity`** (the existing identity321- struct passed into the handshake), computed by the caller that already holds322- the master key — see `01-vault-id-in-pair-identity.md` §"Threading".src/drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts+0 −127
@@ -1,127 +0,0 @@1-// DRAFT test spec for Change 1 — vault_id in the PAIR_IDENTITY frame.2-//3-// DO NOT RUN as-is from the drafts folder. This is the companion spec that4-// should land alongside the Change 1 patch as5-// src/shared/chirp-pair-identity-vault-id.test.ts6-// (or be merged into src/shared/chirp-handshake.test.ts). It assumes the7-// `vaultId` field has been added to LocalIdentity and emitted into the8-// outbound PAIR_IDENTITY frame per drafts/DESKTOP-PAIRING/01-...md.9-//10-// Style mirrors the existing src/shared/chirp-handshake.test.ts:11-// - vitest (describe/it/expect)12-// - identities via deriveSyncIdentityKeypair + pubkeyToHex13-// - memoryTransportPair for the in-memory Noise NK round-trip14-//15-// Strategy: rather than re-parse the encrypted wire bytes, we let the peer16-// RECEIVE our frame through the real handshake and assert on the PairedDevice17-// it learned — but PairedDevice currently has no vaultId getter, so we ALSO18-// intercept the raw decoded frame by spying on the peer's transport. To keep19-// this hermetic and not depend on internals, we drive a second initiator that20-// captures the plaintext it decrypts. See the inline note.21-22-import { describe, expect, it } from "vitest";23-import { generateKeyPair } from "@stablelib/x25519";24-import {25- memoryTransportPair,26- runPairingInitiator,27- runPairingResponder,28- type LocalIdentity,29-} from "../../shared/chirp-handshake";30-import { noiseNKResponder } from "../../shared/noise-js";31-import {32- computeVaultId,33- deriveSyncIdentityKeypair,34- pubkeyToHex,35-} from "../../shared/sync-identity";36-37-const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));38-39-async function identityFor(40- deviceId: string,41- deviceName: string,42- type: LocalIdentity["deviceType"],43- opts: { withVaultId?: boolean } = {},44-): Promise<LocalIdentity> {45- const kp = deriveSyncIdentityKeypair(MASTER, deviceId);46- return {47- deviceId,48- deviceName,49- deviceType: type,50- longTermPublicKeyHex: pubkeyToHex(kp.publicKey),51- signingPublicKeyHex: "b".repeat(64),52- vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined,53- };54-}55-56-/**57- * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the58- * responder ourselves and decrypting the first transport frame. The browser is59- * the NK initiator in the desktop code-pairing flow (_GROUND-TRUTH §3), so this60- * exercises the exact outbound path.61- */62-async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {63- const respEphemeral = generateKeyPair();64- const [t1, t2] = memoryTransportPair();65-66- let captured: Record<string, unknown> | undefined;67-68- const responder = (async () => {69- const session = await noiseNKResponder(70- respEphemeral.secretKey,71- respEphemeral.publicKey,72- t2.send,73- t2.receive,74- );75- // Initiator sends its PAIR_IDENTITY first.76- const cipher = await t2.receive();77- const plain = session.decrypt(cipher);78- captured = JSON.parse(new TextDecoder().decode(plain));79- // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.80- const back = JSON.stringify({81- type: "PAIR_IDENTITY",82- device_id: "desktop-001",83- device_name: "peach-desktop",84- device_type: "desktop",85- long_term_public_key_hex: "a".repeat(64),86- ws_port: 8765,87- });88- await t2.send(session.encrypt(new TextEncoder().encode(back)));89- })();90-91- await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);92- await responder;93- if (!captured) throw new Error("did not capture initiator frame");94- return captured;95-}96-97-describe("PAIR_IDENTITY vault_id (Change 1)", () => {98- it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => {99- const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true });100- const frame = await captureInitiatorFrame(initIdent);101-102- expect(frame.type).toBe("PAIR_IDENTITY");103- expect(frame).toHaveProperty("vault_id");104- expect(typeof frame.vault_id).toBe("string");105- });106-107- it("vault_id is a non-empty 64-char lowercase hex string", async () => {108- const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true });109- const frame = await captureInitiatorFrame(initIdent);110-111- const vaultId = frame.vault_id as string;112- expect(vaultId.length).toBeGreaterThan(0);113- // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars.114- expect(vaultId).toMatch(/^[0-9a-f]{64}$/);115- // Must equal the canonical computeVaultId output for the same master key.116- expect(vaultId).toBe(await computeVaultId(MASTER));117- });118-119- it("omits vault_id entirely when the identity has no vaultId (locked vault / chirp peer)", async () => {120- const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });121- const frame = await captureInitiatorFrame(initIdent);122-123- // JSON.stringify drops undefined keys — backwards-compatible with peers124- // (and peach-sync) that treat vault_id as optional.125- expect(frame).not.toHaveProperty("vault_id");126- });127-});src/drafts/DESKTOP-PAIRING/tests/03-desktop-pairing.test.ts+0 −268
@@ -1,268 +0,0 @@1-// Tests for Change 3 — desktop helper code pairing.2-//3-// DRAFT / apply-ready. These specs import from the proposed module4-// `src/background/desktop-pairing.ts` (Change 3). Path assumes this file is5-// relocated to `src/background/desktop-pairing.test.ts` on apply; from the6-// draft location the imports below would resolve as written if vitest's7-// rootDir maps `src/` — adjust the two `../../` prefixes when you move it.8-//9-// They use the dependency-injection seam (DesktopPairingDeps / a fake10-// DesktopPairingSocket) so no real WebSocket or Noise crypto runs — except11-// the SAS KAT, which calls the REAL `derivePairingSAS` from the shipped12-// chirp-handshake module to pin cross-impl agreement with peach-sync.13-14-import { beforeEach, describe, expect, it, vi } from "vitest";15-16-// Change 3 module (proposed):17-import {18- pairWithDesktopHelper,19- DESKTOP_PAIRING_PORT,20- type DesktopPairingDeps,21- type DesktopPairingSocket,22- type PairWithDesktopCallbacks,23-} from "../../background/desktop-pairing";24-25-// REAL SAS implementation — reused, not re-derived (ground truth §5).26-import { derivePairingSAS } from "../../shared/chirp-handshake";27-import type { LocalIdentity } from "../../shared/chirp-handshake";28-import type { PairedDevice } from "../../shared/sync-types";29-30-// ── helpers ────────────────────────────────────────────────────────────────31-32-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));33-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));34-35-const HELPER_EPHEMERAL_HEX = "a".repeat(64);36-const HELPER_LONGTERM_HEX = "b".repeat(64);37-38-const OUR_IDENTITY: LocalIdentity = {39- deviceId: "ext-local-001",40- deviceName: "Firefox on MacBook",41- deviceType: "extension",42- longTermPublicKeyHex: "c".repeat(64),43- signingPublicKeyHex: "d".repeat(64),44- vaultId: "e".repeat(64),45-};46-47-/**48- * Scripted fake socket. Captures everything sent, and replays a queued list49- * of inbound frames. A frame of `null` means "the socket closes here with no50- * data" — the peach-sync wrong-code behaviour (no error frame).51- */52-function makeFakeSocket(inbound: Array<Uint8Array | null>) {53- const sent: Uint8Array[] = [];54- let closed = false;55- let lastWasClose = false;56- const queue = [...inbound];57- const sock: DesktopPairingSocket = {58- send(bytes) { sent.push(bytes); },59- async receive() {60- const next = queue.shift();61- if (next === undefined || next === null) {62- closed = true;63- lastWasClose = true;64- throw new Error("socket closed");65- }66- lastWasClose = false;67- return next;68- },69- close() { closed = true; },70- closedBeforeFrame() { return lastWasClose; },71- };72- return { sock, sent, isClosed: () => closed };73-}74-75-/**76- * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we77- * can drive the PAIR_IDENTITY exchange without real crypto. We stub78- * `noiseNKInitiator` via vi.mock below; this is the shape it returns.79- */80-const FAKE_HANDSHAKE_HASH = Uint8Array.from([81- 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),82-]);83-84-// Mock the NK initiator: skip the two handshake messages, hand back a session85-// whose encrypt/decrypt are identity transforms and whose handshakeHash is the86-// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).87-vi.mock("../../shared/noise-js", async (orig) => {88- const actual = await orig<typeof import("../../shared/noise-js")>();89- return {90- ...actual,91- noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({92- encrypt: (pt: Uint8Array) => pt,93- decrypt: (ct: Uint8Array) => ct,94- handshakeHash: FAKE_HANDSHAKE_HASH,95- })),96- };97-});98-99-function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {100- deps: DesktopPairingDeps;101- persisted: PairedDevice[];102- sockets: ReturnType<typeof makeFakeSocket>[];103-} {104- const persisted: PairedDevice[] = [];105- const sockets: ReturnType<typeof makeFakeSocket>[] = [];106- const deps: DesktopPairingDeps = {107- findHost: vi.fn(async () => "192.168.1.50"),108- openSocket: vi.fn(async () => {109- // default: ACK then a PAIR_IDENTITY response110- const s = makeFakeSocket([111- enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),112- enc({113- type: "PAIR_IDENTITY",114- device_id: "desktop-001",115- device_name: "June's iMac",116- device_type: "desktop",117- long_term_public_key_hex: HELPER_LONGTERM_HEX,118- ws_port: 8765,119- }),120- ]);121- sockets.push(s);122- return s.sock;123- }),124- getLocalIdentity: vi.fn(async () => OUR_IDENTITY),125- persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),126- ...overrides,127- };128- return { deps, persisted, sockets };129-}130-131-function makeCallbacks(): {132- cb: PairWithDesktopCallbacks;133- sas: { value?: string };134- errors: string[];135- completed: PairedDevice[];136-} {137- const sas: { value?: string } = {};138- const errors: string[] = [];139- const completed: PairedDevice[] = [];140- const cb: PairWithDesktopCallbacks = {141- onSasReady: (s) => { sas.value = s; },142- onError: (e) => { errors.push(e); },143- onComplete: (d) => { completed.push(d); },144- };145- return { cb, sas, errors, completed };146-}147-148-beforeEach(() => {149- vi.clearAllMocks();150-});151-152-// ── specs ────────────────────────────────────────────────────────────────153-154-describe("pairWithDesktopHelper", () => {155- it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {156- const { deps, sockets } = makeDeps();157- const { cb } = makeCallbacks();158-159- await pairWithDesktopHelper("123", cb, deps, async () => true);160-161- expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);162- const firstFrame = dec(sockets[0].sent[0]);163- expect(firstFrame.type).toBe("PEACH_HELLO");164- // "123" must be normalised to exactly 6 ASCII digits, zero-padded.165- expect(firstFrame.code).toBe("000123");166- expect(firstFrame.code).toMatch(/^\d{6}$/);167- });168-169- it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {170- // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY171- // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the172- // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces173- // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)174- const closeImmediately = makeFakeSocket([null]); // close, no data175- const { deps } = makeDeps({176- openSocket: vi.fn(async () => closeImmediately.sock),177- });178- const { cb, errors, completed } = makeCallbacks();179-180- await pairWithDesktopHelper("654321", cb, deps, async () => true);181-182- expect(errors).toEqual(["invalid_code"]);183- expect(completed).toHaveLength(0);184- expect(deps.persistDevice).not.toHaveBeenCalled();185- });186-187- it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {188- // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake189- // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;190- // 263895245 mod 1_000_000 = 895245.191- const hash = Uint8Array.from([192- 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),193- ]);194- expect(derivePairingSAS(hash)).toBe("895245");195- });196-197- it("surfaces that same SAS through the pairing flow", async () => {198- // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.199- const { deps } = makeDeps();200- const { cb, sas } = makeCallbacks();201-202- await pairWithDesktopHelper("000123", cb, deps, async () => true);203-204- expect(sas.value).toBe("895245");205- });206-207- it("persists the device with deviceType 'desktop' on SAS confirm", async () => {208- const { deps, persisted } = makeDeps();209- const { cb, completed, errors } = makeCallbacks();210-211- await pairWithDesktopHelper("000123", cb, deps, async () => true);212-213- expect(errors).toHaveLength(0);214- expect(persisted).toHaveLength(1);215- expect(persisted[0].deviceType).toBe("desktop");216- expect(persisted[0].deviceId).toBe("desktop-001");217- expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);218- expect(persisted[0].trustSource).toBe("direct");219- expect(persisted[0].wsPort).toBe(8765);220- expect(completed).toHaveLength(1);221- });222-223- it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {224- const { deps, persisted } = makeDeps();225- const { cb, errors } = makeCallbacks();226-227- await pairWithDesktopHelper("000123", cb, deps, async () => false);228-229- expect(errors).toEqual(["sas_mismatch"]);230- expect(persisted).toHaveLength(0);231- expect(deps.persistDevice).not.toHaveBeenCalled();232- });233-234- it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {235- const { deps, sockets } = makeDeps();236- const { cb } = makeCallbacks();237-238- await pairWithDesktopHelper("000123", cb, deps, async () => true);239-240- // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY241- // (encrypt is identity in the mock, so it decodes directly).242- const ourPairIdentity = dec(sockets[0].sent[1]);243- expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");244- expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);245- });246-247- it("reports no_host_found when the probe returns null", async () => {248- const { deps } = makeDeps({ findHost: vi.fn(async () => null) });249- const { cb, errors } = makeCallbacks();250-251- await pairWithDesktopHelper("000123", cb, deps, async () => true);252-253- expect(errors).toEqual(["no_host_found"]);254- expect(deps.openSocket).not.toHaveBeenCalled();255- });256-257- it("reports vault_locked when local identity is unavailable", async () => {258- const { deps } = makeDeps({259- getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),260- });261- const { cb, errors } = makeCallbacks();262-263- await pairWithDesktopHelper("000123", cb, deps, async () => true);264-265- expect(errors).toEqual(["vault_locked"]);266- expect(deps.findHost).not.toHaveBeenCalled();267- });268-});src/drafts/DESKTOP-PAIRING/tests/04-desktop-pairing-ui.test.tsx+0 −226
@@ -1,226 +0,0 @@1-// @vitest-environment jsdom2-//3-// DRAFT spec for DesktopPairingFlow (Change 4). Apply-ready — do NOT run from4-// the drafts folder; on apply it lives next to the component as5-// src/popup/components/DesktopPairingFlow.test.tsx, and the import paths below6-// are written for THAT location (./DesktopPairingFlow, ../views/view-test-utils,7-// ../../shared/* ).8-//9-// Mirrors the repo's existing component-test idiom (ChirpPairingFlow.test.tsx):10-// - @vitest-environment jsdom11-// - renderView / inputText from view-test-utils (NOT @solidjs/testing-library)12-// - raw DOM queries + .click()13-//14-// chrome messaging is mocked exactly the way the popup talks to it:15-// - shared/bridge `sendMessage` is mocked (popup -> bg)16-// - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can17-// fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg -> popup)18-19-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";20-21-// --- mock the background bridge (popup -> bg) -------------------------------22-const sendMessage = vi.fn(async (_msg: unknown) => ({ ok: true }) as { ok: boolean; error?: string });23-vi.mock("../../shared/bridge", () => ({ sendMessage }));24-25-import { DesktopPairingFlow } from "./DesktopPairingFlow";26-import type { PairedDevice } from "../../shared/sync-types";27-import { renderView, inputText } from "../views/view-test-utils";28-29-// --- fake chrome.runtime push channel (bg -> popup) ------------------------30-type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;31-let listeners: RuntimeListener[] = [];32-33-function pushFromBackground(type: string, payload: unknown): void {34- // Solid effects/handlers run synchronously; the listener registered in35- // onMount updates the signals immediately when invoked.36- for (const l of [...listeners]) l({ type, payload });37-}38-39-beforeEach(() => {40- listeners = [];41- sendMessage.mockClear();42- sendMessage.mockResolvedValue({ ok: true });43- (globalThis as unknown as { chrome: unknown }).chrome = {44- runtime: {45- onMessage: {46- addListener: (fn: RuntimeListener) => { listeners.push(fn); },47- removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },48- },49- },50- };51-});52-53-describe("DesktopPairingFlow", () => {54- const cleanups: Array<() => void> = [];55- afterEach(() => {56- while (cleanups.length) cleanups.pop()!();57- });58-59- function mount(handlers: { onCancel?: () => void; onDone?: (d?: PairedDevice) => void } = {}) {60- const r = renderView(61- <DesktopPairingFlow62- onCancel={handlers.onCancel ?? (() => {})}63- onDone={handlers.onDone ?? (() => {})}64- />,65- );66- cleanups.push(r.dispose);67- return r;68- }69-70- function codeInput(container: HTMLElement): HTMLInputElement {71- return container.querySelector("#desktop-pair-code") as HTMLInputElement;72- }73- function button(container: HTMLElement, label: string): HTMLButtonElement | undefined {74- return Array.from(container.querySelectorAll("button")).find((b) => b.textContent === label) as75- | HTMLButtonElement76- | undefined;77- }78-79- it("renders a 6-digit numeric code input", () => {80- const { container } = mount();81- const input = codeInput(container);82- expect(input).toBeTruthy();83- expect(input.getAttribute("inputmode")).toBe("numeric");84- expect(input.getAttribute("maxlength")).toBe("6");85- // non-digits are stripped and the value is capped at 6 chars86- inputText(input, "12ab3456789");87- expect(input.value).toBe("123456");88- });89-90- it("Connect sends PAIR_WITH_DESKTOP with the typed code", async () => {91- const { container } = mount();92- inputText(codeInput(container), "428913");93- const connect = button(container, "Connect")!;94- expect(connect.disabled).toBe(false);95- connect.click();96- await Promise.resolve();97- expect(sendMessage).toHaveBeenCalledWith({98- type: "PAIR_WITH_DESKTOP",99- payload: { code: "428913" },100- });101- // moves into the searching state102- expect(container.textContent).toContain("Searching for helper…");103- });104-105- it("Connect stays disabled until 6 digits are entered", () => {106- const { container } = mount();107- inputText(codeInput(container), "123");108- expect(button(container, "Connect")!.disabled).toBe(true);109- inputText(codeInput(container), "123456");110- expect(button(container, "Connect")!.disabled).toBe(false);111- });112-113- it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {114- const { container } = mount();115- inputText(codeInput(container), "428913");116- button(container, "Connect")!.click();117- await Promise.resolve();118-119- pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });120-121- expect(container.textContent).toContain("Confirm this code matches on your computer:");122- expect(container.textContent).toContain("895245");123- expect(button(container, "Confirm")).toBeTruthy();124- expect(button(container, "Cancel")).toBeTruthy();125- });126-127- it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {128- const { container } = mount();129- inputText(codeInput(container), "428913");130- button(container, "Connect")!.click();131- await Promise.resolve();132- pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });133-134- button(container, "Confirm")!.click();135- await Promise.resolve();136- expect(sendMessage).toHaveBeenCalledWith({137- type: "DESKTOP_PAIR_CONFIRM",138- payload: { confirmed: true },139- });140- });141-142- it("Cancel on the SAS gate sends DESKTOP_PAIR_CONFIRM { confirmed: false } and shows mismatch copy", async () => {143- const { container } = mount();144- inputText(codeInput(container), "428913");145- button(container, "Connect")!.click();146- await Promise.resolve();147- pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });148-149- button(container, "Cancel")!.click();150- await Promise.resolve();151- expect(sendMessage).toHaveBeenCalledWith({152- type: "DESKTOP_PAIR_CONFIRM",153- payload: { confirmed: false },154- });155- expect(container.textContent).toContain("Codes don't match");156- });157-158- it("shows the combined 'not found or expired' copy on invalid_code (socket close)", async () => {159- const { container } = mount();160- inputText(codeInput(container), "000000");161- button(container, "Connect")!.click();162- await Promise.resolve();163-164- // peach-sync cannot distinguish wrong vs expired on the wire (ground truth165- // §0.3) — the background collapses both to invalid_code via a socket close.166- pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });167-168- expect(container.textContent).toContain("Code not found or expired");169- expect(button(container, "Try again")).toBeTruthy();170- });171-172- it("shows the 'no computer found' copy on no_host_found", async () => {173- const { container } = mount();174- inputText(codeInput(container), "111111");175- button(container, "Connect")!.click();176- await Promise.resolve();177- pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });178- expect(container.textContent).toContain("Couldn't find your computer");179- });180-181- it("renders Paired! and the device name on a complete result", async () => {182- const onDone = vi.fn();183- const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: Date.now() };184- const { container } = mount({ onDone });185- inputText(codeInput(container), "428913");186- button(container, "Connect")!.click();187- await Promise.resolve();188- pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });189- button(container, "Confirm")!.click();190- await Promise.resolve();191-192- pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });193- expect(container.textContent).toContain("Paired!");194- expect(container.textContent).toContain("Connected to June's iMac");195- });196-197- it("auto-dismisses after the paired state settles", async () => {198- vi.useFakeTimers();199- try {200- const onDone = vi.fn();201- const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };202- const { container } = mount({ onDone });203- inputText(codeInput(container), "428913");204- button(container, "Connect")!.click();205- await Promise.resolve();206- pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });207- button(container, "Confirm")!.click();208- await Promise.resolve();209- pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });210- vi.advanceTimersByTime(2000);211- expect(onDone).toHaveBeenCalled();212- } finally {213- vi.useRealTimers();214- }215- });216-217- it("surfaces a synchronous start failure as an error phase", async () => {218- sendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });219- const { container } = mount();220- inputText(codeInput(container), "428913");221- button(container, "Connect")!.click();222- await Promise.resolve();223- await Promise.resolve();224- expect(container.textContent).toContain("Unlock Peach first");225- });226-});