dsh-cherry-bridge 0.2.0 → 0.2.2

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
@@ -35,6 +36,7 @@ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
35
36
  | `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
36
37
  | `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
37
38
  | `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
39
+ | `unackedBytes` | 262,144 (256 KiB) | the peer HOLDS `agent.events` beyond this much un-acked delivery — terminal included; the layer acks at ¼ of it (hello value, else this) (issue #212) |
38
40
 
39
41
  Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
40
42
  budget aside, still count-paged): `pageItems: 100` fat parts produced a record
@@ -65,6 +67,25 @@ sends `partsMode: 'paged'` only at exactly `1`. Parts keep their clamp; only
65
67
  the message page widens (to the negotiated `pageItems`); a
66
68
  `RESOURCE_EXHAUSTED` with `details.retryable: false` says retrying cannot help.
67
69
 
70
+ **Advertised endpoints (#143).** The hello `connectionEndpointsVersion` bit
71
+ (strictly `1`; absent through 2.1.3 — 2.1.4 already answers both the bit and
72
+ the method) unlocks ONE best-effort
73
+ `connection.endpoints` (`{domain:'agent'}` → `{desktopIdentity, endpoints}`) per
74
+ handshake, awaited INSIDE it after authenticate and bounded by
75
+ `ENDPOINTS_LEARN_TIMEOUT_MS` (2 s). That bound is derived in ONE narrow sense:
76
+ this wait alone stays below `DEVICE_SETTLE_WAIT_MS` (5 s), so a peer that hangs
77
+ on this one call cannot by itself outlive the window a tool waits for
78
+ `connecting` — the handshake's other waits (a 10 s dial, hello/authenticate on
79
+ the 60 s rpc default) already can, and are not this constant's business. The
80
+ desktop gates the call on the authenticated session and can refuse it (gateway
81
+ not running), so EVERY failure is silent — structurally: `#learnEndpoints` wraps
82
+ everything after the capability gate in one `catch` (a refused or wedged call, a
83
+ malformed answer, a throwing assembly hook), and cannot reject. The answer is discarded
84
+ WHOLE when it does not name the pinned `desktopIdentity`, and each entry is
85
+ filtered by the peer's own `advertisedEndpointSchema` (rejects loopback,
86
+ normalizes the host). Learned hosts join the archive as candidates — see
87
+ [Device archive](#device-archive-schemeport-per-host).
88
+
68
89
  **Pacing: two tiers against the desktop's allowances.**
69
90
 
70
91
  | Tier | Methods | Desktop allowance | Default |
@@ -167,8 +188,7 @@ projection. An unchanged message costs no repeat `agent.parts.list`
167
188
  RPC on send → wait → read rounds, while every read still re-fetches and
168
189
  sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
169
190
  revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
170
- this beside the cache). A hit is still not a zero-RPC read: 2.1.6+ externalizes
171
- every part over 4,096 characters.
191
+ this beside the cache).
172
192
 
173
193
  - **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
174
194
  `<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
@@ -628,17 +648,68 @@ 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
635
- page shows the per-host ports only when they differ from the record-level one.
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`.
636
658
  TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
