dsh-cherry-bridge 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pairing.ts CHANGED
@@ -2,12 +2,15 @@ import { hostname } from 'node:os'
2
2
  import { WebSocket } from 'ws'
3
3
  import { connectSecureChannel, RemoteSocketStream } from '@cherrystudio/remote-transport'
4
4
  import type { ChannelOptions, SecureChannel } from '@cherrystudio/remote-transport'
5
- import { remoteConnectPath } from '@cherrystudio/remote-protocol'
5
+ import { peerIdFromString } from '@libp2p/peer-id'
6
+ import type { z } from 'zod'
7
+ import { directEndpointUrl, pairingQrSchema } from '@cherrystudio/remote-protocol'
8
+ import type { WsDialOptions } from './connection.js'
6
9
  import type { DeviceRecord } from './deviceStore.js'
7
10
  import type { DeviceStore } from './deviceStore.js'
8
11
  import { JsonRpcClient, JsonRpcRequestError } from './rpc/jsonRpcClient.js'
9
12
  import { toRemoteSocket } from './rpc/wsAdapter.js'
10
- import { urlHost } from './urlHost.js'
13
+ import { boundedJoin, clipText } from './text.js'
11
14
 
12
15
  /**
13
16
  * The pairing half of the bridge (spec §3.3): turn the desktop's pairing
@@ -52,26 +55,75 @@ export interface PairWithPayloadOptions {
52
55
  pollTimeoutMs?: number
53
56
  /** Per-candidate dial+handshake timeout (default 10s). */
54
57
  connectTimeoutMs?: number
58
+ /**
59
+ * Aggregate budget for the candidate DIAL walk (default 30s). It bounds the
60
+ * SUM of dial attempts, never a single attempt (that is `connectTimeoutMs`),
61
+ * and it does not cover the claim/poll phase, which keeps its own fresh,
62
+ * claim-anchored race guard (`pollTimeoutMs`).
63
+ */
64
+ dialBudgetMs?: number
55
65
  /** Locally supported protocol versions (default [1]). */
56
66
  supportedProtocolVersions?: readonly number[]
67
+ /**
68
+ * The websocket options every dial uses — only TLS trust (`rejectUnauthorized`
69
+ * / `ca`) matters (issue #126: a TLS-proxied endpoint presents a certificate
70
+ * the client must be able to trust, e.g. a private CA; the nested
71
+ * `{ tls: {…} }` shape `https` accepts is NOT read by `ws`). Absent means
72
+ * Node's default trust store.
73
+ */
74
+ wsOptions?: WsDialOptions
57
75
  }
58
76
 
59
77
  // ---------------------------------------------------------------------------
60
- // Payload validation (spec §3.3 step 1, ruling 1).
78
+ // Payload validation (spec §3.3 step 1, ruling 1; widened to the v3 custom-
79
+ // address QR by issue #124 — upstream `bc570d2b9`, desktop 2.1.8).
61
80
  //
62
- // Field caps mirror the protocol package's `values.ts`: `opaqueId` is a
63
- // non-empty unicode string of at most 256 code units, and the claim's
64
- // `deviceName` is capped at 64. A payload field over its cap would be rejected
65
- // by the peer's strict schema anyway, so rejecting it here is the same verdict
66
- // one round-trip earlier, with a message the user can act on.
81
+ // The FIELD CHECKS are the pinned protocol package's own `pairingQrSchema`: a
82
+ // v2|v3 discriminated union whose v2 arm is `ips` (IP LITERALS only — the
83
+ // package's fixture calls a hostname there "DNS masquerading as IP") + `port`,
84
+ // and whose v3 arm is complete `endpoints` (`host`, `port`, `security`).
85
+ // Hand-rolling the checks is what made a 2.1.8 invitation refuse
86
+ // `invalid-payload` for the whole life of the 0.5.0 pin; consuming the schema
87
+ // means the next widening ships with the pin, not with a parser edit. Four
88
+ // local rules stay:
89
+ // - the raw size cap (an oversized blob must not be parsed at all),
90
+ // - v2's bracketed-IPv6 NORMALIZATION (ruling 2) — the published v2 arm
91
+ // validates BARE literals, so brackets are stripped before the parse and
92
+ // the archive keeps the bare form the connection layer re-brackets, and
93
+ // - the `unicodeText` (lone-surrogate) rule on `invitationId` /
94
+ // `invitationSecret` / `desktopIdentity`: the protocol values face
95
+ // enforces it, the pairing QR arm does not, and refusing here keeps the
96
+ // v2 error face identical one round trip earlier, and
97
+ // - the `desktopIdentity` PEER-ID DECODE (issue #153): the schema types the
98
+ // pin as a bounded string, while the transport only decodes it POST-OPEN,
99
+ // so a junk pin would otherwise cost a dial of every candidate and surface
100
+ // as a decoder message. `peerIdFromString` is that same decoder, run here.
101
+ // The error face is `invalid-payload` naming the failing FIELD PATHS only:
102
+ // zod issues carry the `input`, so values never enter the message — an
103
+ // `invitationSecret` must not surface in a user-visible error.
67
104
  // ---------------------------------------------------------------------------
68
105
 
69
- const PAYLOAD_TYPE = 'cherry-studio-pair'
70
- const PAYLOAD_VERSION = 2
71
106
  /** Overall payload ceiling: a QR code never approaches this. */
72
107
  const MAX_PAYLOAD_BYTES = 10 * 1024
