dsh-cherry-bridge 0.1.11 → 0.2.1

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/connection.ts CHANGED
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'
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 { directEndpointUrl, advertisedEndpointSchema } from '@cherrystudio/remote-protocol'
6
+ import type { DirectEndpoint } from '@cherrystudio/remote-protocol'
6
7
  import { isChannelClosedError, JsonRpcClient, JsonRpcRequestError } from './rpc/jsonRpcClient.js'
7
8
  import type { JsonRpcRequestOptions } from './rpc/jsonRpcClient.js'
8
9
  import { toRemoteSocket } from './rpc/wsAdapter.js'
@@ -13,8 +14,9 @@ import {
13
14
  DEFAULT_TRANSPORT_RETRY_WAIT_MS,
14
15
  RATE_LIMIT_MAX_WAIT_MS,
15
16
  } from './defaults.js'
16
- import { urlHost } from './urlHost.js'
17
17
  import type { DeviceRecord } from './deviceStore.js'
18
+ import { mergeAdvertisedEndpoints } from './deviceStore.js'
19
+ import { clipText, LOG_TEXT_MAX } from './text.js'
18
20
 
19
21
  /**
20
22
  * Per-device persistent connection: the M1 capstone that owns the WebSocket,
@@ -36,13 +38,59 @@ export interface EffectiveLimits {
36
38
  subscriptions: number
37
39
  pageItems: number
38
40
  inFlightRequests: number
41
+ /**
42
+ * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
43
+ * stored BESIDE the limits because it is re-read by that same handshake.
44
+ *
45
+ * ABSENT, never defaulted: through desktop 2.1.7 the field does not exist,
46
+ * and 0.5.0's `agent.messages.list` params are a STRICT object with no
47
+ * `partsMode` — sending it to such a peer is a protocol violation, so absence
48
+ * has to be distinguishable from every number. `1` (2.1.8, where
49
+ * `RemoteConnection.ts:69` sets it unconditionally) is the only version that
50
+ * means "the summary arm is available"; any other number is stored verbatim
51
+ * and read as not-yet-known, because a version this client cannot speak is
52
+ * not evidence that it speaks 1.
53
+ *
54
+ * Re-read on EVERY hello, so a desktop upgraded between two handshakes is
55
+ * picked up on the reconnect that follows.
56
+ */
57
+ messageSummaries?: number
39
58
  }
40
59
 
60
+ /**
61
+ * Options handed to every `WebSocket` dial (issue #126).
62
+ *
63
+ * FLAT `tls.connect` options — `ws` forwards its own option bag straight to
64
+ * `tls.connect`, so `{ rejectUnauthorized: false }` or `{ ca: <pem> }` belong
65
+ * at the top level rather than under a `tls:` key.
66
+ */
67
+ export type WsDialOptions = { rejectUnauthorized?: boolean; ca?: string | Buffer | Array<string | Buffer> }
68
+
41
69
  export interface DeviceConnectionOptions {
42
70
  /** Local Ed25519 device identity (raw protobuf private key bytes). */
43
71
  identity: Uint8Array
44
- /** The paired device record; `hosts`/`port` are re-read on every attempt. */
72
+ /**
73
+ * The paired device record; `hosts`/`security`/`ports`/`port` are re-read on
74
+ * every attempt. `security` and `ports` are REQUIRED by schemaVersion 4 (the
75
+ * v2→v3 and v3→v4 migrations backfill them), so the `?.` in the STEADY-STATE
76
+ * dial guards an in-memory record assembled without the map (or constructed
77
+ * before validation), not a migrated one. That dial — this class — consumes
78
+ * both per-host maps; the pairing dial never reads the record (it dials the
79
+ * QR candidate's own host/port/scheme, which is why the record can persist
80
+ * them). Each host dials with its OWN port; the record-level `port` is the
81
+ * winner's at pairing, the operator's after a PATCH, and always the fallback.
82
+ */
45
83
  record: DeviceRecord
84
+ /**
85
+ * Options for every dial's `WebSocket` (issue #126) — in practice
86
+ * `rejectUnauthorized`/`ca`, for a desktop behind a TLS proxy whose
87
+ * certificate is not in Node's default trust store. The assembly injects it
88
+ * from plugin config `wsTls` at BOTH dial sites (the pool and pairing); tests
89
+ * may still pass it directly. Absent means Node's defaults. NEVER derived
90
+ * from the record: trust is a grant the OPERATOR makes, not a fact the
91
+ * desktop asserts about itself.
92
+ */
93
+ wsOptions?: WsDialOptions
46
94
  /**
47
95
  * Second-level smoothed request budget for business RPCs on the SHARED tier
48
96
  * — every business RPC except `agent.content.read`, which has its own tier
@@ -92,6 +140,19 @@ export interface DeviceConnectionOptions {
92
140
  * (up to 60s per attempt) to find the desktop again.
93
141
  */
94
142
  transportRetryWaitMs?: number
143
+ /**
144
+ * Where the endpoints a peer ADVERTISES are handed to the assembly for
145
+ * persistence (issue #143). Called at most once per successful handshake,
146
+ * with the entries that passed the identity pin and the peer's destination
147
+ * schema; the connection has ALREADY merged them into `record` (so this
148
+ * instance's next reconnect dials them) — this hook is the write-back, and the
149
+ * assembly owns it because the store is not this layer's to touch.
150
+ *
151
+ * Absent (tests, or a future caller that only wants the in-memory merge) just
152
+ * means nothing is persisted. The callback must not throw; one that does is
153
+ * swallowed with the rest of the enrichment's failures.
154
+ */
155
+ onAdvertisedEndpoints?: (endpoints: readonly DirectEndpoint[]) => void
95
156
  }