637
659
  `ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
638
660
  validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
639
661
  ignored (half-applied trust reaches some peers, not others). Never persisted,
640
662
  logged or reported; `ca` is PEM text.
641
663
 
664
+ Addresses a peer ADVERTISES (`connection.endpoints`, #143) are ADD-ONLY
665
+ (`mergeAdvertisedEndpoints`): only hosts the record does not already dial are
666
+ appended, each with its OWN port and scheme (the one-entry-per-host invariant is
667
+ never split), capped at `MAX_CANDIDATE_HOSTS` = 32 — what a pairing QR can
668
+ produce (BOTH arms cap at 32), so learning cannot build a record no pairing
669
+ could have. An existing entry — the operator's port, a TLS proxy — is never
670
+ re-pointed and never removed by LEARNING; comparison is CASE-INSENSITIVE (a
671
+ PATCHed record keeps the operator's spelling), and an IPv6 literal in another
672
+ expansion is not reconciled — the PATCH path owns that spelling. A PATCH that
673
+ drops a host therefore still lets a later handshake re-learn it (the peer is the
674
+ source of truth for its own addresses); the operator's answer is **forgetting**
675
+ (#185): `DeviceStore.forgetEndpoints` (through `cherry_devices`
676
+ `action: "forget"`) removes each named host from `hosts`/`security`/`ports` as
677
+ one unit and records it in the record's OPTIONAL `forgottenHosts`, which the
678
+ merge skips in every case — so a desktop that keeps advertising the address
679
+ cannot put it back. `action: "remember"` lifts the mark (the next handshake may
680
+ then re-add it), and a PATCH whose `hosts` names a forgotten host clears that
681
+ host's mark in the same write: an explicit operator list is intent to dial, and
682
+ `hosts`/`forgottenHosts` are disjoint (`assertValidRecord` refuses the overlap).
683
+ Forgetting the LAST candidate is refused (`LastCandidateHostError`) and writes
684
+ nothing — a record with nothing to dial answers `offline` until a re-pair or a
685
+ PATCH writes a fresh `hosts` list.
686
+ `forgottenHosts` is optional and versionless on purpose: absence means "nothing
687
+ forgotten", an older build ignores the key instead of refusing the archive, and
688
+ no migration (or `schemaVersion` 5) was needed. TWO consequences of that
689
+ compatibility are stated, not implied: a RE-PAIR replaces the record wholesale,
690
+ so it clears every mark (a fresh pairing is a fresh decision — the QR's own
691
+ candidates may reintroduce the address), and a DOWNGRADE can produce the overlap
692
+ the write paths refuse, because the older build preserves the key but cannot
693
+ honour it — the READ path repairs that one state
694
+ (`reconcileForgottenMarks`: keeps the dial list, drops the mark, warns), since
695
+ refusing it would brick every device tool until a 0600 file was hand-edited,
696
+ while `upsert`/`forgetEndpoints`/`rememberEndpoints` stay strict. Both write
697
+ actions EVICT the pooled connection ON A CHANGE, exactly like a PATCH's address
698
+ maintenance, because a `DeviceConnection` captures its record at construction.
699
+ `GET /devices` reports `forgottenHosts` beside the address maps, and since issue
700
+ #204 the settings page shows the marks with a 恢复 each, plus a 忘记 beside every
701
+ dialed host: both ride the two POST routes in the table, call the SAME store
702
+ methods the `cherry_devices` action does, and answer the PATCH's projection. A
703
+ PATCH that includes a marked host still clears its mark, so re-admitting an
704
+ address immediately stays the PATCH's job (恢复 only lifts the mark).
705
+ The write goes through
706
+ `DeviceStore.learnEndpoints`, which merges a clone of the STORED record (never
707
+ the connection's: that one mirrors the live `accessToken`, which this archive
708
+ deliberately does not keep), refuses when the record's `desktopIdentity` is no
709
+ longer the pin the answer was validated against (a re-pair), and writes only
710
+ when a host was added, so a stable desktop costs no archive write per
711
+ reconnect.
712
+
642
713
  ### Pairing: one dial budget across candidates
643
714
 
644
715
  `desktopIdentity` is decoded at PARSE time with the transport's own
@@ -698,6 +769,8 @@ listener, no LAN exposure, no separate credentials**:
698
769
  | --- | --- |
699
770
  | `GET /devices` | Device list. |
700
771
  | `PATCH /devices/:id` | Update display name / enabled / addresses / port (`hosts` is **whole-replace**). |
772
+ | `POST /devices/:id/forget` | Drop these `hosts` and record the forget marks (the `cherry_devices` action's face, #204). |
773
+ | `POST /devices/:id/remember` | Lift these marks; `hosts` is untouched, so the address returns on the next advertisement. |
701
774
  | `DELETE /devices/:id` | Remove the device and tear down its connection. |
702
775
  | `GET /scope` | The whole scope state in one body (see below). |
703
776
  | `PUT /scope` | Save the scope — read-modify-write, `expectedRevision` required. |
@@ -705,6 +778,16 @@ listener, no LAN exposure, no separate credentials**:
705
778
  | `GET /events` | SSE: `device-state` / `pairing-progress`. |
706
779
  | `GET /diagnostics` | Operations report (below). |
707
780
 
781
+ Device-route refusals (`{error}`; the page branches on the CODE): `forbidden` 403
782
+ (the fence, before routing) · `invalid-request` 400 (malformed body, or a device id
783
+ that is not valid URL encoding) · `last-address` 400 (the action would leave nothing
784
+ to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
785
+ 404 (unknown device OR action — also what a QUARANTINED archive answers, its view
786
+ being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
787
+ 503 (`{file, corrupt, recovery}`; a damaged archive 404s first, so this arm covers
788
+ only a store that refuses after that read); anything else is the generic 500
789
+ `internal-error`.
790
+
708
791
  > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
709
792
  > **additionally** attaches the optional `claimId` (a cross-run isolation
710
793
  > extension, not in the minimal set) — consumers must tolerate its absence.
@@ -759,9 +842,8 @@ the error message).
759
842
  construction**, not by a deny-list. `limits` is re-projected from its four
760
843
  keys: a whitelist inside the whitelist.
761
844
  - `stats.connectionsBuilt` counts connections built since the last `apply`, not
762
- RPC requests — those belong to the transport; a same-named metric would be
763
- misread. Counters and the error ring are zeroed on every `apply`: the
764
- statistics describe **only** the current assembly.
845
+ RPC requests (those belong to the transport). Counters and the error ring are
846
+ zeroed on every `apply`: they describe **only** the current assembly.
765
847
 
766
848
  ### Logging: levels, identifiers, and what never appears
767
849
 
@@ -816,20 +898,25 @@ refuse; (3) `Origin` present and not same-origin with `Host` → refuse;
816
898
  Neither reference is published with this repository.
817
899
 
818
900
  - **`.scratch-csa`** (gitignored) — the official Cherry Studio **app**
819
- (`CherryHQ/cherry-studio-app` @ v0.5.0, read 2026-10-27), the **same role we
901
+ (`CherryHQ/cherry-studio-app` @ v0.5.2, read 2026-10-31), the **same role we
820
902
  play** (protocol *client* against the desktop's server). Its remote layer is
821
903
  `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
