eed41d6add18

add Change 6 draft + INTEGRATION-CONTRACT + README — Change 6 needs sync-trigger.ts:104 filter widened (+|| deviceType==='desktop'), not a no-op; sync uses Noise XX on 8765 (pairing uses NK on 49142); vault_id flows pairing→SYNC_HELLO match verified; reconcile 4-message contract; 26 specs, additive (no existing test changes), integration bumps 4.0.2→4.0.3

time
2026-06-17 01:55 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
desktop helper pairing drafts
parent
4fba012374b9
3 files src/drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md+376 −0src/drafts/DESKTOP-PAIRING/INTEGRATION-CONTRACT.md+283 −0src/drafts/DESKTOP-PAIRING/README.md+53 −0
src/drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md+376 −0
@@ -0,0 +1,376 @@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

src/drafts/DESKTOP-PAIRING/INTEGRATION-CONTRACT.md+283 −0
@@ -0,0 +1,283 @@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

src/drafts/DESKTOP-PAIRING/README.md+53 −0
@@ -0,0 +1,53 @@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