dsh-cherry-bridge 0.1.11 → 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/README.en.md +11 -4
- package/README.md +10 -4
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +906 -144
- package/docs/e2e-checklist.md +2 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +181 -14
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
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 {
|
|
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 {
|
|
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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
/**
|
|
83
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
388
|
-
// recorded — never `invitationSecret`, the claim's verification
|
|
389
|
-
// token (those are exactly what the sanitization contract
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
payload.port,
|
|
754
|
+
candidate,
|
|
412
755
|
opts.identity,
|
|
413
756
|
payload.desktopIdentity,
|
|
414
757
|
offered,
|
|
415
|
-
|
|
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(
|
|
764
|
+
failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)))
|
|
419
765
|
continue
|
|
420
766
|
}
|
|
421
767
|
try {
|
|
422
|
-
|
|
768
|
+
approved = await claimAndAwait(temp.client, payload, offered, opts, report, candidate)
|
|
423
769
|
destroy(temp)
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
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`
|
|
433
|
-
// is an unexpected fault on THIS candidate — not a
|
|
434
|
-
// invitation. The walk therefore CONTINUES to the next
|
|
435
|
-
// and if a later candidate reaches a real terminal
|
|
436
|
-
// wins. Only if EVERY candidate fails does the walk end
|
|
437
|
-
// conversely an unexpected fault on the FIRST candidate
|
|
438
|
-
// peer `rejected` on the SECOND surfaces as `rejected`, not
|
|
439
|
-
// That is deliberate: the peer's answer about the invitation
|
|
440
|
-
// own transport trouble, and it is the one the user can act
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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:
|
|
957
|
+
schemaVersion: 4,
|
|
584
958
|
deviceId: approved.deviceId,
|
|
585
959
|
displayName: deviceName,
|
|
586
|
-
hosts:
|
|
587
|
-
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
|
-
|
|
999
|
+
failureSegment(
|
|
1000
|
+
`pairing ${stage} failed`,
|
|
1001
|
+
error instanceof Error ? error.message : String(error),
|
|
1002
|
+
),
|
|
611
1003
|
)
|
|
612
1004
|
}
|