904
+ from there. v0.5.0 moved onto the pinned generation, whose one behavioural
905
+ change is the versioned pairing QR; v0.5.1 and v0.5.2 changed NOTHING on that
906
+ face — `git rev-parse <tag>:src/backend/services/remoteAgent` is
907
+ `16e505fe…` at all three tags, and the pins are still 0.7.0 / 0.2.5. Check
908
+ that tree hash before believing a new app version changed the wire.
909
+ - **`_research/cherry-studio`** — the **desktop** source, the protocol **server**
910
+ of record: a shallow `blob:none` clone carrying tags `v2.1.6` (`90b234e`)
911
+ through `v2.1.10` (`c60cd41a6`). Diff TREES between tags; attribute a change
912
+ with the GitHub API, not `git log` (the graft hides the range). The faces we
913
+ depend on are `src/main/services/remoteAccess/` and
827
914
  `packages/remote-{protocol,transport}`; `RemoteAccessService.ts` carries both
828
915
  rate lines. Gitignored; treated as read-only research.
829
916
  - `node_modules/@cherrystudio/remote-protocol` / `remote-transport` are the
830
917
  protocol schema source of truth for what we send and decode.
831
918
 
832
- **Verified desktop range: 2.1.4 → 2.1.8.** mDNS, protocol versions (`[1]`) and
919
+ **Verified desktop range: 2.1.4 → 2.1.10.** mDNS, protocol versions (`[1]`) and
833
920
  Noise saw **zero changes**; pairing only ADDED the v3 QR arm (below). Notable
834
921
  faces:
835
922
 
@@ -843,6 +930,7 @@ faces:
843
930
  | 2.1.5 keychain-less Linux stores the identity as plaintext 0600; identity is never regenerated | Upgrades need **no re-pairing** |
844
931
  | 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
932
  | 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 |
933
+ | 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
934
  | `5db8ab7` (agent runtime DSH → Pi, deleting `packages/dsh-bridge`) | The **opposite** bridge (Cherry driving DSH); unrelated to the remote-access face |
847
935
 
848
936
  **Traps in the official app (a cautionary list, not a spec).**
@@ -853,8 +941,4 @@ faces:
853
941
  answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
854
942
  - Its read path refuses an unusable `byteLength`; we fall back to a slice bound
855
943
  plus the cumulative arm.
