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
15 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 −226
manifest.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": {

view file

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",

view file

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",

view file

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).

view file at the parent

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.**

view file at the parent

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.

view file at the parent

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.

view file at the parent

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.

view file at the parent

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.

view file at the parent

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 |

view file at the parent

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.

view file at the parent

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".

view file at the parent

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-});

view file at the parent

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-});

view file at the parent

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-});

view file at the parent