96
157
 
97
158
  /**
@@ -223,6 +284,35 @@ const DEFAULT_BACKOFF_MAX_MS = 60_000
223
284
  const DEFAULT_REFRESH_GRACE_MS = 30_000
224
285
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
225
286
  const DEFAULT_PROTOCOL_VERSIONS: readonly number[] = [1]
287
+
288
+ /**
289
+ * How long the optional `connection.endpoints` enrichment (issue #143) may hold
290
+ * up ONE handshake before the connection gives up on it.
291
+ *
292
+ * The call runs INSIDE the handshake, so without a bound of its own it would
293
+ * inherit the rpc layer's 60 s default: a peer that advertises
294
+ * `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
295
+ * would keep the device in `connecting` for a minute, and every reconnect would
296
+ * pay it again. THIS layer bounds it because the enrichment is worth
297
+ * milliseconds, not a retry.
298
+ *
299
+ * WHY 2 s. The peer's handler is a local computation
300
+ * (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
301
+ * round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
302
+ * slack.
303
+ *
304
+ * The UPPER bound is NOT derived from a claim about the whole handshake: a dial
305
+ * already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
306
+ * ride the rpc layer's 60 s default, so `connected` can arrive long after a
307
+ * tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
308
+ * derived is narrower and true: THIS wait alone must stay below that window, so
309
+ * a peer that advertises the capability and then hangs on this one call cannot
310
+ * by itself push the state flip past the window a tool already waited out. A
311
+ * timeout costs one reconnect's worth of enrichment and says nothing about the
312
+ * device.
313
+ */
314
+ export const ENDPOINTS_LEARN_TIMEOUT_MS = 2_000
315
+
226
316
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
227
317
  const SILENCE_FACTOR = 2
228
318
  const RATE_WINDOW_MS = 1_000