856
- - Its read path never replays a read; we replay reads **once** (deliberate
857
- 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
944
 
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
  /**
@@ -36,6 +38,17 @@ export interface EffectiveLimits {
36
38
  subscriptions: number
37
39
  pageItems: number
38
40
  inFlightRequests: number
41
+ /**
42
+ * The peer's hello-advertised UN-ACKED DELIVERY WINDOW (protocol
43
+ * `remoteLimits.unackedBytes`). Past this much delivered-but-unacknowledged
44
+ * traffic the desktop holds its event stream (issue #212), so the
45
+ * subscription layer sizes its own ack watermark from it.
46
+ *
47
+ * ABSENT, never defaulted — same rule as `messageSummaries` below: a hello
48
+ * that does not carry the key leaves the layer on the pinned protocol
49
+ * constant rather than on a guess about this peer.
50
+ */
51
+ unackedBytes?: number
39
52
  /**
40
53
  * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
41
54
  * stored BESIDE the limits because it is re-read by that same handshake.
@@ -138,6 +151,19 @@ export interface DeviceConnectionOptions {
138
151
  * (up to 60s per attempt) to find the desktop again.
139
152
  */
140
153
  transportRetryWaitMs?: number
154
+ /**
155
+ * Where the endpoints a peer ADVERTISES are handed to the assembly for
156
+ * persistence (issue #143). Called at most once per successful handshake,
157
+ * with the entries that passed the identity pin and the peer's destination
158
+ * schema; the connection has ALREADY merged them into `record` (so this
159
+ * instance's next reconnect dials them) — this hook is the write-back, and the
160
+ * assembly owns it because the store is not this layer's to touch.
161
+ *
162
+ * Absent (tests, or a future caller that only wants the in-memory merge) just
163
+ * means nothing is persisted. The callback must not throw; one that does is
164
+ * swallowed with the rest of the enrichment's failures.
165
+ */
166
+ onAdvertisedEndpoints?: (endpoints: readonly DirectEndpoint[]) => void
141
167
  }
142
168
 
143
169
  /**
@@ -269,6 +295,35 @@ const DEFAULT_BACKOFF_MAX_MS = 60_000
269
295
  const DEFAULT_REFRESH_GRACE_MS = 30_000
270
296
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
271
297
  const DEFAULT_PROTOCOL_VERSIONS: readonly number[] = [1]
298
+
299
+ /**
300
+ * How long the optional `connection.endpoints` enrichment (issue #143) may hold
301
+ * up ONE handshake before the connection gives up on it.
302
+ *
303
+ * The call runs INSIDE the handshake, so without a bound of its own it would
304
+ * inherit the rpc layer's 60 s default: a peer that advertises
305
+ * `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
306
+ * would keep the device in `connecting` for a minute, and every reconnect would
307
+ * pay it again. THIS layer bounds it because the enrichment is worth
308
+ * milliseconds, not a retry.
309
+ *
310
+ * WHY 2 s. The peer's handler is a local computation
311
+ * (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
312
+ * round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
313
+ * slack.
314
+ *
315
+ * The UPPER bound is NOT derived from a claim about the whole handshake: a dial
316
+ * already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
317
+ * ride the rpc layer's 60 s default, so `connected` can arrive long after a
318
+ * tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
319
+ * derived is narrower and true: THIS wait alone must stay below that window, so
320
+ * a peer that advertises the capability and then hangs on this one call cannot
321
+ * by itself push the state flip past the window a tool already waited out. A
322
+ * timeout costs one reconnect's worth of enrichment and says nothing about the
323
+ * device.
324
+ */
325
+ export const ENDPOINTS_LEARN_TIMEOUT_MS = 2_000
326
+
272
327
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
273
328
  const SILENCE_FACTOR = 2
274
329
  const RATE_WINDOW_MS = 1_000
