06-sync-coordinator-desktop.md
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
// 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 itsPAIR_IDENTITY(_GROUND-TRUTH.md§4), persisted by Change 3.device.wsPort ?? DEFAULT_PORTwhereDEFAULT_PORT = 8765(chirp-sync-runner.ts:40,89). The helper announcesws_port: 8765(peach-sync/src/pairing.zig:179); Change 3 persists that, defaulting to8765if absent (03-desktop-pairing-handler.mdhunk 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.
triggerSyncIfReachable→ filter includes the desktop host (after this change) →runSyncRounds→state.runner(host)→productionSyncRunner→runWithDeps(chirp-sync-runner.ts:73-127).port = device.wsPort ?? 8765(chirp-sync-runner.ts:89).connect()(chirp-sync-runner.ts:129-173):- fast path:
getLastKnownIp(longTermPubkeyHex)→ if cached, open a session straight tows://<cachedIp>:8765(:136-145). Change 3 already primes this cache viasetLastKnownIpon a successful pairing (03-desktop-pairing-handler.mdcacheLastKnownIp), 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 againstexpectedRemoteHex = 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).
- fast path:
openChirpSyncSession(endpoint, ourPriv, ourPub, expectedRemoteHex)runs Noise XX and verifies the responder's static key equalsexpectedRemoteHex(chirp-sync-runner.ts:64-66,139-144,165-170).runChirpSyncInitiator(session, adapter)drivesSYNC_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:
- 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). - Helper persists it:
peach-sync/src/pairing.zig:212stores the browser-sentvault_id;saveVaultIdwrites{ "vaultId": vault_id }to disk (pairing.zig:241-244). On the helper's next start,main.zig:63reads it back and passes it asopts.vault_idinto the sync coordinator (main.zig:80). - 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:
The helper's// peach-sync/src/sync_coordinator.zig:141 if (!std.mem.eql(u8, parsed.value.vaultId, opts.vault_id)) { sendError(... "vault_id_mismatch") ...; }HelloMsg.vaultIdfield is camelCase (sync_coordinator.zig:59), matching the browser'sSYNC_HELLOJSON keyvaultId(chirp-sync-coordinator.ts:108). - 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.vaultIdiscomputeVaultId(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 ascomputeVaultIdis 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.
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 onlyEXT, 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": pairsANDROID,IOS,EXTand assertsseen.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.
// 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<string, unknown> = {};
(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<string, unknown> = {};
for (const k of list) if (k in store) out[k] = store[k];
return out;
},
set: async (obj: Record<string, unknown>) => { 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):
paired-devices.tsmay not export_resetPairedDevicesForTest; the existingsync-trigger.test.tsresets viamakeStorageStub()re-init eachbeforeEach. Drop the optional reset call if the symbol does not exist.- Reuse the real
makeStorageStubfromsync-trigger.test.tsinstead of the inline copy when merging into that file.ChirpSyncSessionis a class/interface fromchirp-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 againstchirp-sync-coordinator.ts:269-289).
Open integration TODOs (Change 6)
- The one-line filter widening above — the only functional change.
- Confirm
paired-devicestest-reset export name when wiring the spec. - (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.