@@ -332,7 +422,13 @@ class SecondWindowRateLimiter {
332
422
  // released strictly after the one before it reached the head.
333
423
  let release!: () => void
334
424
  const ahead = this.#queue
335
- this.#queue = ahead.then(() => new Promise<void>((resolve) => (release = resolve)))
425
+ // Both arms hand off the same way: a rejected link must still open a slot
426
+ // for the next waiter. Today no link can reject (each is settled only by
427
+ // its own `release()` in the `finally` below), so this is a guard rather
428
+ // than a fix — the desktop learned the shape the hard way when one failed
429
+ // write silenced every later send on the connection (2.1.9, PR #22593).
430
+ const next = (): Promise<void> => new Promise<void>((resolve) => (release = resolve))
431
+ this.#queue = ahead.then(next, next)
336
432
  await ahead
337
433
  try {
338
434
  for (;;) {
@@ -544,6 +640,10 @@ interface HelloResult {
544
640
  protocolVersion?: number
545
641
  limits?: Record<string, number>
546
642
  heartbeatMs?: number
643
+ /** The paged-message-summaries capability (issue #105), when advertised. */
644
+ agentMessageSummariesVersion?: number
645
+ /** The advertised-endpoints capability (issue #143), when advertised. */
646
+ connectionEndpointsVersion?: number
547
647
  }
548
648
 
549
649
  interface AuthResult {
@@ -590,6 +690,51 @@ function businessWindowFor(reportedInFlightRequests: number): number {
590
690
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)))
591
691
  }
592
692
 
693
+ /**
694
+ * The destinations a `connection.endpoints` answer may contribute, in the order
695
+ * the peer listed them (issue #143). Empty when the answer must be discarded.
696
+ *
697
+ * TWO answers are refused WHOLE:
698
+ *
699
+ * - **the identity pin.** The answer must name the very desktop this record is
700
+ * pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
701
+ * static key, so this is not the adversarial case it protects against — it
702
+ * is the defence against a CONFUSED answer (a proxy, a desktop whose identity
703
+ * was regenerated, a future protocol that answers for more than one host):
704
+ * the list decides where this client dials, so a list that cannot be
705
+ * attributed to our own desktop may not change it;
706
+ * - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
707
+ * number is not the pin either, so both fall out of the same comparison.
708
+ *
709
+ * One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
710
+ * destination schema, rejects a LOOPBACK destination (a shared address that
711
+ * resolves to our own machine would send the next reconnect to the wrong host)
712
+ * and normalizes the host (case, IPv6), so the archive never stores a spelling
713
+ * the dial cannot use. One unusable row must not discard its neighbours.
714
+ *
715
+ * NOT total on the wire shape, deliberately: a non-object answer or a non-array
716
+ * `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
717
+ * turns that into the silent no-op the contract promises. Defending each shape
718
+ * twice would look safer and be worse: the redundancy masks which guard a test
719
+ * actually pins (issue #114's lesson), and the whole ANSWER is untrusted
720
+ * anyway, so the boundary belongs at the caller, where the handshake can see
721
+ * it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
722
+ * "no addresses right now" answer (the desktop's `addresses` can be empty).
723
+ *
724
+ * No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
725
+ * the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
726
+ */
727
+ function advertisedEndpoints(result: unknown, desktopIdentity: string): DirectEndpoint[] {
728
+ const answer = result as { desktopIdentity?: unknown; endpoints?: readonly unknown[] | null }
729
+ if (answer.desktopIdentity !== desktopIdentity) return []
730
+ const accepted: DirectEndpoint[] = []
731
+ for (const raw of answer.endpoints ?? []) {
732
+ const parsed = advertisedEndpointSchema.safeParse(raw)
733
+ if (parsed.success) accepted.push(parsed.data)
734
+ }
735
+ return accepted
736
+ }
737
+
593
738
  /**
594
739
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
595
740
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
@@ -690,6 +835,7 @@ export class DeviceConnection {
690
835
  readonly #protocolVersions: readonly number[]
691
836
  readonly #helloProtocolVersions: readonly number[]
692
837
  readonly #transportRetryWaitMs: number
838
+ readonly #onAdvertisedEndpoints: ((endpoints: readonly DirectEndpoint[]) => void) | undefined
693
839
  readonly #stateListeners = new Set<(state: DeviceState) => void>()
694
840
  /**
695
841
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -740,8 +886,11 @@ export class DeviceConnection {
740
886
  #token: string | undefined
741
887
  #tokenExpiresAt: number | undefined
742
888
 
889
+ readonly #wsOptions: WsDialOptions | undefined
890
+
743
891
  constructor(opts: DeviceConnectionOptions) {
744
892
  this.record = opts.record
893
+ this.#wsOptions = opts.wsOptions
745
894
  this.#identity = opts.identity
746
895
  this.#limiter = new TieredRateLimiter(
747
896
  opts.rateBudgetPerSecond ?? DEFAULT_RATE_BUDGET_PER_SECOND,
@@ -755,6 +904,7 @@ export class DeviceConnection {
755
904
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS
756
905
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions
757
906
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? DEFAULT_TRANSPORT_RETRY_WAIT_MS
907
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints
758
908
  }
759
909
 
760
910
  /**
@@ -967,6 +1117,10 @@ export class DeviceConnection {
967
1117
  if (error instanceof IdentityMismatchError) {
968
1118
  this.#running = false
969
1119
  this.#setState('identity-mismatch')
1120
+ // DELIBERATELY not clipped: this message is a COMPILE-TIME LITERAL
1121
+ // ("peer static key does not match the pinned desktopIdentity") with no
1122
+ // peer bytes in it, so there is nothing here to bound. The other two
1123
+ // error lines below interpolate text the peer controls.
970
1124
  console.error(
971
1125
  `[cherry-remote] device ${this.record.deviceId}: ${error.message}; ` +
972
1126
  'marked identity-mismatch and disconnected — re-pair the device',
@@ -978,9 +1132,19 @@ export class DeviceConnection {
978
1132
  if (error instanceof ProtocolVersionError) {
979
1133
  this.#running = false
980
1134
  this.#setState('offline')
1135
+ // BOUNDED (issue #155): the `cause` is the peer's OWN hello-echo text —
1136
+ // `ProtocolVersionError` is built from `String(<the peer's RPC failure>)`
1137
+ // — so the message can carry up to a whole 64 KiB record of peer text.
1138
+ // Ceiling: 56 fixed characters + the `deviceId` + LOG_TEXT_MAX (240,
1139
+ // marker included) + the 37-character upgrade suffix. The id is
1140
+ // deliberately NOT clipped (a whitelisted identifier an operator
1141
+ // correlates on), and it is peer-assigned (`pairing.get`'s approved
1142
+ // result, `opaqueId` ≤ 256), so the bound moves with it: 8-character
1143
+ // fixture id → 341; a 36-char UUID → 369; the schema worst case (256) →
1144
+ // 589.
981
1145
  console.error(
982
1146
  `[cherry-remote] device ${this.record.deviceId}: protocol version incompatible ` +
983
- `(${error.message}); upgrade Cherry Studio / the plugin`,
1147
+ `(${clipText(error.message, LOG_TEXT_MAX)}); upgrade Cherry Studio / the plugin`,
984
1148
  )
985
1149
  this.#cycle.reject(new Error(`device ${this.record.deviceId}: protocol version incompatible`))
986
1150
  return
@@ -1014,9 +1178,19 @@ export class DeviceConnection {
1014
1178
  ? ' — all candidate addresses were unreachable; if the desktop moved, update ' +
1015
1179
  'them via the device card 地址维护 (address maintenance)'
1016
1180
  : ''
1181
+ const detail = error instanceof Error ? error.message : String(error)
1017
1182
  console.warn(
1018
1183
  `[cherry-remote] device ${this.record.deviceId} offline ` +
1019
- `(${error instanceof Error ? error.message : String(error)}); reconnecting in ${delay}ms${hint}`,
1184
+ // BOUNDED (issue #155): this text is peer-influenced — an RPC failure
1185
+ // carries the peer's own `error.message` verbatim (an authenticate
1186
+ // refusal, a hello fault), and a transport error carries the system's.
1187
+ // Only the 64 KiB record budget bounded it before. Ceiling: 33 fixed
1188
+ // characters + the `deviceId` + LOG_TEXT_MAX (240, marker included) +
1189
+ // `); reconnecting in <delay>ms` (26 at the 60,000 ms backoff cap) +
1190
+ // the all-hosts hint (125). The id is NOT clipped (same decision as
1191
+ // above), so a 36-char UUID gives 460 and the schema worst case (256)
1192
+ // gives 680, against 432 for the 8-character fixture id.
1193
+ `(${clipText(detail, LOG_TEXT_MAX)}); reconnecting in ${delay}ms${hint}`,
1020
1194
  )
1021
1195
  this.#setTimer('backoff', setTimeout(() => this.#tryEstablish(), delay))
1022
1196
  }
@@ -1041,6 +1215,12 @@ export class DeviceConnection {
1041
1215
  * network failures fall through to the next candidate — the §6
1042
1216
  * 「候选地址全部不可达」 verdict only holds when EVERY host failed.
1043
1217
  *
1218
+ * Each candidate dials with ITS OWN port from `record.ports`; the record-level
1219
+ * `port` (the winner's at pairing, the operator's after a PATCH) is the
1220
+ * fallback for an in-memory record assembled without the map (issue #135).
1221
+ * Before that, one port was shared by every host, so a reconnect to a
1222
+ * non-winner host dialed the right scheme at the wrong port.
1223
+ *
1044
1224
  * "Per-host" covers the CHANNEL-LEVEL handshake failures as well as the dial:
1045
1225
  * a socket that opens and then dies mid-handshake (the peer accepted the TCP
1046
1226
  * connection and went away, a proxy reset it, the Noise exchange was cut) is a
@@ -1081,7 +1261,11 @@ export class DeviceConnection {
1081
1261
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError()
1082
1262
  let link: LiveLink
1083
1263
  try {
1084
- link = await this.#dial(host, this.record.port, epoch)
1264
+ // Per-host port, with the record-level (winner's) port as the fallback
1265
+ // for an in-memory record assembled without the map — the same `?.`
1266
+ // default the scheme gets in #dial (issue #135).
1267
+ const port = this.record.ports?.[host] ?? this.record.port
1268
+ link = await this.#dial(host, port, epoch)
1085
1269
  } catch (error) {
1086
1270
  if (error instanceof IdentityMismatchError) throw error
1087
1271
  lastError = error instanceof Error ? error : new Error(String(error))
@@ -1117,10 +1301,17 @@ export class DeviceConnection {
1117
1301
  }
1118
1302
 
1119
1303
  async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
1120
- // Bare IPv6 hosts (the archive's normalized form) must be bracketed into
1121
- // a URL authority or `new WebSocket` throws Invalid URL.
1122
- const url = `ws://${urlHost(host)}:${port}${remoteConnectPath}`
1123
- const ws = new WebSocket(url)
1304
+ // (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
1305
+ // The URL comes from the SAME helper the pairing dial uses (issue #126):
1306
+ // it brackets bare IPv6 hosts (the archive's normalized form — splicing one
1307
+ // raw into a URL makes `new WebSocket` throw Invalid URL) and selects
1308
+ // `ws:`/`wss:` from the record's own scheme for this host. Persisting the
1309
+ // scheme is what makes a TLS-proxied desktop RE-connectable: #124 paired
1310
+ // over `wss` and this dial then rebuilt `ws://` for every later session.
1311
+ const ws = new WebSocket(
1312
+ directEndpointUrl({ host, port, security: this.record.security?.[host] ?? 'ws' }),
1313
+ this.#wsOptions,
1314
+ )
1124
1315
  const controller = new AbortController()
1125
1316
 
1126
1317
  const opened = new Promise<void>((resolve) => ws.once('open', () => resolve()))
@@ -1207,11 +1398,18 @@ export class DeviceConnection {
1207
1398
  throw error instanceof Error ? error : new Error(String(error))
1208
1399
  }
1209
1400
 
1401
+ // REBUILT, never patched: the object is replaced on every hello, so a
1402
+ // reconnect to a peer that no longer advertises the capability (or a
1403
+ // downgrade) cannot inherit a stale `messageSummaries` from the previous
1404
+ // link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
1210
1405
  this.#limits = {
1211
1406
  heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
1212
1407
  subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
1213
1408
  pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
1214
1409
  inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
1410
+ ...(typeof hello.agentMessageSummariesVersion === 'number'
1411
+ ? { messageSummaries: hello.agentMessageSummariesVersion }
1412
+ : {}),
1215
1413
  }
1216
1414
 
1217
1415
  // Size the admission window from the peer's OWN concurrency budget now that
@@ -1230,6 +1428,14 @@ export class DeviceConnection {
1230
1428
  throw error instanceof Error ? error : new Error(String(error))
1231
1429
  }
1232
1430
 
1431
+ // The peer's own view of its addresses (issue #143), learned while the link
1432
+ // is AUTHENTICATED — the desktop gates `connection.endpoints` on the
1433
+ // capability of the authenticated session — and BEFORE the epoch gate
1434
+ // below, so a close() landing during the call is caught by that same gate
1435
+ // instead of publishing `connected` on a dead link. Bounded and
1436
+ // failure-tolerant: everything about it lives in `#learnEndpoints`.
1437
+ await this.#learnEndpoints(link, hello.connectionEndpointsVersion)
1438
+
1233
1439
  if (epoch !== this.#epoch) {
1234
1440
  // close() raced the handshake: drop the link quietly (close() already
1235
1441
  // tore down what it could see; this catches the just-dialed socket).
@@ -1282,6 +1488,71 @@ export class DeviceConnection {
1282
1488
  )
1283
1489
  }
1284
1490
 
1491
+ /**
1492
+ * Best-effort enrichment of the dial list from the peer's advertised
1493
+ * endpoints (issue #143).
1494
+ *
1495
+ * WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
1496
+ * address that appeared after the QR was minted. It does NOT heal a total
1497
+ * address change (the list is captured on the old path) and can never carry
1498
+ * TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
1499
+ * comes from the QR or the operator.
1500
+ *
1501
+ * EVERY failure here is SILENT and non-fatal, because this is an enrichment,
1502
+ * not a verdict: a peer that does not advertise the capability (`connection`
1503
+ * through 2.1.3 has neither the hello bit nor this method), one advertising a
1504
+ * version this client cannot speak (strictly `1` — a higher number is not
1505
+ * evidence that it speaks 1, the same rule the paged-summaries bit took), a
1506
+ * refused call (the desktop throws when its gateway is not running), a wedged
1507
+ * one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
1508
+ * answer, or a store that cannot write. None of them is a CONNECTION failure:
1509
+ * the link comes up either way, and only the archive is left without the new
1510
+ * addresses — this instance's own record may already carry them, because the
1511
+ * merge runs before the callback (see the paragraph below).
1512
+ *
1513
+ * The MERGE happens in two places on purpose: here (this instance's record,
1514
+ * so its next reconnect dials the new address) and through the callback (the
1515
+ * assembly's archive, so a restart keeps it). They are the same pure rule
1516
+ * against two copies of the record, and each is idempotent.
1517
+ *
1518
+ * ONE `catch` covers everything after the gate, and that is the contract, not
1519
+ * laziness: a refused call, a wedged one (bounded by
1520
+ * {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
1521
+ * parser throws rather than defending every field twice), a throwing assembly
1522
+ * hook (an unwritable archive), or a bug in the merge must all land here —
1523
+ * none of them may fail the handshake. The observable promise is therefore
1524
+ * structural: this method cannot reject.
1525
+ */
1526
+ async #learnEndpoints(link: LiveLink, capability: number | undefined): Promise<void> {
1527
+ if (capability !== 1) return
1528
+ try {
1529
+ const answer = await this.#houseCall<unknown>(
1530
+ link,
1531
+ 'connection.endpoints',
1532
+ { domain: 'agent' },
1533
+ ENDPOINTS_LEARN_TIMEOUT_MS,
1534
+ )
1535
+ const endpoints = advertisedEndpoints(answer, this.record.desktopIdentity)
1536
+ if (endpoints.length === 0) return
1537
+ const added = mergeAdvertisedEndpoints(this.record, endpoints)
1538
+ if (added > 0) {
1539
+ // Count only: a host is peer-supplied text, and the logging rule clips
1540
+ // peer text at the render site (src/AGENTS.md). deviceId is exempt.
1541
+ console.info(
1542
+ `[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`,
1543
+ )
1544
+ }
1545
+ this.#onAdvertisedEndpoints?.(endpoints)
1546
+ } catch {
1547
+ // Silent by contract. The failure modes are all "this desktop does not
1548
+ // want to tell us today" (or "this archive cannot take it"), and a log
1549
+ // line per reconnect would be noise on a device that is otherwise
1550
+ // perfectly healthy. One bounded line cannot be produced here anyway: the
1551
+ // cause may be the peer's own answer text, which src/AGENTS.md clips at
1552
+ // the render site — and this line would have to run on every connect.
1553
+ }
1554
+ }
1555
+
1285
1556
  /**
1286
1557
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
1287
1558
  * UNAUTHENTICATED) is retried exactly once without it before the pairing
@@ -1764,6 +2035,9 @@ export class DeviceConnection {
1764
2035
  try {
1765
2036
  cb(next)
1766
2037
  } catch (error) {
2038
+ // LOCAL observer fan-out (our own listeners), so no peer text can reach
2039
+ // this error: the object print keeps the stack, which is the useful
2040
+ // part here (issue #161's boundary).
1767
2041
  console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error)
1768
2042
  }
1769
2043
  }
@@ -1771,6 +2045,8 @@ export class DeviceConnection {
1771
2045
  try {
1772
2046
  cb(next)
1773
2047
  } catch (error) {
2048
+ // LOCAL: `waitForSettled`'s internal closure, no peer text — the object
2049
+ // print (and its stack) is deliberate (issue #161's boundary).
1774
2050
  console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error)
1775
2051
  }
1776
2052
  }