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