73
- /** `opaqueId` cap for invitationId/invitationSecret/desktopIdentity. */
74
- const OPAQUE_ID_MAX = 256
108
+ /**
109
+ * Bounds the unknown-key WARNING (issue #155). The keys in that warning are
110
+ * payload-CONTROLLED NAMES, and `MAX_PAYLOAD_BYTES` is no bound at all on the
111
+ * line: a 10 KiB payload carrying 32 unknown keys of ~256 characters used to
112
+ * join into ~8.2 KB of attacker-chosen text (the measured fixture is 8,192
113
+ * characters of keys and an 8,312-character line) on the operator's log.
114
+ *
115
+ * `UNKNOWN_FIELDS_MAX` is the joined list's budget — the same 240-character
116
+ * working budget as `LOG_TEXT_MAX`, so "one bounded peer detail" means one
117
+ * number for one detail-sized face (pairing's `FAILURE_SEGMENT_MAX` is the
118
+ * same NUMBER bounding a different quantity: a whole `<label>: <detail>`).
119
+ * `UNKNOWN_FIELD_MAX` clips ONE
120
+ * key: 64 characters is longer than any field name the protocol defines, so a
121
+ * clipped key is always a malformed or hostile payload, never a real typo the
122
+ * operator needs whole. A list that stops early names the count (`(+K more)`);
123
+ * entries are never dropped silently.
124
+ */
125
+ const UNKNOWN_FIELDS_MAX = 240
126
+ const UNKNOWN_FIELD_MAX = 64
75
127
  /** The claim's deviceName cap; a hostname may reach 253, so it is truncated. */
76
128
  const DEVICE_NAME_MAX = 64
77
129
  /** Bounds a single candidate host string (IPv6 literals are ~45). */
@@ -79,21 +131,45 @@ const HOST_MAX = 256
79
131
  /** Matches the protocol's unicodeText: no lone UTF-16 surrogates. */
80
132
  const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
81
133
 
