dsh-cherry-bridge 0.2.0 → 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/AGENTS.md CHANGED
@@ -21,8 +21,9 @@ Pinned at **exact versions**, no `^`, with contract assertions on the pins:
21
21
  | `ws` | `8.22.0` |
22
22
  | `zod` | `4.3.4` |
23
23
 
24
- These are the versions Cherry Studio **desktop 2.1.8** ships (verified at
25
- tag `v2.1.8`). Upgrading either protocol package means re-running
24
+ These are the versions Cherry Studio **desktop 2.1.8** ships, and 2.1.9/2.1.10
25
+ still ship the same pair (verified at tags `v2.1.8`, `v2.1.9`, `v2.1.10`).
26
+ Upgrading either protocol package means re-running
26
27
  `tests/contract/contract.test.ts` against schema drift — the suite models the
27
28
  **six behavioral differences** between our loopback fake peer and a real
28
29
  desktop, each mapped to a manual step
@@ -65,6 +66,25 @@ sends `partsMode: 'paged'` only at exactly `1`. Parts keep their clamp; only
65
66
  the message page widens (to the negotiated `pageItems`); a
66
67
  `RESOURCE_EXHAUSTED` with `details.retryable: false` says retrying cannot help.
67
68
 
69
+ **Advertised endpoints (#143).** The hello `connectionEndpointsVersion` bit
70
+ (strictly `1`; absent through 2.1.3 — 2.1.4 already answers both the bit and
71
+ the method) unlocks ONE best-effort
72
+ `connection.endpoints` (`{domain:'agent'}` → `{desktopIdentity, endpoints}`) per
73
+ handshake, awaited INSIDE it after authenticate and bounded by
74
+ `ENDPOINTS_LEARN_TIMEOUT_MS` (2 s). That bound is derived in ONE narrow sense:
75
+ this wait alone stays below `DEVICE_SETTLE_WAIT_MS` (5 s), so a peer that hangs
76
+ on this one call cannot by itself outlive the window a tool waits for
77
+ `connecting` — the handshake's other waits (a 10 s dial, hello/authenticate on
78
+ the 60 s rpc default) already can, and are not this constant's business. The
79
+ desktop gates the call on the authenticated session and can refuse it (gateway
80
+ not running), so EVERY failure is silent — structurally: `#learnEndpoints` wraps
81
+ everything after the capability gate in one `catch` (a refused or wedged call, a
82
+ malformed answer, a throwing assembly hook), and cannot reject. The answer is discarded
83
+ WHOLE when it does not name the pinned `desktopIdentity`, and each entry is
84
+ filtered by the peer's own `advertisedEndpointSchema` (rejects loopback,
85
+ normalizes the host). Learned hosts join the archive as candidates — see
86
+ [Device archive](#device-archive-schemeport-per-host).
87
+
68
88
  **Pacing: two tiers against the desktop's allowances.**
69
89
 
70
90
  | Tier | Methods | Desktop allowance | Default |
@@ -628,10 +648,13 @@ fallback. The steady-state dial uses `directEndpointUrl` + both maps (`??`):
628
648
  candidate's own host/port/scheme, never the record. v2→v3 backfills `ws`,
629
649
  v3→v4 `ports` from `port`, atomic on read. Scheme loss breaks a TLS proxy;
630
650
  port loss dials the right scheme at the wrong port. Re-typing keeps scheme
631
- (`securityFor`)/port (`portsFor`); whole-replace `hosts` defaults new hosts to
651
+ (`securityFor`)/port (`portsFor`) CASE-INSENSITIVELY — a re-typed spelling is
652
+ the same address (#189), and a PATCH refuses a list holding two spellings of
653
+ one host; whole-replace `hosts` defaults new hosts to
632
654
  `ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
633
- no-op (the settings UI sends both on save). `GET /devices` reports both maps
634
- (addresses, not credentials); tools/diagnostics show only `port`. The settings
655
+ no-op (the settings UI sends both on save). `GET /devices` reports both maps and
656
+ `forgottenHosts` (addresses and intent, not credentials); tools/diagnostics show
657
+ only `port`. The settings
635
658
  page shows the per-host ports only when they differ from the record-level one.
636
659
  TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
637
660
  `ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
@@ -639,6 +662,55 @@ validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
639
662
  ignored (half-applied trust reaches some peers, not others). Never persisted,
640
663
  logged or reported; `ca` is PEM text.
641
664
 
665
+ Addresses a peer ADVERTISES (`connection.endpoints`, #143) are ADD-ONLY
666
+ (`mergeAdvertisedEndpoints`): only hosts the record does not already dial are
667
+ appended, each with its OWN port and scheme (the one-entry-per-host invariant is
668
+ never split), capped at `MAX_CANDIDATE_HOSTS` = 32 — what a pairing QR can
669
+ produce (BOTH arms cap at 32), so learning cannot build a record no pairing
670
+ could have. An existing entry — the operator's port, a TLS proxy — is never
671
+ re-pointed and never removed by LEARNING; comparison is CASE-INSENSITIVE (a
672
+ PATCHed record keeps the operator's spelling), and an IPv6 literal in another
673
+ expansion is not reconciled — the PATCH path owns that spelling. A PATCH that
674
+ drops a host therefore still lets a later handshake re-learn it (the peer is the
675
+ source of truth for its own addresses); the operator's answer is **forgetting**
676
+ (#185): `DeviceStore.forgetEndpoints` (through `cherry_devices`
677
+ `action: "forget"`) removes each named host from `hosts`/`security`/`ports` as
678
+ one unit and records it in the record's OPTIONAL `forgottenHosts`, which the
679
+ merge skips in every case — so a desktop that keeps advertising the address
680
+ cannot put it back. `action: "remember"` lifts the mark (the next handshake may
681
+ then re-add it), and a PATCH whose `hosts` names a forgotten host clears that
682
+ host's mark in the same write: an explicit operator list is intent to dial, and
683
+ `hosts`/`forgottenHosts` are disjoint (`assertValidRecord` refuses the overlap).
684
+ Forgetting the LAST candidate is refused (`LastCandidateHostError`) and writes
685
+ nothing — a record with nothing to dial answers `offline` until a re-pair or a
686
+ PATCH writes a fresh `hosts` list.
687
+ `forgottenHosts` is optional and versionless on purpose: absence means "nothing
688
+ forgotten", an older build ignores the key instead of refusing the archive, and
689
+ no migration (or `schemaVersion` 5) was needed. TWO consequences of that
690
+ compatibility are stated, not implied: a RE-PAIR replaces the record wholesale,
691
+ so it clears every mark (a fresh pairing is a fresh decision — the QR's own
692
+ candidates may reintroduce the address), and a DOWNGRADE can produce the overlap
693
+ the write paths refuse, because the older build preserves the key but cannot
694
+ honour it — the READ path repairs that one state
695
+ (`reconcileForgottenMarks`: keeps the dial list, drops the mark, warns), since
696
+ refusing it would brick every device tool until a 0600 file was hand-edited,
697
+ while `upsert`/`forgetEndpoints`/`rememberEndpoints` stay strict. Both write
698
+ actions EVICT the pooled connection ON A CHANGE, exactly like a PATCH's address
699
+ maintenance, because a `DeviceConnection` captures its record at construction.
700
+ `GET /devices` reports `forgottenHosts` beside the address maps, and since issue
701
+ #204 the settings page shows the marks with a 恢复 each, plus a 忘记 beside every
702
+ dialed host: both ride the two POST routes in the table, call the SAME store
703
+ methods the `cherry_devices` action does, and answer the PATCH's projection. A
704
+ PATCH that includes a marked host still clears its mark, so re-admitting an
705
+ address immediately stays the PATCH's job (恢复 only lifts the mark).
706
+ The write goes through
707
+ `DeviceStore.learnEndpoints`, which merges a clone of the STORED record (never
708
+ the connection's: that one mirrors the live `accessToken`, which this archive
709
+ deliberately does not keep), refuses when the record's `desktopIdentity` is no
710
+ longer the pin the answer was validated against (a re-pair), and writes only
711
+ when a host was added, so a stable desktop costs no archive write per
712
+ reconnect.
713
+
642
714
  ### Pairing: one dial budget across candidates
643
715
 
644
716
  `desktopIdentity` is decoded at PARSE time with the transport's own
@@ -698,6 +770,8 @@ listener, no LAN exposure, no separate credentials**:
698
770
  | --- | --- |
699
771
  | `GET /devices` | Device list. |
700
772
  | `PATCH /devices/:id` | Update display name / enabled / addresses / port (`hosts` is **whole-replace**). |
773
+ | `POST /devices/:id/forget` | Drop these `hosts` and record the forget marks (the `cherry_devices` action's face, #204). |
774
+ | `POST /devices/:id/remember` | Lift these marks; `hosts` is untouched, so the address returns on the next advertisement. |
701
775
  | `DELETE /devices/:id` | Remove the device and tear down its connection. |
702
776
  | `GET /scope` | The whole scope state in one body (see below). |
703
777
  | `PUT /scope` | Save the scope — read-modify-write, `expectedRevision` required. |
@@ -705,6 +779,17 @@ listener, no LAN exposure, no separate credentials**:
705
779
  | `GET /events` | SSE: `device-state` / `pairing-progress`. |
706
780
  | `GET /diagnostics` | Operations report (below). |
707
781
 
782
+ Device-route refusals (`{error}`; the page branches on the CODE): `forbidden` 403
783
+ (the fence, before routing) · `invalid-request` 400 (malformed body, or a device id
784
+ that is not valid URL encoding) · `last-address` 400 (the action would leave nothing
785
+ to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
786
+ 404 (unknown device OR action — also what a QUARANTINED archive answers, its view
787
+ being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
788
+ 503 (`{file, corrupt, recovery}`; a damaged archive empties its view and 404s first,
789
+ so this arm only covers a store that refuses after that read — unreachable for the
790
+ shipped store, kept so the arm exists); anything else is the generic 500
791
+ `internal-error`.
792
+
708
793
  > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
709
794
  > **additionally** attaches the optional `claimId` (a cross-run isolation
710
795
  > extension, not in the minimal set) — consumers must tolerate its absence.
@@ -816,20 +901,25 @@ refuse; (3) `Origin` present and not same-origin with `Host` → refuse;
816
901
  Neither reference is published with this repository.
817
902
 
818
903
  - **`.scratch-csa`** (gitignored) — the official Cherry Studio **app**
819
- (`CherryHQ/cherry-studio-app` @ v0.5.0, read 2026-10-27), the **same role we
904
+ (`CherryHQ/cherry-studio-app` @ v0.5.2, read 2026-10-31), the **same role we
820
905
  play** (protocol *client* against the desktop's server). Its remote layer is
821
906
  `src/backend/services/remoteAgent/`; "what the official client does" comes
822
- from there. v0.5.0 moved onto our pins (0.7.0 / 0.2.5), whose one behavioural
823
- change is the versioned pairing QR.
824
- - **`_research/cherry-studio`** — the **desktop** source at v2.1.6 (`90b234e`),
825
- read alongside v2.1.7, the protocol **server** of record. The faces we depend
826
- on are `src/main/services/remoteAccess/` and
907
+ from there. v0.5.0 moved onto the pinned generation, whose one behavioural
908
+ change is the versioned pairing QR; v0.5.1 and v0.5.2 changed NOTHING on that
909
+ face — `git rev-parse <tag>:src/backend/services/remoteAgent` is
910
+ `16e505fe…` at all three tags, and the pins are still 0.7.0 / 0.2.5. Check
911
+ that tree hash before believing a new app version changed the wire.
912
+ - **`_research/cherry-studio`** — the **desktop** source, the protocol **server**
913
+ of record: a shallow `blob:none` clone carrying tags `v2.1.6` (`90b234e`)
914
+ through `v2.1.10` (`c60cd41a6`). Diff TREES between tags; attribute a change
915
+ with the GitHub API, not `git log` (the graft hides the range). The faces we
916
+ depend on are `src/main/services/remoteAccess/` and
827
917
  `packages/remote-{protocol,transport}`; `RemoteAccessService.ts` carries both
828
918
  rate lines. Gitignored; treated as read-only research.
829
919
  - `node_modules/@cherrystudio/remote-protocol` / `remote-transport` are the
830
920
  protocol schema source of truth for what we send and decode.
831
921
 
832
- **Verified desktop range: 2.1.4 → 2.1.8.** mDNS, protocol versions (`[1]`) and
922
+ **Verified desktop range: 2.1.4 → 2.1.10.** mDNS, protocol versions (`[1]`) and
833
923
  Noise saw **zero changes**; pairing only ADDED the v3 QR arm (below). Notable
834
924
  faces:
835
925
 
@@ -843,6 +933,7 @@ faces:
843
933
  | 2.1.5 keychain-less Linux stores the identity as plaintext 0600; identity is never regenerated | Upgrades need **no re-pairing** |
844
934
  | 2.1.7 raises the shared allowance to 256/s and refuses an over-budget request (`RATE_LIMITED` + `retryAfterMs`) instead of closing 1011 | No schema change; our 32/s default already sits far below it |
845
935
  | 2.1.8: v3 pairing QR (`bc570d2b9`, PR #21387) — `pairingQrSchema` v2|v3 with custom host/port/security `endpoints`, tightened IPv6; paged message summaries (`54736dc9`/`8bf2fe28d`, PR #21992) advertised by the `agentMessageSummariesVersion` hello capability; server `PAGE_ITEM_BYTES = 62_279` per-item budget | Pairing consumes the published union (#124); summaries are consumed behind the hello capability (#105); a byte-budgeted page may come back SHORTER, which our walk already follows |
936
+ | 2.1.9/2.1.10: **only** `RemoteConnection.send()`'s write chain changed (`16d9487a9`, PR #22593) — `this.writes` keeps its links resolved, so one rejected send no longer makes every later send skip `channel.write`; every other remote-access file and both protocol packages are byte-identical to 2.1.8 | No client change: our answers never depend on the notification stream (list snapshots are the authority), and our own serialized queue (`SecondWindowRateLimiter`) cannot reject |
846
937
  | `5db8ab7` (agent runtime DSH → Pi, deleting `packages/dsh-bridge`) | The **opposite** bridge (Cherry driving DSH); unrelated to the remote-access face |
847
938
 
848
939
  **Traps in the official app (a cautionary list, not a spec).**
@@ -855,6 +946,4 @@ faces:
855
946
  plus the cumulative arm.
856
947
  - Its read path never replays a read; we replay reads **once** (deliberate
857
948
  enhancement) while both sides keep commands receipt-recovered.
858
- - Its command journal backs command recovery, which is why our commands are
859
- never replayed.
860
949
 
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 { directEndpointUrl } 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'
@@ -14,6 +15,7 @@ import {
14
15
  RATE_LIMIT_MAX_WAIT_MS,
15
16
  } from './defaults.js'
16
17
  import type { DeviceRecord } from './deviceStore.js'
18
+ import { mergeAdvertisedEndpoints } from './deviceStore.js'
17
19
  import { clipText, LOG_TEXT_MAX } from './text.js'
18
20
 
19
21
  /**
@@ -138,6 +140,19 @@ export interface DeviceConnectionOptions {
138
140
  * (up to 60s per attempt) to find the desktop again.
139
141
  */
140
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
141
156
  }
142
157
 
143
158
  /**
@@ -269,6 +284,35 @@ const DEFAULT_BACKOFF_MAX_MS = 60_000
269
284
  const DEFAULT_REFRESH_GRACE_MS = 30_000
270
285
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
271
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
+
272
316
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
273
317
  const SILENCE_FACTOR = 2
274
318
  const RATE_WINDOW_MS = 1_000
@@ -378,7 +422,13 @@ class SecondWindowRateLimiter {
378
422
  // released strictly after the one before it reached the head.
379
423
  let release!: () => void
380
424
  const ahead = this.#queue
381
- 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)
382
432
  await ahead
383
433
  try {
384
434
  for (;;) {
@@ -592,6 +642,8 @@ interface HelloResult {
592
642
  heartbeatMs?: number
593
643
  /** The paged-message-summaries capability (issue #105), when advertised. */
594
644
  agentMessageSummariesVersion?: number
645
+ /** The advertised-endpoints capability (issue #143), when advertised. */
646
+ connectionEndpointsVersion?: number
595
647
  }
596
648
 
597
649
  interface AuthResult {
@@ -638,6 +690,51 @@ function businessWindowFor(reportedInFlightRequests: number): number {
638
690
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)))
639
691
  }
640
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
+
641
738
  /**
642
739
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
643
740
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
@@ -738,6 +835,7 @@ export class DeviceConnection {
738
835
  readonly #protocolVersions: readonly number[]
739
836
  readonly #helloProtocolVersions: readonly number[]
740
837
  readonly #transportRetryWaitMs: number
838
+ readonly #onAdvertisedEndpoints: ((endpoints: readonly DirectEndpoint[]) => void) | undefined
741
839
  readonly #stateListeners = new Set<(state: DeviceState) => void>()
742
840
  /**
743
841
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -806,6 +904,7 @@ export class DeviceConnection {
806
904
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS
807
905
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions
808
906
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? DEFAULT_TRANSPORT_RETRY_WAIT_MS
907
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints
809
908
  }
810
909
 
811
910
  /**
@@ -1329,6 +1428,14 @@ export class DeviceConnection {
1329
1428
  throw error instanceof Error ? error : new Error(String(error))
1330
1429
  }
1331
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
+
1332
1439
  if (epoch !== this.#epoch) {
1333
1440
  // close() raced the handshake: drop the link quietly (close() already
1334
1441
  // tore down what it could see; this catches the just-dialed socket).
@@ -1381,6 +1488,71 @@ export class DeviceConnection {
1381
1488
  )
1382
1489
  }
1383
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
+
1384
1556
  /**
1385
1557
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
1386
1558
  * UNAUTHENTICATED) is retried exactly once without it before the pairing