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/README.en.md +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
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
|
|
25
|
-
|
|
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).
|
|
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`)
|
|
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
|
|
635
|
-
|
|
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
|
|
763
|
-
|
|
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.
|
|
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
|
|
823
|
-
change is the versioned pairing QR.
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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.
|
|
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
|
-
|
|
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
|