1# Change 5 — Subnet probe for the pairing port23> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module:4> `/Users/june/projects/peach-browser/src/background/desktop-pairing-probe.ts`.5> Consumed by Change 3 (`findHost`).67## Verdict: the EXISTING probe cannot be reused as-is89There IS an existing /24 probe, and its **port is already parameterized**:1011```ts1213export async function probeSubnetForPeer(14 subnet: string, 15 port: number, 16 ourLongTermPrivateKey: Uint8Array,17 ourLongTermPublicKey: Uint8Array,18 expectedRemotePubkeyHex: string,19 options?: { batch?; timeoutMs?; cooldownMs?; now? },20): Promise<SyncEndpoint | null>21```2223So "just call it with 49142" is tempting — but it is **wrong for pairing**:24251. `probeSubnetForPeer` runs a **full Noise XX handshake** against an26 *expected long-term static key* per host (`probeOne` →27 `openChirpSyncSession`, `chirp-sync-transport.ts:272-292, 51-156`). It only28 returns a host that completes Noise XX with `expectedRemotePubkeyHex`.292. During **code pairing we have no long-term pubkey yet** — we only obtain30 the helper's *ephemeral* key, and only AFTER the plaintext `PEACH_HELLO` /31 `PEACH_HELLO_ACK` exchange (ground truth §2-3). The helper on port 4914232 speaks the HELLO protocol, **not** Noise XX. `openChirpSyncSession` would33 send a Noise XX msg1 the pairing server never expects and fail every host.343. `probeSubnetForPeer` is also rate-limited per-pubkey35 (`subnetProbeOnCooldown`, `chirp-sync-transport.ts:210-217`); there is no36 pubkey to key the cooldown on during pairing.3738Therefore Change 5 supplies a **lighter, handshake-free port probe**: "is39there a WebSocket server answering on this port?" — nothing more. The HELLO40handshake then happens against the single found host in Change 3.4142## Design4344A plain `/24` scan that returns the first IP whose `ws://<ip>:<port>/`45WebSocket reaches `onopen` within a tight timeout, then immediately closes it.46No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of47`probeSubnetForPeer` (batches of 16, ~500ms timeout) so worst-case scan time48stays ~comparable.4950> NOTE: a bare port probe is intentionally undiscriminating — it returns the51> FIRST host with an open WebSocket on 49142, which during an active52> `peach-sync pair` session is the helper. The security boundary is NOT the53> probe: it is (a) the 6-digit code in `PEACH_HELLO` (a non-matching host54> closes the socket) and (b) the user-confirmed SAS. The probe only needs to55> find a candidate; it does not need to authenticate it.5657---5859## Apply-ready module — `src/background/desktop-pairing-probe.ts`6061```ts626364656667686970const DEFAULT_PROBE_TIMEOUT_MS = 500;71const DEFAULT_PROBE_BATCH = 16;7273export interface PortProbeOptions {74 batch?: number;75 timeoutMs?: number;76 77 probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;78}798081828384export async function probeSubnetForPort(85 subnet: string,86 port: number,87 options: PortProbeOptions = {},88): Promise<string | null> {89 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;90 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;91 const probe = options.probeOne ?? wsPortOpen;9293 for (let start = 1; start <= 254; start += batchSize) {94 const end = Math.min(start + batchSize - 1, 254);95 const ips: string[] = [];96 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);9798 const results = await Promise.all(99 ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),100 );101 const hit = results.find((r): r is string => r !== null);102 if (hit) return hit;103 }104 return null;105}106107108function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {109 return new Promise((resolve) => {110 let ws: WebSocket;111 let settled = false;112 const done = (ok: boolean) => {113 if (settled) return;114 settled = true;115 try { ws.close(); } catch { }116 resolve(ok);117 };118 try {119 ws = new WebSocket(`ws://${ip}:${port}/`);120 } catch {121 resolve(false);122 return;123 }124 const t = setTimeout(() => done(false), timeoutMs);125 ws.onopen = () => { clearTimeout(t); done(true); };126 ws.onerror = () => { clearTimeout(t); done(false); };127 ws.onclose = () => { clearTimeout(t); done(false); };128 });129}130```131132## Why a new module rather than extracting from `chirp-sync-transport.ts`133134Extracting a shared scan core from `probeSubnetForPeer` would force the Noise135flow and the pairing flow to share a helper whose only common part is the136nested batch loop (~10 lines). The per-host predicate differs fundamentally137(full Noise XX vs bare WS open), as does the cooldown handling. A standalone138module keeps the pairing path free of the sync transport's pubkey/cooldown139machinery and avoids touching the load-bearing, already-tested sync probe.140**No edit to `chirp-sync-transport.ts` is required.**141142## Call sites143144- **Only** new caller: Change 3's `productionDesktopPairingDeps().findHost`145 (`03-desktop-pairing-handler.md`), which calls146 `probeSubnetForPort(subnet, 49142)` with `subnet` from `getLocalSubnet()`147 (`src/shared/local-network.ts`, already used at `chirp-sync-runner.ts:69`).148- No existing call site changes.149150## Integration TODO1511521. Consider a hard ceiling on total scan time (the audio-chirp flow uses a153 90s chirp window; a pairing probe should resolve much faster — a full /24154 at 16-wide batches of 500ms is ~8s worst case, acceptable).1552. The probe finds the FIRST open WS on the port. If multiple hosts answer156 (unlikely on a home LAN during an active pairing), only the first is tried;157 the wrong-code silent-close in Change 3 then surfaces as `invalid_code`.158 A future enhancement could try each candidate in turn until one ACKs —159 out of scope for this draft.