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
↓ peach-browser-eed41d6a.tar.gzatlas at this save
files changed3 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 −0src/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.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 |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.