1# Desktop Helper Pairing — Integration Contract23> **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).1011---1213## 1. File map — each draft → its destination on apply1415| 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 `../../`→`../` |3031> 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.3435---3637## 2. Integration order (dependency-correct)3839Apply in this sequence; each step compiles on its own.40411. **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.442. **Change 2** — verify-only. Confirm `"desktop"` ∈ `DeviceType`45 (`sync-types.ts:11`); optionally add the round-trip regression test. No code.463. **Change 5** — `desktop-pairing-probe.ts` (`probeSubnetForPort`). Standalone;47 Change 3 imports it.484. **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`).515. **`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`.546. **Change 4** — `DesktopPairingFlow.tsx` + `ChirpSync.tsx` entry point + the55 `peach-desktop-*` / `peach-code-input` CSS. Depends on the message contract56 (step 5) existing.577. **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).6061---6263## 3. The reconciled popup ↔ background message contract (CANONICAL)6465Reconciled across `03` (defined four messages) and `04` (uses all four; pinned66`{ confirmed }` not the brief's `{ accept }`). **03 and 04 AGREE** — 04 adopts6703's `DESKTOP_PAIR_RESULT` terminal push and 03's `{ confirmed: boolean }`68payload. The brief's three-message / `{accept:true}` shape is superseded. Final69canonical shapes:7071```ts72737475{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }767778798081{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }828384858687{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }888990{ type: "DESKTOP_PAIR_RESULT",91 payload:92 | { status: "complete", device: PairedDevice }93 | { status: "error", error: DesktopPairingError } }94```9596`DesktopPairingError` (defined in `desktop-pairing.ts`, Change 3):97```ts98type DesktopPairingError =99 | "invalid_code" 100 | "no_host_found"101 | "handshake_failed"102 | "sas_mismatch"103 | "vault_locked";104```105106Bridge: `sendMessage` (`src/shared/bridge.ts:13`) for popup→bg; pushes received107via `chrome.runtime.onMessage.addListener` (`App.tsx:601` idiom).108109### 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.115116---117118## 4. New message types — where the union lives119120The three new `type` strings (`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_CONFIRM`121inbound; `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_RESULT` outbound) are handled in the122background `switch` in **`src/background/messages.ts`**, alongside the `CHIRP_*`123cases (`messages.ts:1318-1384`). 124125**Where to add the type literals:** the existing `CHIRP_*` messages are dispatched126by string `case` in that switch; the integrator should follow the same pattern127the repo already uses for chirp messages — add the four `case` labels (two128inbound handlers in the switch; the two outbound are `chrome.runtime.sendMessage`129pushes, not switch cases). If the repo maintains a discriminated message-union130type for `sendMessage` payloads, extend it where the `CHIRP_*` variants are131declared (co-located with the chirp definitions — verify the exact union file at132integration; the chirp messages are the template). **No change to133`src/shared/types.ts` is required** — the brief's guess that device/message types134live there is wrong (`_GROUND-TRUTH.md` §0.1); message handling is in135`messages.ts` and sync types are in `sync-types.ts`.136137---138139## 5. New dependencies — NONE.140141Confirmed: 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.147148No `package.json` change.149150---151152## 6. New CSS hooks (Change 4 → `src/popup/styles/popup.css`)153154The 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**158hooks to add a small block for in `popup.css`:159160| 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` |168169Inline 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 the171CSS block is polish, not a blocker.172173---174175## 7. Estimated test count across the four draft test files176177| 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** |184185(Change 2 ships no required test; an optional `paired-devices` round-trip186regression would add 1 if the integrator wants it.)187188---189190## 8. Do existing tests need updating? — NO.191192Desktop pairing is **additive**. Verified against the existing suites:193194- **`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.210211**No existing test file requires edits.** All four draft test files are net-new212additions.213214---215216## 9. Consolidated remaining-integration TODO checklist217218Carried forward from every pass (01–06):219220- [ ] **`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`.257258---259260## 10. Version note261262The 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 versions264are 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 —267not 4.0.2. (Drafts touch only `src/drafts/`, so no version bump happens at draft268time; this is an apply-time obligation.)269270---271272## 11. Quality-bar checklist (from the brief), annotated273274| 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 |