@@ -378,7 +433,13 @@ class SecondWindowRateLimiter {
378
433
  // released strictly after the one before it reached the head.
379
434
  let release!: () => void
380
435
  const ahead = this.#queue
381
- this.#queue = ahead.then(() => new Promise<void>((resolve) => (release = resolve)))
436
+ // Both arms hand off the same way: a rejected link must still open a slot
437
+ // for the next waiter. Today no link can reject (each is settled only by
438
+ // its own `release()` in the `finally` below), so this is a guard rather
439
+ // than a fix — the desktop learned the shape the hard way when one failed
440
+ // write silenced every later send on the connection (2.1.9, PR #22593).
441
+ const next = (): Promise<void> => new Promise<void>((resolve) => (release = resolve))
442
+ this.#queue = ahead.then(next, next)
382
443
  await ahead
383
444
  try {
384
445
  for (;;) {
@@ -592,6 +653,8 @@ interface HelloResult {
592
653
  heartbeatMs?: number
593
654
  /** The paged-message-summaries capability (issue #105), when advertised. */
594
655
  agentMessageSummariesVersion?: number
656
+ /** The advertised-endpoints capability (issue #143), when advertised. */
657
+ connectionEndpointsVersion?: number
595
658
  }
596
659
 
597
660
  interface AuthResult {
@@ -638,6 +701,51 @@ function businessWindowFor(reportedInFlightRequests: number): number {
638
701
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)))
639
702
  }
640
703
 
704
+ /**
705
+ * The destinations a `connection.endpoints` answer may contribute, in the order
706
+ * the peer listed them (issue #143). Empty when the answer must be discarded.
707
+ *
708
+ * TWO answers are refused WHOLE:
709
+ *
710
+ * - **the identity pin.** The answer must name the very desktop this record is
711
+ * pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
712
+ * static key, so this is not the adversarial case it protects against — it
713
+ * is the defence against a CONFUSED answer (a proxy, a desktop whose identity
714
+ * was regenerated, a future protocol that answers for more than one host):
715
+ * the list decides where this client dials, so a list that cannot be
716
+ * attributed to our own desktop may not change it;
717
+ * - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
718
+ * number is not the pin either, so both fall out of the same comparison.
719
+ *
720
+ * One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
721
+ * destination schema, rejects a LOOPBACK destination (a shared address that
722
+ * resolves to our own machine would send the next reconnect to the wrong host)
723
+ * and normalizes the host (case, IPv6), so the archive never stores a spelling
724
+ * the dial cannot use. One unusable row must not discard its neighbours.
725
+ *
726
+ * NOT total on the wire shape, deliberately: a non-object answer or a non-array
727
+ * `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
728
+ * turns that into the silent no-op the contract promises. Defending each shape
729
+ * twice would look safer and be worse: the redundancy masks which guard a test
730
+ * actually pins (issue #114's lesson), and the whole ANSWER is untrusted
731
+ * anyway, so the boundary belongs at the caller, where the handshake can see
732
+ * it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
733
+ * "no addresses right now" answer (the desktop's `addresses` can be empty).
734
+ *
735
+ * No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
736
+ * the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
737
+ */
738
+ function advertisedEndpoints(result: unknown, desktopIdentity: string): DirectEndpoint[] {
739
+ const answer = result as { desktopIdentity?: unknown; endpoints?: readonly unknown[] | null }
740
+ if (answer.desktopIdentity !== desktopIdentity) return []
741
+ const accepted: DirectEndpoint[] = []
742
+ for (const raw of answer.endpoints ?? []) {
743
+ const parsed = advertisedEndpointSchema.safeParse(raw)
744
+ if (parsed.success) accepted.push(parsed.data)
745
+ }
746
+ return accepted
747
+ }
748
+
641
749
  /**
642
750
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
643
751
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
@@ -738,6 +846,7 @@ export class DeviceConnection {
738
846
  readonly #protocolVersions: readonly number[]
739
847
  readonly #helloProtocolVersions: readonly number[]
740
848
  readonly #transportRetryWaitMs: number
849
+ readonly #onAdvertisedEndpoints: ((endpoints: readonly DirectEndpoint[]) => void) | undefined
741
850
  readonly #stateListeners = new Set<(state: DeviceState) => void>()
742
851
  /**
743
852
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -806,6 +915,7 @@ export class DeviceConnection {
806
915
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS
807
916
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions
808
917
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? DEFAULT_TRANSPORT_RETRY_WAIT_MS
918
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints
809
919
  }
810
920
 
811
921
  /**
@@ -1303,11 +1413,13 @@ export class DeviceConnection {
1303
1413
  // reconnect to a peer that no longer advertises the capability (or a
1304
1414
  // downgrade) cannot inherit a stale `messageSummaries` from the previous
1305
1415
  // link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
1416
+ const unackedBytes = positive(hello.limits?.['unackedBytes'])
1306
1417
  this.#limits = {
1307
1418
  heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
1308
1419
  subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
1309
1420
  pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
1310
1421
  inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
1422
+ ...(unackedBytes === undefined ? {} : { unackedBytes }),
1311
1423
  ...(typeof hello.agentMessageSummariesVersion === 'number'
1312
1424
  ? { messageSummaries: hello.agentMessageSummariesVersion }
1313
1425
  : {}),
@@ -1329,6 +1441,14 @@ export class DeviceConnection {
1329
1441
  throw error instanceof Error ? error : new Error(String(error))
1330
1442
  }
1331
1443
 
1444
+ // The peer's own view of its addresses (issue #143), learned while the link
1445
+ // is AUTHENTICATED — the desktop gates `connection.endpoints` on the
1446
+ // capability of the authenticated session — and BEFORE the epoch gate
1447
+ // below, so a close() landing during the call is caught by that same gate
1448
+ // instead of publishing `connected` on a dead link. Bounded and
1449
+ // failure-tolerant: everything about it lives in `#learnEndpoints`.
1450
+ await this.#learnEndpoints(link, hello.connectionEndpointsVersion)
1451
+
1332
1452
  if (epoch !== this.#epoch) {
1333
1453
  // close() raced the handshake: drop the link quietly (close() already
1334
1454
  // tore down what it could see; this catches the just-dialed socket).
@@ -1381,6 +1501,71 @@ export class DeviceConnection {
1381
1501
  )
1382
1502
  }
1383
1503
 
1504
+ /**
1505
+ * Best-effort enrichment of the dial list from the peer's advertised
1506
+ * endpoints (issue #143).
1507
+ *
1508
+ * WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
1509
+ * address that appeared after the QR was minted. It does NOT heal a total
1510
+ * address change (the list is captured on the old path) and can never carry
1511
+ * TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
1512
+ * comes from the QR or the operator.
1513
+ *
1514
+ * EVERY failure here is SILENT and non-fatal, because this is an enrichment,
1515
+ * not a verdict: a peer that does not advertise the capability (`connection`
1516
+ * through 2.1.3 has neither the hello bit nor this method), one advertising a
1517
+ * version this client cannot speak (strictly `1` — a higher number is not
1518
+ * evidence that it speaks 1, the same rule the paged-summaries bit took), a
1519
+ * refused call (the desktop throws when its gateway is not running), a wedged
1520
+ * one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
1521
+ * answer, or a store that cannot write. None of them is a CONNECTION failure:
1522
+ * the link comes up either way, and only the archive is left without the new
1523
+ * addresses — this instance's own record may already carry them, because the
1524
+ * merge runs before the callback (see the paragraph below).
1525
+ *
1526
+ * The MERGE happens in two places on purpose: here (this instance's record,
1527
+ * so its next reconnect dials the new address) and through the callback (the
1528
+ * assembly's archive, so a restart keeps it). They are the same pure rule
1529
+ * against two copies of the record, and each is idempotent.
1530
+ *
1531
+ * ONE `catch` covers everything after the gate, and that is the contract, not
1532
+ * laziness: a refused call, a wedged one (bounded by
1533
+ * {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
1534
+ * parser throws rather than defending every field twice), a throwing assembly
1535
+ * hook (an unwritable archive), or a bug in the merge must all land here —
1536
+ * none of them may fail the handshake. The observable promise is therefore
1537
+ * structural: this method cannot reject.
1538
+ */
1539
+ async #learnEndpoints(link: LiveLink, capability: number | undefined): Promise<void> {
1540
+ if (capability !== 1) return
1541
+ try {
1542
+ const answer = await this.#houseCall<unknown>(
1543
+ link,
1544
+ 'connection.endpoints',
1545
+ { domain: 'agent' },
1546
+ ENDPOINTS_LEARN_TIMEOUT_MS,
1547
+ )
1548
+ const endpoints = advertisedEndpoints(answer, this.record.desktopIdentity)
1549
+ if (endpoints.length === 0) return
1550
+ const added = mergeAdvertisedEndpoints(this.record, endpoints)
1551
+ if (added > 0) {
1552
+ // Count only: a host is peer-supplied text, and the logging rule clips
1553
+ // peer text at the render site (src/AGENTS.md). deviceId is exempt.
1554
+ console.info(
1555
+ `[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`,
1556
+ )
1557
+ }
1558
+ this.#onAdvertisedEndpoints?.(endpoints)
1559
+ } catch {
1560
+ // Silent by contract. The failure modes are all "this desktop does not
1561
+ // want to tell us today" (or "this archive cannot take it"), and a log
1562
+ // line per reconnect would be noise on a device that is otherwise
1563
+ // perfectly healthy. One bounded line cannot be produced here anyway: the
1564
+ // cause may be the peer's own answer text, which src/AGENTS.md clips at
1565
+ // the render site — and this line would have to run on every connect.
1566
+ }
1567
+ }
1568
+
1384
1569
  /**
1385
1570
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
1386
1571
  * UNAUTHENTICATED) is retried exactly once without it before the pairing