06-sync-coordinator-desktop.md

16.5 KB · raw

rendered · source

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:

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://<cachedIp>: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:
    // 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.

   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:

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

  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.