# Desktop Helper Pairing — Integration Contract > **AUTHORITATIVE.** The single document an integrator follows to apply the > `DESKTOP-PAIRING` draft set to the live `peach-browser` source. It reconciles > every individual draft (01–06) into one apply plan: file map, order, message > contract, new types, deps, CSS, test count, and the consolidated TODO list. > > All `file:line` citations are against source as read 2026-06-16. Where a draft > and this contract disagree, **this contract wins** (it is the reconciliation). --- ## 1. File map — each draft → its destination on apply | Draft file | What it describes | Destination on apply | Action | | --- | --- | --- | --- | | `_GROUND-TRUTH.md` | Wire contract pin | (none — reference only) | keep in repo as design record | | `README.md` | Orientation | (none — reference only) | keep as design record | | `INTEGRATION-CONTRACT.md` | This document | (none — reference only) | keep as design record | | `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 | | `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 | | `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 | | `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 | | `05-subnet-probe.md` | Handshake-free port probe | **new file** `src/background/desktop-pairing-probe.ts` | create | | `06-sync-coordinator-desktop.md` | Include desktop in sync | **edit** `src/background/sync-trigger.ts` (one filter line) | apply patch | | `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 | | `tests/03-desktop-pairing.test.ts` | Change 3 spec | `src/background/desktop-pairing.test.ts` | relocate, fix `../../`→`../` | | `tests/04-desktop-pairing-ui.test.tsx` | Change 4 spec | `src/popup/components/DesktopPairingFlow.test.tsx` | relocate (paths already written for that location) | | `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 `../../`→`../` | > On apply, the entire `src/drafts/DESKTOP-PAIRING/` tree can be deleted (use > `trash`, never `rm`) once the destinations above exist — it is design-time > scaffolding, not shipped code. --- ## 2. Integration order (dependency-correct) Apply in this sequence; each step compiles on its own. 1. **Change 1** — `vault_id` into `PAIR_IDENTITY` (`chirp-handshake.ts`, Hunks A–D). Adds `LocalIdentity.vaultId` + `PairingFrame.vault_id`. Nothing downstream type-checks against `vaultId` until this lands. 2. **Change 2** — verify-only. Confirm `"desktop"` ∈ `DeviceType` (`sync-types.ts:11`); optionally add the round-trip regression test. No code. 3. **Change 5** — `desktop-pairing-probe.ts` (`probeSubnetForPort`). Standalone; Change 3 imports it. 4. **Change 3** — `desktop-pairing.ts` (the client + `DesktopPairingError`, `DesktopPairingSocket`, message-payload shapes) + the `openBinaryWebSocket` helper. Depends on Change 1 (`vaultId` on identity) and Change 5 (`findHost`). 5. **`messages.ts` cases** — the three `PAIR_WITH_DESKTOP` / `DESKTOP_PAIR_CONFIRM` handlers and the two background pushes. Depends on Change 3's module. Gate exactly like `CHIRP_GET_LOCAL_IDENTITY`. 6. **Change 4** — `DesktopPairingFlow.tsx` + `ChirpSync.tsx` entry point + the `peach-desktop-*` / `peach-code-input` CSS. Depends on the message contract (step 5) existing. 7. **Change 6** — widen the `sync-trigger.ts` host filter to include `"desktop"`. Independent of the UI; can land any time after pairing persists a desktop device, but logically last (it's the "now actually sync it" step). --- ## 3. The reconciled popup ↔ background message contract (CANONICAL) Reconciled across `03` (defined four messages) and `04` (uses all four; pinned `{ confirmed }` not the brief's `{ accept }`). **03 and 04 AGREE** — 04 adopts 03's `DESKTOP_PAIR_RESULT` terminal push and 03's `{ confirmed: boolean }` payload. The brief's three-message / `{accept:true}` shape is superseded. Final canonical shapes: ```ts // ── popup → background ───────────────────────────────────────────────────── /** Start pairing with the typed 6-digit code. */ { type: "PAIR_WITH_DESKTOP", payload: { code: string } } // response: { ok: true } | { ok: false, error: DesktopPairingError } // (synchronous start failures — e.g. vault_locked — come back HERE; // the SAS and the terminal result arrive LATER as pushes.) /** User accepted/rejected the SAS shown by DESKTOP_PAIR_SAS. */ { type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } } // response: { ok: true } // ── background → popup (pushes via chrome.runtime.sendMessage) ────────────── /** SAS ready — show the confirm gate. */ { type: "DESKTOP_PAIR_SAS", payload: { sas: string } } /** Terminal outcome. */ { type: "DESKTOP_PAIR_RESULT", payload: | { status: "complete", device: PairedDevice } | { status: "error", error: DesktopPairingError } } ``` `DesktopPairingError` (defined in `desktop-pairing.ts`, Change 3): ```ts type DesktopPairingError = | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired) | "no_host_found" | "handshake_failed" | "sas_mismatch" | "vault_locked"; ``` Bridge: `sendMessage` (`src/shared/bridge.ts:13`) for popup→bg; pushes received via `chrome.runtime.onMessage.addListener` (`App.tsx:601` idiom). ### Reconciliation flags (resolved) - **`{ confirmed }` vs `{ accept }`:** use `{ confirmed: boolean }` (03 + 04). - **Three vs four messages:** four — `DESKTOP_PAIR_RESULT` is required so the popup can distinguish "Paired!" from a late error (04 §1). - **`invalid_code` collapses wrong+expired:** the wire cannot distinguish them (`_GROUND-TRUTH.md` §0.3) — one error, one copy string. --- ## 4. New message types — where the union lives The three new `type` strings (`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_CONFIRM` inbound; `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_RESULT` outbound) are handled in the background `switch` in **`src/background/messages.ts`**, alongside the `CHIRP_*` cases (`messages.ts:1318-1384`). **Where to add the type literals:** the existing `CHIRP_*` messages are dispatched by string `case` in that switch; the integrator should follow the same pattern the repo already uses for chirp messages — add the four `case` labels (two inbound handlers in the switch; the two outbound are `chrome.runtime.sendMessage` pushes, not switch cases). If the repo maintains a discriminated message-union type for `sendMessage` payloads, extend it where the `CHIRP_*` variants are declared (co-located with the chirp definitions — verify the exact union file at integration; the chirp messages are the template). **No change to `src/shared/types.ts` is required** — the brief's guess that device/message types live there is wrong (`_GROUND-TRUTH.md` §0.1); message handling is in `messages.ts` and sync types are in `sync-types.ts`. --- ## 5. New dependencies — NONE. Confirmed: no new npm packages. Everything reuses shipped modules: - `noiseNKInitiator` (`src/shared/noise-js.ts:71`) — already present. - `derivePairingSAS` (`src/shared/chirp-handshake.ts:171`) — already present. - `computeVaultId` (`src/shared/sync-identity.ts:75`) — already present. - `WebSocket` / `TextEncoder` / `TextDecoder` — platform globals. - Solid (`solid-js`), `vitest`, the local `view-test-utils` — already in repo. No `package.json` change. --- ## 6. New CSS hooks (Change 4 → `src/popup/styles/popup.css`) The UI **reuses** existing classes (`peach-chirp-pair`, `peach-chirp-head`, `peach-chirp-back`, `peach-chirp-headline`, `peach-chirp-sub`, `peach-chirp-action`, `peach-chirp-meter*`, `peach-btn*`, `peach-sr-only`, `peach-underline-input`, `peach-unlock-input`, `peach-label`). New **additive** hooks to add a small block for in `popup.css`: | New class | Purpose | | --- | --- | | `peach-desktop-pair` | root container modifier on the flow | | `peach-code-input` | the 6-digit code field (mono, wide letter-spacing) | | `peach-desktop-enter` / `-searching` / `-confirm` / `-paired` / `-error` | per-phase containers | | `peach-desktop-sas` | the big SAS glyph (load-bearing layout is inline for now) | | `peach-desktop-connect` | Connect button modifier | | `peach-chirp-pair-start--desktop` | the "Desktop Helper" entry button in `ChirpSync.tsx` | Inline styles in the draft cover the SAS/confirm layout in the interim (mirroring `ChirpSync.tsx`'s inline-styled SAS gate at lines 445-472), so the CSS block is polish, not a blocker. --- ## 7. Estimated test count across the four draft test files | Test file | `it()` specs | | --- | --- | | `tests/01-pair-identity-vault-id.test.ts` | **3** | | `tests/03-desktop-pairing.test.ts` | **9** | | `tests/04-desktop-pairing-ui.test.tsx` | **12** | | `tests/06-sync-coordinator-desktop.test.ts` | **2** | | **Total** | **26** | (Change 2 ships no required test; an optional `paired-devices` round-trip regression would add 1 if the integrator wants it.) --- ## 8. Do existing tests need updating? — NO. Desktop pairing is **additive**. Verified against the existing suites: - **`DeviceType` is an OPEN-ENOUGH union already.** `"desktop"` is a member (`sync-types.ts:11`); no existing test asserts a closed union *without* desktop. `paired-devices.ts` validates only field *presence*, not membership (`02-desktop-device-type.md` §"storage gate"), so existing `paired-devices.test.ts` specs are unaffected. - **No existing chirp/pairing test asserts the ABSENCE of `vault_id`.** Change 1 emits `vault_id` only when `identity.vaultId` is set; `JSON.stringify` drops it when `undefined`. The existing `chirp-handshake.test.ts` identities don't set `vaultId`, so their serialized frames are byte-identical to before — no assertion flips. - **`sync-trigger.test.ts` does NOT break (Change 6).** Its fixtures are `ANDROID`, `IOS`, `EXT` — no `desktop`. The two filter-pinning specs (`:75-81` "no mobile hosts", `:83-94` "once per mobile host → `[ANDROID, IOS]`") are unchanged by adding `"desktop"` to the filter: EXT stays excluded and no desktop fixture is present. The new desktop behaviour is covered by the NEW spec (`tests/06-...`), not by mutating an existing one. **No existing test file requires edits.** All four draft test files are net-new additions. --- ## 9. Consolidated remaining-integration TODO checklist Carried forward from every pass (01–06): - [ ] **`openBinaryWebSocket` helper** (Change 3 TODO 1) — adapt `WebSocket` to `DesktopPairingSocket`: `binaryType="arraybuffer"`, queued inbound frames, and a `closedBeforeFrame()` that distinguishes "closed, no ACK" (`invalid_code`) from "ACK received". Pattern at `chirp-sync-transport.ts:58-121`. - [ ] **`LocalChirpIdentity` vs `LocalIdentity` reconciliation** (Change 3 TODO 2) — `buildLocalIdentity` returns `LocalChirpIdentity` (`chirp-identity.ts:79`), which lacks `vaultId`/`wsPort`. Either confirm it's assignable to `LocalIdentity` after the spread `{ ...base, vaultId }`, or add `vaultId?` to `LocalChirpIdentity` directly. Extension never sets `wsPort` (never a host) — leaving it undefined is correct. - [ ] **`messages.ts` gating** (Change 3 §Wiring / Change 4 TODO 4) — gate the three cases on `vault.isUnlocked()` and `requirePro("sync")` exactly like `CHIRP_GET_LOCAL_IDENTITY` (`messages.ts:1319-1321`). Master key obtained via `getRootKeyHex()` (stable root key, SEV-2-B), zeroed in `finally`. - [ ] **Single-flight confirm guard** (Change 3 TODO 4 / Change 4 TODO 5) — one in-flight pairing per worker; store the pending `confirm` resolver in a module-level variable, cleared on complete/error. On popup `onCancel` mid-flow, release the resolver (a `DESKTOP_PAIR_CONFIRM { confirmed:false }` or a cancel message). - [ ] **Timeouts** (Change 3 TODO 5 / Change 5 TODO 1) — wrap `findHost`/`receive` with ceilings (e.g. ~10s probe, ~5s per receive) so a stalled helper surfaces `no_host_found`/`handshake_failed` instead of hanging the popup. Idiom at `chirp-sync-transport.ts:174`. Pairing-port probe worst case is ~8s for a /24 at 16-wide 500ms batches. - [ ] **Test path adjustments** — fix `../../`→`../` prefixes in the relocated 03 and 06 specs; reuse the real `makeStorageStub` from `sync-trigger.test.ts`; confirm `paired-devices` test-reset export name; the 04 spec's paths are already written for its destination. - [ ] **CSS block** for `peach-desktop-*` / `peach-code-input` in `popup.css` (Change 4 TODO 3) — polish; inline styles cover the load-bearing layout. - [ ] **ChirpSync.tsx entry point** (Change 4 TODO 2) — add the "Desktop Helper (enter code)" affordance to `restView()` action block (`ChirpSync.tsx:412-423`) and render `` from `pairingView()`. - [ ] **Change 6 filter** — the one-line `sync-trigger.ts:104-108` widening. - [ ] **(Optional) Change 2 regression test** — desktop device round-trips through `paired-devices.ts`. --- ## 10. Version note The brief said bump **4.0.1 → 4.0.2**. But dev is **already at 4.0.2** (`manifest.json` `"version": "4.0.2"`, verified 2026-06-16). Repeated versions are rejected by the stores (auto-memory: "every code change is a version bump"). **Integration must bump 4.0.2 → 4.0.3** across all three manifest targets (`manifest.json`, `manifest.firefox.json`, and the mobile manifest) on apply — not 4.0.2. (Drafts touch only `src/drafts/`, so no version bump happens at draft time; this is an apply-time obligation.) --- ## 11. Quality-bar checklist (from the brief), annotated | Quality bar | Status in drafts | Integrator must verify | | --- | --- | --- | | **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 | | **`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` | | **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 | | **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 | | **`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 | | **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 | | **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 | | **No new deps** | ✅ §5 — none | confirm `package.json` untouched |