82
- /** Fields the implementation consumes; everything else in the payload is ignored. */
83
- const KNOWN_FIELDS = new Set([
134
+ /**
135
+ * The keys each QR ARM consumes (selected by `v`). A payload field outside
136
+ * the MATCHED arm's set is ignored with a warning — the desktop names hosts
137
+ * the design does not use. One union set would silently swallow a v2 payload
138
+ * carrying a spurious `endpoints` (or v3 carrying `ips`/`port`); the arm
139
+ * split keeps the pre-schema warning behavior.
140
+ */
141
+ const V2_KEYS = new Set([
84
142
  'v',
85
143
  't',
86
- 'port',
144
+ 'name',
87
145
  'ips',
146
+ 'port',
147
+ 'invitationId',
148
+ 'invitationSecret',
149
+ 'desktopIdentity',
150
+ 'protocolVersions',
151
+ ])
152
+ const V3_KEYS = new Set([
153
+ 'v',
154
+ 't',
155
+ 'name',
156
+ 'endpoints',
88
157
  'invitationId',
89
158
  'invitationSecret',
90
159
  'desktopIdentity',
91
160
  'protocolVersions',
92
161
  ])
93
162
 
94
- interface PairingPayload {
95
- hosts: string[]
163
+ /** One dial candidate; a v3 endpoint carries its OWN port and scheme. */
164
+ interface PairCandidate {
165
+ host: string
96
166
  port: number
167
+ security: 'ws' | 'wss'
168
+ }
169
+
170
+ interface PairingPayload {
171
+ /** Ordered dial candidates — every entry is attempted before `unreachable`. */
172
+ candidates: PairCandidate[]
97
173
  invitationId: string
98
174
  invitationSecret: string
99
175
  desktopIdentity: string
@@ -119,12 +195,14 @@ function isUnicodeText(value: string): boolean {
119
195
  return !LONE_SURROGATE_RE.test(value)
120
196
  }
121
197
 
122
- function checkString(value: unknown, field: string, max: number): string {
123
- if (typeof value !== 'string') invalid(`payload field "${field}" must be a string`)
124
- if (value.length === 0) invalid(`payload field "${field}" must not be empty`)
125
- if (value.length > max) invalid(`payload field "${field}" exceeds ${max} characters`)
126
- if (!isUnicodeText(value)) invalid(`payload field "${field}" is not valid unicode text`)
127
- return value
198
+ /** Rejections quote issue PATHS (deduplicated, sorted). The "never inputs"
199
+ * promise is the CALL SITE's property — only `issue.path` is read here; an
200
+ * editor reaching for `issue.message` or `String(error)` must re-check what
201
+ * zod embeds there before keeping that promise. */
202
+ function schemaIssuePaths(error: z.ZodError): string {
203
+ return [...new Set(error.issues.map((issue) => issue.path.join('.') || 'payload'))]
204
+ .sort()
205
+ .join(', ')
128
206
  }
129
207
 
130
208
  /**
@@ -152,9 +230,9 @@ function normalizeHost(raw: string): string {
152
230
  }
153
231
 
154
232
  /**
155
- * Parses and validates the pairing payload. Unknown fields are ignored (with a
156
- * warning) — the payload carries host naming the design does not consume — but
157
- * the fields on the manifest are judged against their protocol caps.
233
+ * Parses and validates the pairing payload against the published
234
+ * `pairingQrSchema` (v2 or v3). Unknown fields are ignored (with a warning);
235
+ * every field judgement is the schema's, capped by the local size rule.
158
236
  */
159
237
  function parsePayload(payload: string): PairingPayload {
160
238
  // Size is judged on the raw string first: an oversized blob must not be
@@ -174,47 +252,97 @@ function parsePayload(payload: string): PairingPayload {
174
252
  }
175
253
 
176
254
  const raw = parsed as Record<string, unknown>
177
- if (raw['v'] !== PAYLOAD_VERSION) {
178
- invalid(`payload version must be ${PAYLOAD_VERSION}`)
179
- }
180
- if (raw['t'] !== PAYLOAD_TYPE) {
181
- invalid(`payload type must be "${PAYLOAD_TYPE}"`)
255
+
256
+ // Normalize v2 bracketed IPv6 entries BEFORE the schema sees them;
257
+ // non-string entries pass through for the schema to judge (the published
258
+ // arm rejects them with the same `invalid-payload` verdict).
259
+ const ips = raw['ips']
260
+ const input = Array.isArray(ips)
261
+ ? {
262
+ ...raw,
263
+ ips: ips.map((entry) => (typeof entry === 'string' ? normalizeHost(entry) : entry)),
264
+ }
265
+ : raw
266
+
267
+ const result = pairingQrSchema.safeParse(input)
268
+ if (!result.success) {
269
+ invalid(`payload rejected by the pairing QR schema (v2 or v3): ${schemaIssuePaths(result.error)}`)
182
270
  }
271
+ const qr = result.data
183
272
 
184
- const ignored = Object.keys(raw).filter((key) => !KNOWN_FIELDS.has(key))
273
+ // Unknown-key warning, judged against the MATCHED arm's keys (see the
274
+ // V2_KEYS/V3_KEYS docblock): after the parse, so a payload that fails the
275
+ // schema is never warned-and-then-rejected — its verdict is the rejection.
276
+ const ignored = Object.keys(raw).filter((key) => !(qr.v === 2 ? V2_KEYS : V3_KEYS).has(key))
185
277
  if (ignored.length > 0) {
186
- console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${ignored.join(', ')}`)
278
+ console.warn(
279
+ `[cherry-remote] pairing payload ignored unknown field(s): ${boundedJoin(ignored, {
280
+ max: UNKNOWN_FIELDS_MAX,
281
+ itemMax: UNKNOWN_FIELD_MAX,
282
+ })}`,
283
+ )
187
284
  }
188
285
 
189
- const port = raw['port']
190
- if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
191
- invalid('payload field "port" must be an integer in 1..65535')
286
+ const candidates: PairCandidate[] =
287
+ qr.v === 2
288
+ ? qr.ips.map((host) => ({ host, port: qr.port, security: 'ws' as const }))
289
+ : qr.endpoints.map((endpoint) => ({
290
+ host: endpoint.host,
291
+ port: endpoint.port,
292
+ security: endpoint.security,
293
+ }))
294
+
295
+ // ADDRESS rules the schema cannot express (issue #135 review). The pinned
296
+ // schema already constrains a v3 endpoint port, but that is the PIN's
297
+ // property, not ours — a widening must not reopen the hole — and it has no
298
+ // uniqueness rule at all. Judged HERE — for BOTH arms, after normalization,
299
+ // before any socket — because an invalid address must never consume the
300
+ // invitation, and must never reach `store.upsert` after an approval (where a
301
+ // validation failure would surface as `unreachable` about an invitation
302
+ // already spent).
303
+ const seenHosts = new Set<string>()
304
+ for (const candidate of candidates) {
305
+ if (!Number.isInteger(candidate.port) || candidate.port < 1 || candidate.port > 65_535) {
306
+ invalid(`payload endpoint "${candidate.host}": port must be an integer in 1..65535`)
307
+ }
308
+ if (seenHosts.has(candidate.host)) {
309
+ // One host, one port: `DeviceRecord` v4 stores `ports` per host, so a
310
+ // same-host/two-port invitation cannot be represented — refuse it by
311
+ // name instead of silently keeping one port. (The desktop's v2 builder
312
+ // publishes one shared port per address; a v3 builder emitting one host
313
+ // twice was not observed upstream, so this refusal is ours.)
314
+ invalid(`payload endpoints must not repeat host "${candidate.host}"`)
315
+ }
316
+ seenHosts.add(candidate.host)
192
317
  }
193
318
 
194
- const ips = raw['ips']
195
- if (!Array.isArray(ips) || ips.length === 0) {
196
- invalid('payload field "ips" must be a non-empty array')
319
+ // The lone-surrogate rule is ours (see the header): the claim RPC would
320
+ // carry such a value to the peer's unicodeText params face and be refused
321
+ // there; naming the field now is the same verdict one round trip earlier.
322
+ for (const field of ['invitationId', 'invitationSecret', 'desktopIdentity'] as const) {
323
+ if (!isUnicodeText(qr[field])) invalid(`payload field "${field}" is not valid unicode text`)
197
324
  }
198
- const hosts = ips.map((entry, index) =>
199
- normalizeHost(checkString(entry, `ips[${index}]`, HOST_MAX)),
200
- )
201
-
202
- const invitationId = checkString(raw['invitationId'], 'invitationId', OPAQUE_ID_MAX)
203
- const invitationSecret = checkString(raw['invitationSecret'], 'invitationSecret', OPAQUE_ID_MAX)
204
- const desktopIdentity = checkString(raw['desktopIdentity'], 'desktopIdentity', OPAQUE_ID_MAX)
205
325
 
206
- const versions = raw['protocolVersions']
207
- if (!Array.isArray(versions) || versions.length === 0) {
208
- invalid('payload field "protocolVersions" must be a non-empty array')
326
+ // The PIN must DECODE here (issue #153). The published schema types
327
+ // `desktopIdentity` as a 1..256-char string, so a junk pin passes it; the
328
+ // transport decodes it only POST-OPEN (`peerIdFromString` in the channel
329
+ // handshake), which cost a dial of every candidate and surfaced as
330
+ // `channel: Please pass a multibase decoder …` — a decoder message instead
331
+ // of the offending field. Use the transport's OWN decoder, at the same
332
+ // version, before any socket: name the FIELD, never the value.
333
+ try {
334
+ peerIdFromString(qr.desktopIdentity)
335
+ } catch {
336
+ invalid('payload field "desktopIdentity" is not a peer id')
209
337
  }
210
- const protocolVersions = versions.map((version) => {
211
- if (typeof version !== 'number' || !Number.isInteger(version)) {
212
- invalid('payload field "protocolVersions" must contain integers')
213
- }
214
- return version
215
- })
216
338
 
217
- return { hosts, port, invitationId, invitationSecret, desktopIdentity, protocolVersions }
339
+ return {
340
+ candidates,
341
+ invitationId: qr.invitationId,
342
+ invitationSecret: qr.invitationSecret,
343
+ desktopIdentity: qr.desktopIdentity,
344
+ protocolVersions: qr.protocolVersions,
345
+ }
218
346
  }
219
347
 
220
348
  // ---------------------------------------------------------------------------
@@ -223,10 +351,52 @@ function parsePayload(payload: string): PairingPayload {
223
351
 
224
352
  const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS: readonly number[] = [1]
225
353
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
354
+ /**
355
+ * ONE aggregate budget for the candidate DIAL walk (issue #141). A v3 QR admits
356
+ * up to 32 endpoints, so 32 serial `connectTimeoutMs` dials could spend 320 s
357
+ * on an invitation that lives 120 s. It bounds the SUM, never one attempt.
358
+ */
359
+ const DEFAULT_DIAL_BUDGET_MS = 30_000
226
360
  const DEFAULT_POLL_INTERVAL_MS = 1_000
227
361
  /** The protocol invitationMs: 120s of invitation, and our poll race guard. */
228
362
  const DEFAULT_POLL_TIMEOUT_MS = 120_000
229
363
  const VERIFICATION_CODE_RE = /^[0-9]{6}$/
364
+ /** Bounds the close frame's reason text quoted into a candidate failure. */
365
+ const CLOSE_REASON_MAX = 200
366
+ /**
367
+ * Bounds ONE candidate failure segment of the joined `unreachable` verdict
368
+ * (issue #148). Unbounded, a v3 QR's 32 endpoints × a 253-char host — repeated
369
+ * again inside the dial error — reached ~17-18 KB in one tool result that
370
+ * `cherry_pair` passes through whole. The bound is per SEGMENT, never a
371
+ * "first N + and M more" cut: issue #141's contract is that every candidate,
372
+ * attempted or not, is NAMED.
373
+ *
374
+ * `FAILURE_LABEL_MAX` clips an over-long `host:port` in the HOST and keeps the
375
+ * PORT (`<host prefix>…:<port>`) — a prefix without its port names no address.
376
+ * `FAILURE_SEGMENT_MAX` bounds the whole `<label>: <detail>`: the detail is
377
+ * clipped to the room the label leaves, and the `… (+N chars)` marker's own
378
+ * length is charged to the bound. 32 candidates then join to at most
379
+ * 32 × 240 + 31 × 2 = 7,742 characters.
380
+ */
381
+ const FAILURE_LABEL_MAX = 128
382
+ const FAILURE_SEGMENT_MAX = 240
383
+ /**
384
+ * Bounds the progress line's HOST TEXT (issue #152). `report()` runs once per
385
+ * stage transition and used to join every candidate host: a v3 QR admits 32
386
+ * endpoints and a host name may be 253 characters, so one stage wrote ~8 KB.
387
+ *
388
+ * The FIRST host is always shown — a line with no host at all would lose the
389
+ * run's identity — then further hosts are appended while the host NAMES fit
390
+ * this budget; a truncated list carries an explicit `(+K more)` marker that
391
+ * rides ON TOP of the budget (the marker is a COUNT, not a host name).
392
+ *
393
+ * Invariant: the host names consume ≤ `STAGE_HOSTS_CHARS`, with ONE exception
394
+ * — a FIRST host that alone exceeds it, still shown because the identity rule
395
+ * outranks the bound. A LATER over-long host can never enter the bounded
396
+ * section (`used` already holds the first host, so it fails the fit test) and
397
+ * is omitted into the marker.
398
+ */
399
+ const STAGE_HOSTS_CHARS = 160
230
400
 
231
401
  const noop = (): void => {}
232
402
 
@@ -257,12 +427,81 @@ function sleep(ms: number): Promise<void> {
257
427
  return new Promise((resolve) => setTimeout(resolve, ms))
258
428
  }
259
429
 
260
- // urlHost() moved to its shared home (./urlHost): both dial sites — pairing's
261
- // candidate dial and DeviceConnection's per-device dial — bracket IPv6 the
262
- // same way. Re-exported here to keep this module's original public surface.
263
- export { urlHost } from './urlHost.js'
430
+ /**
431
+ * Tags a PRE-OPEN `ws` error as a candidate-dial failure (issue #142). The
432
+ * `websocket:` prefix is the KIND: the operator's fix is this address or its
433
+ * TLS, not the network — the contrast with `timeout:` is the whole point of the
434
+ * prefix, and it is what the joined `unreachable` verdict shows. The `ws` error
435
+ * carries the discriminator a bare message drops — an OS `code`
436
+ * (`ECONNREFUSED`, `ENOTFOUND`, a TLS code) or a numeric ws code — so it is
437
+ * appended, and only when it is actually a string or a number (never
438
+ * `undefined`/`null`).
439
+ */
440
+ function webSocketFailure(error: Error): Error {
441
+ const code = (error as { code?: unknown }).code
442
+ const suffix = typeof code === 'string' || typeof code === 'number' ? ` (code ${code})` : ''
443
+ return new Error(`websocket: ${error.message}${suffix}`)
444
+ }
445
+
446
+ /**
447
+ * Tags a POST-OPEN channel failure as a candidate-dial failure (issue #147).
448
+ * `channel:` is the THIRD kind, deliberately not `websocket:`: the upgrade
449
+ * completed, so the address works and only the channel's establishment failed
450
+ * (Noise handshake, remote identity proof, protocol offer). Blaming the
451
+ * address would send the operator to inspect something that demonstrably
452
+ * answered. No code suffix is appended: a Noise-stage error can carry its own
453
+ * string code (`ERR_INVALID_CRYPTO_EXCHANGE`), but that is an internal label
454
+ * rather than the ws/OS discriminator the other kinds keep, so it is
455
+ * deliberately dropped.
456
+ */
457
+ function channelFailure(error: Error): Error {
458
+ return new Error(`channel: ${error.message}`)
459
+ }
460
+
461
+ /**
462
+ * The ONE text form of a candidate failure (issue #148). All three
463
+ * `failures.push` sites render through it, so the segment bound cannot hold on
464
+ * one arm (budget-exhausted, dial failure, claim fall-through) and fail on
465
+ * another. A short label and a short detail are returned BYTE FOR BYTE; the
466
+ * enforcement is never a rewrite of ordinary text.
467
+ *
468
+ * The detail goes through the SHARED {@link clipText} (issue #155), the same
469
+ * `… (+N chars)` form every other peer-controlled log detail uses; the clip
470
+ * point is no longer private to pairing.
471
+ */
472
+ function failureSegment(label: string, detail: string): string {
473
+ const boundedLabel = clipFailureLabel(label)
474
+ const room = FAILURE_SEGMENT_MAX - boundedLabel.length - 2 // ': '
475
+ // Unreachable while the label cap holds (128 + 2 < 240). It is kept so the
476
+ // SEGMENT bound is independent of the label cap: a removed label cap cuts
477
+ // the label into the segment instead of letting the segment overflow.
478
+ if (room <= 0) return boundedLabel.slice(0, FAILURE_SEGMENT_MAX)
479
+ return `${boundedLabel}: ${clipText(detail, room)}`
480
+ }
481
+
482
+ /**
483
+ * Clips a `host:port` label in the HOST, keeping the port (see
484
+ * {@link FAILURE_LABEL_MAX}).
485
+ *
486
+ * Identification here is PREFIX + PORT, not uniqueness: two schema-distinct
487
+ * 253-char hosts that agree on their first ~121 characters AND on the port clip
488
+ * to identical labels (ports may repeat — only hosts are deduped). Each still
489
+ * contributes its own segment, so #141's per-candidate count is intact.
490
+ */
491
+ function clipFailureLabel(label: string): string {
492
+ if (label.length <= FAILURE_LABEL_MAX) return label
493
+ const colon = label.lastIndexOf(':')
494
+ if (colon < 0) return `${label.slice(0, FAILURE_LABEL_MAX - 1)}…`
495
+ const suffix = `…${label.slice(colon)}` // `…` + `:<port>`
496
+ const hostRoom = FAILURE_LABEL_MAX - suffix.length
497
+ return hostRoom > 0 ? `${label.slice(0, hostRoom)}${suffix}` : suffix.slice(0, FAILURE_LABEL_MAX)
498
+ }
264
499
 
265
500
  /** Dials one candidate address and completes the Noise handshake.
501
+ *
502
+ * The URL is the published `directEndpointUrl` — it brackets IPv6 literals,
503
+ * appends the fixed `/v1/remote/connect` path, and selects `ws:` or `wss:`
504
+ * from the candidate's own `security` (v3 TLS-proxy endpoints dial TLS).
266
505
  *
267
506
  * Both halves start concurrently (`Promise.all`): the peer's accept half is
268
507
  * already running on its side, so a sequential dial would deadlock the
@@ -274,40 +513,90 @@ export { urlHost } from './urlHost.js'
274
513
  * rather than assumed: the payload's `desktopIdentity` is the peer the Noise
275
514
  * XX handshake must prove, so a swapped-in impostor fails the handshake
276
515
  * instead of being pinned as the desktop.
516
+ *
517
+ * A failure carries one of THREE kinds into the joined `unreachable` verdict,
518
+ * because each names the operator's next check: `timeout:` (the per-attempt
519
+ * timer won its race — check the network), `websocket:` (the socket failed
520
+ * BEFORE the upgrade — check this address or its TLS), and `channel:` (the
521
+ * socket came up and the channel failed AFTER it — check the peer's identity
522
+ * or the protocol offer). A stalled handshake stays `timeout:`: the dial
523
+ * promise's own timer settles first, so the abort it triggers cannot win.
277
524
  */
278
525
  async function dial(
279
- host: string,
280
- port: number,
526
+ candidate: PairCandidate,
281
527
  identity: Uint8Array,
282
528
  desktopIdentity: string,
283
529
  protocolVersions: readonly number[],
284
530
  timeoutMs: number,
531
+ wsOptions: PairWithPayloadOptions['wsOptions'],
285
532
  ): Promise<TempChannel> {
286
- const ws = new WebSocket(`ws://${urlHost(host)}:${port}${remoteConnectPath}`)
533
+ const { host, port } = candidate
534
+ const ws = new WebSocket(directEndpointUrl(candidate), wsOptions)
287
535
  const controller = new AbortController()
288
536
  let timer: ReturnType<typeof setTimeout> | undefined
289
537
  const timedOut = new Promise<never>((_, reject) => {
290
- timer = setTimeout(() => reject(new Error(`connect to ${host}:${port} timed out`)), timeoutMs)
538
+ timer = setTimeout(
539
+ () => reject(new Error(`timeout: connect to ${host}:${port} timed out`)),
540
+ timeoutMs,
541
+ )
291
542
  })
292
543
 
293
544
  try {
294
545
  const connecting = (async (): Promise<SecureChannel> => {
295
546
  await new Promise<void>((resolve, reject) => {
296
- ws.once('open', () => resolve())
297
- ws.once('error', (error: Error) => reject(error))
298
- })
299
- const stream = new RemoteSocketStream(
300
- toRemoteSocket(ws),
301
- silentComponentLogger.forComponent('pairing'),
302
- 'outbound',
303
- )
304
- return connectSecureChannel(stream, {
305
- identity,
306
- logger: silentComponentLogger,
307
- protocolVersions: [...protocolVersions],
308
- signal: controller.signal,
309
- remoteIdentity: desktopIdentity,
547
+ // A socket can fail before the upgrade completes in two shapes: an
548
+ // `error` event (the HTTP handshake's own failure) and a `close` with
549
+ // no error. Both are the `websocket:` kind; the close path is tagged
550
+ // with the close code and (bounded) reason instead of a message.
551
+ // Unexercised under the pinned `ws` 8.22: every client pre-open failure
552
+ // emits `error` first (verified in the library source), so a `ws` bump
553
+ // must re-check that ordering.
554
+ // Whichever fires first wins, and the listeners are removed on settle
555
+ // so a LATER close — e.g. the timeout's `ws.terminate()` — cannot fire
556
+ // a stale rejection on an open promise nobody waits for anymore.
557
+ const settle = (outcome: () => void): void => {
558
+ ws.off('open', onOpen)
559
+ ws.off('error', onError)
560
+ ws.off('close', onClose)
561
+ outcome()
562
+ }
563
+ const onOpen = (): void => settle(resolve)
564
+ const onError = (error: Error): void => settle(() => reject(webSocketFailure(error)))
565
+ const onClose = (code: number, reason: Buffer): void => {
566
+ const text = reason.toString('utf8').slice(0, CLOSE_REASON_MAX)
567
+ settle(() =>
568
+ reject(
569
+ new Error(
570
+ `websocket: closed before open (code ${code})${text === '' ? '' : ` ${text}`}`,
571
+ ),
572
+ ),
573
+ )
574
+ }
575
+ ws.once('open', onOpen)
576
+ ws.once('error', onError)
577
+ ws.on('close', onClose)
310
578
  })
579
+ // The POST-OPEN half starts here: the upgrade completed, so the socket
580
+ // is not what failed. Tag it `channel:` rather than `websocket:` — the
581
+ // address demonstrably worked, and blaming it would send the operator to
582
+ // the wrong fix. Wrapping only these two statements keeps every pre-open
583
+ // arm on its own `websocket:`/`timeout:` kind.
584
+ try {
585
+ const stream = new RemoteSocketStream(
586
+ toRemoteSocket(ws),
587
+ silentComponentLogger.forComponent('pairing'),
588
+ 'outbound',
589
+ )
590
+ return await connectSecureChannel(stream, {
591
+ identity,
592
+ logger: silentComponentLogger,
593
+ protocolVersions: [...protocolVersions],
594
+ signal: controller.signal,
595
+ remoteIdentity: desktopIdentity,
596
+ })
597
+ } catch (error) {
598
+ throw channelFailure(error instanceof Error ? error : new Error(String(error)))
599
+ }
311
600
  })()
312
601
 
313
602
  // Aborting on timeout is what unsticks the channel half: the ws 'error'
@@ -362,6 +651,30 @@ type GetResult =
362
651
  expiresAt: string
363
652
  }
364
653
 
654
+ /**
655
+ * Renders the progress line's host list under {@link STAGE_HOSTS_CHARS} (the
656
+ * invariant lives on that constant). A truncated list names the first host,
657
+ * then says how many candidates it is NOT naming — the count is explicit so a
658
+ * reader can tell a short host list from a bounded one.
659
+ */
660
+ function stageHosts(hosts: readonly string[]): string {
661
+ const [first, ...rest] = hosts
662
+ // Defensive only: both QR arms require at least one candidate, so a parsed
663
+ // payload always reaches the fill loop below.
664
+ if (first === undefined) return ''
665
+ let text = first
666
+ let used = first.length
667
+ let shown = 1
668
+ for (const host of rest) {
669
+ if (used + host.length + 2 > STAGE_HOSTS_CHARS) break
670
+ text += `, ${host}`
671
+ used += host.length + 2
672
+ shown += 1
673
+ }
674
+ const omitted = hosts.length - shown
675
+ return omitted > 0 ? `${text} (+${omitted} more)` : text
676
+ }
677
+
365
678
  /**
366
679
  * Pairs this DSH profile with a Cherry Studio desktop from its pairing payload,
367
680
  * returning the PERSISTED record (ruling 6).
@@ -384,11 +697,14 @@ export async function pairWithPayload(opts: PairWithPayloadOptions): Promise<Dev
384
697
  const onStatus = opts.onStatus ?? noop
385
698
  // INFO-LEVEL PROGRESS (spec §5): every stage transition is logged, so an
386
699
  // operator watching a run that can take a minute can see where it is WITHOUT
387
- // the tool result. Only the stage NAME and the payload's host list are
388
- // recorded — never `invitationSecret`, the claim's verification code, or any
389
- // token (those are exactly what the sanitization contract forbids).
700
+ // the tool result. Only the stage NAME and a BOUNDED view of the payload's
701
+ // host list are recorded — never `invitationSecret`, the claim's verification
702
+ // code, or any token (those are exactly what the sanitization contract
703
+ // forbids), and never an unbounded join of 32 maximum-length hosts.
390
704
  const report = (stage: string): void => {
391
- console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${payload.hosts.join(', ')})`)
705
+ console.info(
706
+ `[cherry-remote] pairing stage ${stage} (hosts: ${stageHosts(payload.candidates.map((candidate) => candidate.host))})`,
707
+ )
392
708
  onStatus(stage)
393
709
  }
394
710
  report('validate')
@@ -400,52 +716,94 @@ export async function pairWithPayload(opts: PairWithPayloadOptions): Promise<Dev
400
716
  const offered = payload.protocolVersions.filter((version) => supported.includes(version))
401
717
 
402
718
  const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
719
+ // ONE deadline for the whole candidate walk (issue #141). The per-attempt
720
+ // bound stays `connectTimeoutMs`, but no candidate may START after this
721
+ // instant and the last dial is clamped to what is left of it. The CLAIM/POLL
722
+ // phase below is deliberately outside it: that phase keeps its own FRESH
723
+ // race guard anchored at the claim (`pollTimeoutMs`), not the invitation's
724
+ // birth — the peer's own `expired` is the real bound there.
725
+ //
726
+ // The deadline rides the MONOTONIC clock: a backward wall-clock step would
727
+ // GROW `remaining` and re-open the unbounded walk (the per-dial clamp cannot
728
+ // bound a walk whose deadline moved).
729
+ const dialBudgetMs =
730
+ typeof opts.dialBudgetMs === 'number' && Number.isFinite(opts.dialBudgetMs)
731
+ ? opts.dialBudgetMs
732
+ : DEFAULT_DIAL_BUDGET_MS
733
+ const dialDeadline = performance.now() + dialBudgetMs
403
734
  report('connect')
404
735
 
405
736
  const failures: string[] = []
406
- for (const host of payload.hosts) {
737
+ // The approved record, set by the winner and written ONCE after the loop.
738
+ let approved: DeviceRecord | undefined
739
+ for (const candidate of payload.candidates) {
740
+ const label = `${candidate.host}:${candidate.port}`
741
+ const remaining = dialDeadline - performance.now()
742
+ if (remaining <= 0) {
743
+ failures.push(
744
+ failureSegment(
745
+ label,
746
+ `not attempted (the ${dialBudgetMs} ms dial budget was exhausted before this candidate)`,
747
+ ),
748
+ )
749
+ continue
750
+ }
407
751
  let temp: TempChannel
408
752
  try {
409
753
  temp = await dial(
410
- host,
411
- payload.port,
754
+ candidate,
412
755
  opts.identity,
413
756
  payload.desktopIdentity,
414
757
  offered,
415
- connectTimeoutMs,
758
+ // Clamped so the last attempt cannot OVERSHOOT the budget: a dial must
759
+ // never outlive the deadline it was started under.
760
+ Math.min(connectTimeoutMs, remaining),
761
+ opts.wsOptions,
416
762
  )
417
763
  } catch (error) {
418
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`)
764
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)))
419
765
  continue
420
766
  }
421
767
  try {
422
- const record = await claimAndAwait(temp.client, payload, offered, opts, report)
768
+ approved = await claimAndAwait(temp.client, payload, offered, opts, report, candidate)
423
769
  destroy(temp)
424
- // `DeviceStore.upsert` persists and returns nothing, so the call returns
425
- // the very record it handed over — the persisted archive entry, not a
426
- // pre-write copy.
427
- opts.store.upsert(record)
428
- return record
770
+ // Settled: stop walking. The ARCHIVE WRITE is deliberately outside this
771
+ // per-candidate catch (below) — see the fall-through comment.
772
+ break
429
773
  } catch (error) {
430
774
  destroy(temp)
431
775
  if (error instanceof PairingError) throw error
432
- // MID-FLOW FALL-THROUGH (documented boundary): a NON-`PairingError` here
433
- // is an unexpected fault on THIS candidate — not a verdict about the
434
- // invitation. The walk therefore CONTINUES to the next candidate address,
435
- // and if a later candidate reaches a real terminal state that verdict
436
- // wins. Only if EVERY candidate fails does the walk end as `unreachable`;
437
- // conversely an unexpected fault on the FIRST candidate followed by a
438
- // peer `rejected` on the SECOND surfaces as `rejected`, not `unreachable`.
439
- // That is deliberate: the peer's answer about the invitation outranks our
440
- // own transport trouble, and it is the one the user can act on.
441
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`)
776
+ // MID-FLOW FALL-THROUGH (documented boundary): a NON-`PairingError` DIAL
777
+ // or CLAIM fault here is an unexpected fault on THIS candidate — not a
778
+ // verdict about the invitation. The walk therefore CONTINUES to the next
779
+ // candidate address, and if a later candidate reaches a real terminal
780
+ // state that verdict wins. Only if EVERY candidate fails does the walk end
781
+ // as `unreachable`; conversely an unexpected fault on the FIRST candidate
782
+ // followed by a peer `rejected` on the SECOND surfaces as `rejected`, not
783
+ // `unreachable`. That is deliberate: the peer's answer about the invitation
784
+ // outranks our own transport trouble, and it is the one the user can act
785
+ // on. The archive write is NOT in this arm: it happens once, after a
786
+ // winner, and is TERMINAL — a store failure is a fact about the archive,
787
+ // never an address failure.
788
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)))
442
789
  }
443
790
  }
444
791
 
445
- throw new PairingError(
446
- 'unreachable',
447
- `no candidate address could complete pairing (${failures.join('; ')})`,
448
- )
792
+ if (approved === undefined) {
793
+ throw new PairingError(
794
+ 'unreachable',
795
+ `no candidate address could complete pairing (${failures.join('; ')})`,
796
+ )
797
+ }
798
+
799
+ // ONE write, after a winner and outside every per-candidate catch. A failure
800
+ // here surfaces as itself (`QuarantinedArchiveError`, a validation error, …):
801
+ // the desktop already approved, so blaming an address would be a lie and the
802
+ // invitation is already spent. `upsert` returns nothing, so the return value
803
+ // is the record handed to it — the persisted archive entry, not a pre-write
804
+ // copy.
805
+ opts.store.upsert(approved)
806
+ return approved
449
807
  }
450
808
 
451
809
  /**
@@ -460,6 +818,7 @@ async function claimAndAwait(
460
818
  protocolVersions: readonly number[],
461
819
  opts: PairWithPayloadOptions,
462
820
  onStatus: (stage: string) => void,
821
+ candidate: PairCandidate,
463
822
  ): Promise<DeviceRecord> {
464
823
  const deviceName = (opts.name ?? hostname()).slice(0, DEVICE_NAME_MAX)
465
824
 
@@ -525,7 +884,7 @@ async function claimAndAwait(
525
884
  )
526
885
  }
527
886
  if (result.status === 'approved') {
528
- return recordFrom(payload, result, deviceName)
887
+ return recordFrom(payload, result, deviceName, candidate)
529
888
  }
530
889
  // `pending`: the user has not acted yet. The local deadline is race
531
890
  // protection for a peer that never reaches a terminal state; the normal
@@ -547,11 +906,26 @@ async function claimAndAwait(
547
906
  * `displayName` is the name the claim actually carried — the record must not
548
907
  * disagree with what the desktop shows for this device, and re-deriving it
549
908
  * here would lose the 64-char truncation.
909
+ *
910
+ * The address projection: every candidate host is kept (the v2 record always
911
+ * did, and reconnect re-walks them), and `port` is the one the pairing actually
912
+ * completed on — the WINNER's port, and the record-level fallback. A mixed-port
913
+ * v3 payload is now persisted FAITHFULLY, per host, through `ports` (issue
914
+ * #135): the 2.1.8 generation side emits one endpoint per custom-address
915
+ * invitation (`ApiGatewayService.getRemoteEndpoint`:
916
+ * `advertisedEndpoint ? [advertisedEndpoint] : …`), but a QR that does carry a
917
+ * mixed list must still reconnect each host on its own port. `security` IS
918
+ * persisted (issue #126), one entry per host, taken from the very candidates the
919
+ * walk dialed — so a schemaVersion-4 record remembers the scheme the pairing
920
+ * actually used, and the steady-state dial (src/connection.ts) rebuilds the
921
+ * same URL instead of a plain `ws://`. Before that, #124 paired a TLS endpoint
922
+ * and made it unreachable on every reconnect.
550
923
  */
551
924
  function recordFrom(
552
925
  payload: PairingPayload,
553
926
  approved: Extract<GetResult, { status: 'approved' }>,
554
927
  deviceName: string,
928
+ winner: PairCandidate,
555
929
  ): DeviceRecord {
556
930
  const grants = (approved.authorization?.grants ?? [])
557
931
  .map((grant) => grant.domain)
@@ -580,11 +954,21 @@ function recordFrom(
580
954
  }
581
955
 
582
956
  return {
583
- schemaVersion: 2,
957
+ schemaVersion: 4,
584
958
  deviceId: approved.deviceId,
585
959
  displayName: deviceName,
586
- hosts: [...payload.hosts],
587
- port: payload.port,
960
+ hosts: payload.candidates.map((candidate) => candidate.host),
961
+ // The winner's port, and the record-level fallback (issue #135).
962
+ port: winner.port,
963
+ // One scheme and one port per host, from the very candidates the walk
964
+ // dialed: both maps and `hosts` come from the same list, so they cannot
965
+ // disagree.
966
+ security: Object.fromEntries(
967
+ payload.candidates.map((candidate) => [candidate.host, candidate.security]),
968
+ ),
969
+ ports: Object.fromEntries(
970
+ payload.candidates.map((candidate) => [candidate.host, candidate.port]),
971
+ ),
588
972
  desktopIdentity: payload.desktopIdentity,
589
973
  grants,
590
974
  accessToken: approved.accessToken,
@@ -605,8 +989,16 @@ function asPairingError(error: unknown, stage: 'hello' | 'claim' | 'poll'): Pair
605
989
  if (reason !== undefined) {
606
990
  return new PairingError('rejected', `the desktop refused the pairing ${stage} (${reason})`)
607
991
  }
992
+ // The peer's message rides the SAME segment shaping as one candidate failure
993
+ // (issue #155): this verdict used to pass it through whole, bounded only by
994
+ // the 64 KiB record budget, and `cherry_pair` hands the message to the model
995
+ // verbatim. `unreachable` keeps the `pairing <stage> failed: <detail>` label,
996
+ // so the STAGE is still named; a detail that fits comes back byte for byte.
608
997
  return new PairingError(
609
998
  'unreachable',
610
- `pairing ${stage} failed: ${error instanceof Error ? error.message : String(error)}`,
999
+ failureSegment(
1000
+ `pairing ${stage} failed`,
1001
+ error instanceof Error ? error.message : String(error),
1002
+ ),
611
1003
  )
612
1004
  }