# Change 6 — Include `"desktop"` peers in sync sessions > **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Target file (apply-ready patch): > `/Users/june/projects/peach-browser/src/background/sync-trigger.ts`. > Nothing here is applied — it is an apply-ready draft. ## VERDICT: NOT a pure no-op. One small, additive host-filter widening is required. The brief assumed the sync runner/coordinator might branch on device type and exclude `"desktop"`. The **runner and coordinator are in fact generic** — they never look at `deviceType` — BUT the **dispatcher's host filter (`sync-trigger.ts:104-108`) hard-codes `"android" | "ios"` and silently drops a `"desktop"` peer.** So a desktop-paired device would be persisted correctly and would sync correctly *if reached*, but `triggerSyncIfReachable` never hands it to the runner. **Change 6 = add `"desktop"` to that one filter.** Everything else is verification-only. ### Deciding citation ```ts // src/background/sync-trigger.ts:103-108 const devices = await listPairedDevices(); const hosts = devices.filter( (d) => (d.deviceType === "android" || d.deviceType === "ios") && // ← desktop excluded d.trustSource === "direct", ); ``` `"desktop"` is not in the disjunction, so a desktop peer is filtered out before `runSyncRounds` (`sync-trigger.ts:128`) ever iterates. This is the **only** device-type gate on the sync path. --- ## What is already generic (verification-only — no change) ### 1. The runner iterates whatever hosts it is given, with no type gate `runSyncRounds` simply loops the filtered hosts and calls the runner per host (`sync-trigger.ts:128-144`). It reads only `host.deviceId` and passes the whole `PairedDevice` to `state.runner(host)`. No `deviceType` reference. ### 2. `productionSyncRunner` / `runWithDeps` are type-agnostic `runWithDeps` (`chirp-sync-runner.ts:80-127`) needs only: - `device.longTermPublicKeyHex` (`:85,88`) — the desktop helper supplies this in its `PAIR_IDENTITY` (`_GROUND-TRUTH.md` §4), persisted by Change 3. - `device.wsPort ?? DEFAULT_PORT` where `DEFAULT_PORT = 8765` (`chirp-sync-runner.ts:40,89`). The helper announces `ws_port: 8765` (`peach-sync/src/pairing.zig:179`); Change 3 persists that, defaulting to `8765` if absent (`03-desktop-pairing-handler.md` hunk 8). **The runner's default and the helper's announced port are the same 8765.** There is **no** `deviceType` branch anywhere in `chirp-sync-runner.ts`. ### 3. The coordinator never inspects device type `runChirpSyncInitiator` (`chirp-sync-coordinator.ts:88-244`) is driven entirely by the `ChirpSyncVaultAdapter` and the wire protocol. It contains zero references to `deviceType` or to any peer identity beyond the `vaultId`/`deviceId` exchanged in `SYNC_HELLO`. --- ## Exact connection path a desktop peer takes (post-pairing, for SYNC) This is **phase 2 / sync** — distinct from pairing. The handshake used here is **Noise XX over the persistent sync port**, NOT the Noise NK / port-49142 path used during pairing. 1. `triggerSyncIfReachable` → filter includes the desktop host (after this change) → `runSyncRounds` → `state.runner(host)` → `productionSyncRunner` → `runWithDeps` (`chirp-sync-runner.ts:73-127`). 2. `port = device.wsPort ?? 8765` (`chirp-sync-runner.ts:89`). 3. `connect()` (`chirp-sync-runner.ts:129-173`): - **fast path:** `getLastKnownIp(longTermPubkeyHex)` → if cached, open a session straight to `ws://:8765` (`:136-145`). Change 3 already primes this cache via `setLastKnownIp` on a successful pairing (`03-desktop-pairing-handler.md` `cacheLastKnownIp`), so the first sync can skip the probe. - **fallback:** `probeSubnetForPeer(subnet, 8765, …)` over the /24 (`:156-162`) — this is the **Noise-XX** probe (`chirp-sync-transport.ts`), authenticating each host against `expectedRemoteHex = device.longTermPublicKeyHex`. (Note: this is the sync-transport probe, NOT the handshake-free pairing probe from Change 5 — different phase, different port, different handshake.) - on success, `setLastKnownIp(...)` caches the IP (`:171`). 4. `openChirpSyncSession(endpoint, ourPriv, ourPub, expectedRemoteHex)` runs **Noise XX** and verifies the responder's static key equals `expectedRemoteHex` (`chirp-sync-runner.ts:64-66,139-144,165-170`). 5. `runChirpSyncInitiator(session, adapter)` drives `SYNC_HELLO → … → SYNC_DONE`. ### Which handshake for which phase (pinned) | Phase | Port | Handshake | Browser role | Citation | | --- | --- | --- | --- | --- | | **Pairing** | 49142 | Noise **NK** | initiator | `_GROUND-TRUTH.md` §3; `pairing.zig:153-154` | | **Sync** | 8765 | Noise **XX** | initiator (WS client) | `chirp-sync-runner.ts:5,64-66`; `chirp-sync-coordinator.ts:88-90` | Verified: SYNC uses **Noise XX** (mutual static-key auth, both ends know each other's long-term key from pairing), NOT NK. The runner header documents "Noise XX session" (`chirp-sync-runner.ts:3`). --- ## `vault_id` flows correctly into SYNC_HELLO validation End-to-end chain, verified across both repos: 1. **Pairing (Change 1):** the browser sends `PAIR_IDENTITY.vault_id = computeVaultId(masterKey)` — 64-char lowercase hex (`sync-identity.ts:75-82`; `01-vault-id-in-pair-identity.md`). 2. **Helper persists it:** `peach-sync/src/pairing.zig:212` stores the browser-sent `vault_id`; `saveVaultId` writes `{ "vaultId": vault_id }` to disk (`pairing.zig:241-244`). On the helper's next start, `main.zig:63` reads it back and passes it as `opts.vault_id` into the sync coordinator (`main.zig:80`). 3. **Sync HELLO compare:** the browser sends `SYNC_HELLO { vaultId: await vault.computeVaultId(), … }` (`chirp-sync-coordinator.ts:102,107-113`). The helper compares it byte-for-byte against its stored value: ```zig // peach-sync/src/sync_coordinator.zig:141 if (!std.mem.eql(u8, parsed.value.vaultId, opts.vault_id)) { sendError(... "vault_id_mismatch") ...; } ``` The helper's `HelloMsg.vaultId` field is **camelCase** (`sync_coordinator.zig:59`), matching the browser's `SYNC_HELLO` JSON key `vaultId` (`chirp-sync-coordinator.ts:108`). 4. **Browser-side check too:** the browser independently rejects a mismatched peer HELLO (`chirp-sync-coordinator.ts:118-122`). **Both sides compute the SAME `vaultId` for the same vault** because both call `computeVaultId(masterKey)` over the identical master key material — the browser sent the value at pairing, the helper stored exactly those bytes, and the browser recomputes the identical value at sync time. **They match by construction.** No transformation, no case folding, no truncation differs between the pairing value and the SYNC_HELLO value (both are the full 64-char lowercase-hex BLAKE3 digest). > Subtlety worth noting for the integrator: the browser's `SYNC_HELLO.vaultId` > is `computeVaultId(masterKey)` recomputed at sync time, NOT a value read back > from the paired-device record. The paired-device record stores the *peer's* > identity, not our vault id. So the match relies on the same master key being > present at both pairing time and sync time — which holds for the local vault. > The desktop helper, conversely, replays the value we GAVE it. The equality is > therefore "our recomputed id == the id we previously sent the helper" — stable > as long as `computeVaultId` is deterministic over the root key (it is — > `sync-identity.ts:75-82`, SEV-2-B uses the stable root key). --- ## The patch (apply to `src/background/sync-trigger.ts`) ### Hunk — widen the host filter to include `"desktop"` Context: lines 103-108. ```diff const devices = await listPairedDevices(); const hosts = devices.filter( (d) => - (d.deviceType === "android" || d.deviceType === "ios") && + (d.deviceType === "android" || + d.deviceType === "ios" || + d.deviceType === "desktop") && d.trustSource === "direct", ); ``` That is the entire functional change for Change 6. ### Why `trustSource === "direct"` still holds for desktop Change 3 persists the desktop device with `trustSource: "direct"` (`03-desktop-pairing-handler.md`, the `PairedDevice` built in step 8 sets `trustSource: "direct"`). So the desktop peer passes the second predicate unchanged — no further edit needed. ### Why `"extension"` stays excluded (and SHOULD) The filter still omits `"extension"`: two browser extensions do not run a sync *server* (no `wsPort`, never a host). This change adds only `"desktop"`, the third device type that runs a listening sync server (port 8765), alongside the two mobile types. This matches the existing intent: the filter selects *reachable sync hosts*, and desktop is one. --- ## Does any EXISTING sync-trigger test break? — NO. `src/background/sync-trigger.test.ts` uses three fixtures: `ANDROID`, `IOS` (`:44-58`) and `EXT` (extension, `:61-67`). **There is no `desktop` fixture.** The two assertions that pin the filter are: - `:75-81` — "does not call the runner when no mobile hosts are paired": pairs only `EXT`, asserts the runner is NOT called. Adding `"desktop"` to the filter does not change this (EXT is still excluded; no desktop device present). - `:83-94` — "calls the runner once per mobile host": pairs `ANDROID`, `IOS`, `EXT` and asserts `seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId])`. Adding `"desktop"` to the filter does not change this either — EXT remains excluded and no desktop device is present, so the seen set is unchanged. **Conclusion: the filter widening is additive and breaks no existing test.** The new behaviour is covered by the new spec below, which adds a `desktop` fixture. --- ## Draft test file — `tests/06-sync-coordinator-desktop.test.ts` Two specs, matching the repo's `sync-trigger.test.ts` idiom (the injected-runner seam via `setSyncRunner`, `upsertPairedDevice`, the storage stub). On apply this relocates to `src/background/sync-trigger.desktop.test.ts` (or is merged into `sync-trigger.test.ts`); the `../../` import prefixes become `../` then. ```ts // Tests for Change 6 — desktop peers participate in sync sessions. // // DRAFT / apply-ready. On apply, relocate to // src/background/sync-trigger.desktop.test.ts // and change the "../../background/..." / "../../shared/..." prefixes to // "./..." / "../shared/...". The fixtures and idiom mirror the existing // sync-trigger.test.ts (injected runner via setSyncRunner, storage stub). // // Spec 1 asserts the host filter (sync-trigger.ts:104) includes "desktop". // Spec 2 asserts the browser's SYNC_HELLO carries a vaultId equal to the // desktop helper's stored vault_id (== computeVaultId of the same master key), // which is what peach-sync compares byte-for-byte (sync_coordinator.zig:141). import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { triggerSyncIfReachable, setSyncRunner, _resetSyncTriggerForTest, } from "../../background/sync-trigger"; import { upsertPairedDevice, _resetPairedDevicesForTest, } from "../../background/paired-devices"; import { computeVaultId } from "../../shared/sync-identity"; import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "../../background/chirp-sync-coordinator"; import type { ChirpSyncSession } from "../../shared/chirp-sync-transport"; import type { ChirpSyncMessage } from "../../shared/sync-types"; import type { PairedDevice } from "../../shared/sync-types"; // NOTE (integrator): sync-trigger.test.ts already exports a makeStorageStub() // helper inline. Reuse it (or its chrome.storage.local fake) here rather than // re-declaring; reproduced minimally below so this draft is self-contained. function makeStorageStub(): void { const store: Record = {}; (globalThis as unknown as { chrome: unknown }).chrome = { storage: { local: { get: async (keys?: string | string[]) => { if (!keys) return { ...store }; const list = Array.isArray(keys) ? keys : [keys]; const out: Record = {}; for (const k of list) if (k in store) out[k] = store[k]; return out; }, set: async (obj: Record) => { Object.assign(store, obj); }, remove: async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k]; }, }, }, runtime: { lastError: undefined }, }; } const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff)); const DESKTOP: PairedDevice = { deviceId: "desktop-001", deviceName: "June's iMac", deviceType: "desktop", longTermPublicKeyHex: "a".repeat(64), trustSource: "direct", wsPort: 8765, pairedAt: 1700000000000, }; beforeEach(() => { makeStorageStub(); _resetSyncTriggerForTest(); _resetPairedDevicesForTest?.(); }); afterEach(() => { vi.restoreAllMocks(); }); describe("Change 6 — desktop peers in sync", () => { it("desktop peer is included in sync session attempts", async () => { await upsertPairedDevice(DESKTOP); const seen: string[] = []; setSyncRunner(async (device) => { seen.push(device.deviceId); return { ok: true, entriesReceived: 0, entriesSent: 0 }; }); await triggerSyncIfReachable({ reason: "manual" }); // Before Change 6, the filter (sync-trigger.ts:104) only matched // android|ios, so the runner was never called for a desktop host. expect(seen).toEqual([DESKTOP.deviceId]); }); it("SYNC_HELLO vaultId matches the desktop helper's stored vault_id", async () => { // The helper stores exactly the vault_id the browser sent at pairing // (peach-sync pairing.zig:212), which is computeVaultId(masterKey). At // sync time the browser sends SYNC_HELLO { vaultId: computeVaultId(...) }. // peach-sync compares the two byte-for-byte (sync_coordinator.zig:141). // Here we capture the browser's outbound SYNC_HELLO and assert its vaultId // equals the canonical computeVaultId(MASTER) — i.e. the value the helper // holds. const ourVaultId = await computeVaultId(MASTER); // Capture frames the coordinator sends; reply just enough to reach the // equal-version SYNC_DONE shortcut so the round completes cleanly. const sent: ChirpSyncMessage[] = []; const inbound: ChirpSyncMessage[] = [ { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: "desktop-001", version: 7, deviceTime: Date.now() }, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 }, ]; let rx = 0; const session: ChirpSyncSession = { send: (bytes: Uint8Array) => { sent.push(JSON.parse(new TextDecoder().decode(bytes))); }, receive: async () => new TextEncoder().encode(JSON.stringify(inbound[rx++])), close: async () => {}, } as unknown as ChirpSyncSession; const adapter: ChirpSyncVaultAdapter = { computeVaultId: async () => ourVaultId, getOurDeviceId: async () => "ext-local", getLocalVersion: async () => 7, // equal version → SYNC_DONE shortcut buildEntryManifest: async () => ({}), buildTombstoneManifest: async () => ({}), getEncryptedEntries: async () => ({}), applyEntries: async () => {}, applyTombstones: async () => {}, }; const outcome = await runChirpSyncInitiator(session, adapter); const hello = sent.find((m) => m.type === "SYNC_HELLO") as | { type: "SYNC_HELLO"; vaultId: string } | undefined; expect(hello).toBeTruthy(); // This is the exact value peach-sync compares against opts.vault_id. expect(hello!.vaultId).toBe(ourVaultId); expect(hello!.vaultId).toMatch(/^[0-9a-f]{64}$/); expect(outcome.status).toBe("completed"); }); }); ``` > **INTEGRATOR TODO (test wiring):** > 1. `paired-devices.ts` may not export `_resetPairedDevicesForTest`; the > existing `sync-trigger.test.ts` resets via `makeStorageStub()` re-init each > `beforeEach`. Drop the optional reset call if the symbol does not exist. > 2. Reuse the real `makeStorageStub` from `sync-trigger.test.ts` instead of the > inline copy when merging into that file. > 3. `ChirpSyncSession` is a class/interface from `chirp-sync-transport.ts`; the > cast above stubs only the three methods the coordinator uses > (`send`/`receive`/`close`). Confirm those are the only members touched > (verified against `chirp-sync-coordinator.ts:269-289`). --- ## Open integration TODOs (Change 6) 1. The one-line filter widening above — the only functional change. 2. Confirm `paired-devices` test-reset export name when wiring the spec. 3. (Optional) a CONTINUITY note that desktop now participates in the same 30s-timeout sync round (`chirp-sync-coordinator.ts:25`) as mobile — no per-type timeout tuning is introduced.