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/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { DeviceStore } from './deviceStore.js'
|
|
4
4
|
import { loadOrCreateIdentity } from './identity.js'
|
|
5
5
|
import { DeviceConnection } from './connection.js'
|
|
6
|
-
import type { DeviceState } from './connection.js'
|
|
6
|
+
import type { DeviceState, WsDialOptions } from './connection.js'
|
|
7
7
|
import { DEFAULT_PER_SESSION_CACHE_BYTES, DEFAULT_RATE_BUDGET_PER_SECOND } from './defaults.js'
|
|
8
8
|
import { pairWithPayload } from './pairing.js'
|
|
9
9
|
import { SessionSubscriptions } from './subscriptions.js'
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
} from './httpApi.js'
|
|
22
22
|
import type { DiagnosticsError } from './diagnostics.js'
|
|
23
23
|
import { truncateErrorMessage } from './diagnostics.js'
|
|
24
|
+
import { boundedJoin, failureText } from './text.js'
|
|
24
25
|
|
|
25
26
|
export const name = 'cherry-remote'
|
|
26
27
|
|
|
@@ -97,11 +98,25 @@ export interface CherryBridgeConfig {
|
|
|
97
98
|
* (not a protocol value). Omitted uses {@link DEFAULT_PER_SESSION_CACHE_BYTES}.
|
|
98
99
|
*/
|
|
99
100
|
perSessionCacheBytes?: number
|
|
101
|
+
/**
|
|
102
|
+
* FLAT `tls.connect` options for every `wss` dial (issue #133): the operator's
|
|
103
|
+
* TLS trust for a desktop behind a private CA or a self-signed TLS front.
|
|
104
|
+
* `ws` forwards this bag straight to Node's TLS layer, so `{ ca: <pem> }` and
|
|
105
|
+
* `{ rejectUnauthorized: false }` belong at the TOP level — the `{ tls: {…} }`
|
|
106
|
+
* shape (`https`' wrapper) is not recognised by `ws` and is refused here.
|
|
107
|
+
* Applied to BOTH dial sites: the steady-state pool and pairing.
|
|
108
|
+
*
|
|
109
|
+
* NEVER persisted in the record, never logged, never reported: a `ca` is PEM
|
|
110
|
+
* CONTENT, and trust is a grant the operator makes, not a fact the desktop
|
|
111
|
+
* asserts about itself.
|
|
112
|
+
*/
|
|
113
|
+
wsTls?: WsDialOptions
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/**
|
|
103
|
-
* The
|
|
104
|
-
* applied
|
|
117
|
+
* The plugin-config values that reach constructor arguments, resolved once: the
|
|
118
|
+
* two numeric budgets with their defaults applied, and the validated TLS trust
|
|
119
|
+
* bag (see {@link dialOptions}).
|
|
105
120
|
*
|
|
106
121
|
* Resolved ONCE per `apply` and threaded explicitly, rather than re-read at each
|
|
107
122
|
* construction site: a config surface that can change between two constructions
|
|
@@ -110,6 +125,7 @@ export interface CherryBridgeConfig {
|
|
|
110
125
|
export interface ResolvedBridgeConfig {
|
|
111
126
|
rateBudgetPerSecond: number
|
|
112
127
|
perSessionCacheBytes: number
|
|
128
|
+
wsTls: WsDialOptions | undefined
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
/**
|
|
@@ -130,11 +146,12 @@ export {
|
|
|
130
146
|
DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
131
147
|
} from './defaults.js'
|
|
132
148
|
|
|
133
|
-
/** Applies the documented fallbacks
|
|
149
|
+
/** Applies the documented fallbacks and validates the plugin config's keys. */
|
|
134
150
|
export function resolveBridgeConfig(config: CherryBridgeConfig = {}): ResolvedBridgeConfig {
|
|
135
151
|
return {
|
|
136
152
|
rateBudgetPerSecond: positive('rateBudgetPerSecond', config.rateBudgetPerSecond, DEFAULT_RATE_BUDGET_PER_SECOND),
|
|
137
153
|
perSessionCacheBytes: positive('perSessionCacheBytes', config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
|
|
154
|
+
wsTls: dialOptions(config.wsTls),
|
|
138
155
|
}
|
|
139
156
|
}
|
|
140
157
|
|
|
@@ -164,6 +181,104 @@ function positive(key: string, value: unknown, fallback: number): number {
|
|
|
164
181
|
return value
|
|
165
182
|
}
|
|
166
183
|
|
|
184
|
+
/** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
|
|
185
|
+
const WS_TLS_KEYS: ReadonlySet<string> = new Set(['rejectUnauthorized', 'ca'])
|
|
186
|
+
|
|
187
|
+
/** The shape a non-object config value arrived as, for a value-free warning. */
|
|
188
|
+
function shapeOf(value: unknown): string {
|
|
189
|
+
if (value === null) return 'null'
|
|
190
|
+
if (Array.isArray(value)) return 'array'
|
|
191
|
+
return typeof value
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
|
|
196
|
+
*
|
|
197
|
+
* A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
|
|
198
|
+
* would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
|
|
199
|
+
* would load bytes the operator never inspected. The header check is a SHAPE
|
|
200
|
+
* check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
|
|
201
|
+
* and an unparseable body fails the handshake loudly, which is the honest
|
|
202
|
+
* verdict. Buffers keep the non-empty test with no header requirement: DER and
|
|
203
|
+
* programmatically built PEM bytes are legitimate `ca` values.
|
|
204
|
+
*/
|
|
205
|
+
function certificateEntry(value: unknown): boolean {
|
|
206
|
+
if (typeof value === 'string') return value.trimStart().startsWith('-----BEGIN')
|
|
207
|
+
if (Buffer.isBuffer(value)) return value.length > 0
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A configured TLS trust bag, or `undefined` when absent/unusable.
|
|
213
|
+
*
|
|
214
|
+
* ABSENT is the documented default (Node's trust store) and says nothing —
|
|
215
|
+
* exactly like {@link positive}. A key that is PRESENT but unusable is a
|
|
216
|
+
* deployment mistake that silently changes which peers are reachable, so it is
|
|
217
|
+
* warned about by name rather than swallowed — but NEVER by value: a `ca` is
|
|
218
|
+
* PEM CONTENT, and a certificate must not reach a log.
|
|
219
|
+
*
|
|
220
|
+
* FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
|
|
221
|
+
* bag rather than forwarding the parts that happen to look right. Trust must be
|
|
222
|
+
* applied AS WRITTEN: a partially applied bag would reach some peers and not
|
|
223
|
+
* others with nothing telling the operator which half took effect, and their
|
|
224
|
+
* `ca` intention would be silently replaced by Node's default store. The
|
|
225
|
+
* fallback is "no override" — the same state as absent.
|
|
226
|
+
*/
|
|
227
|
+
function dialOptions(value: unknown): WsDialOptions | undefined {
|
|
228
|
+
if (value === undefined) return undefined
|
|
229
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options ` +
|
|
232
|
+
`(got ${shapeOf(value)}); ignoring it`,
|
|
233
|
+
)
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
const record = value as Record<string, unknown>
|
|
237
|
+
const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key))
|
|
238
|
+
if (unknownKeys.length > 0) {
|
|
239
|
+
// The `{ tls: {…} }` wrapper is the common mistake (`https`' shape, which
|
|
240
|
+
// `ws` ignores), so naming the offending keys points straight at it.
|
|
241
|
+
console.warn(
|
|
242
|
+
`[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(', ')}; ` +
|
|
243
|
+
'only flat rejectUnauthorized / ca are accepted — ignoring the whole object',
|
|
244
|
+
)
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rejectUnauthorized = record['rejectUnauthorized']
|
|
249
|
+
if (rejectUnauthorized !== undefined && typeof rejectUnauthorized !== 'boolean') {
|
|
250
|
+
// FIELD only — a config value never enters the message.
|
|
251
|
+
console.warn(
|
|
252
|
+
'[cherry-remote] config "wsTls.rejectUnauthorized" must be a boolean; ignoring the whole object',
|
|
253
|
+
)
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ca = record['ca']
|
|
258
|
+
if (ca !== undefined) {
|
|
259
|
+
const usable = Array.isArray(ca)
|
|
260
|
+
? ca.length > 0 && ca.every((entry) => certificateEntry(entry))
|
|
261
|
+
: certificateEntry(ca)
|
|
262
|
+
if (!usable) {
|
|
263
|
+
// FIELD only — never the value: a `ca` is certificate text.
|
|
264
|
+
console.warn(
|
|
265
|
+
'[cherry-remote] config "wsTls.ca" must be PEM CONTENT (the certificate text itself, ' +
|
|
266
|
+
'not a file path) — a "-----BEGIN" string, a non-empty Buffer, or a non-empty array ' +
|
|
267
|
+
'of those; ignoring the whole object',
|
|
268
|
+
)
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// `{}` (or an explicit `undefined` value) is the documented default written
|
|
274
|
+
// out: no override, and no warning.
|
|
275
|
+
if (rejectUnauthorized === undefined && ca === undefined) return undefined
|
|
276
|
+
return {
|
|
277
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
278
|
+
...(ca === undefined ? {} : { ca: ca as WsDialOptions['ca'] }),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
167
282
|
/** The context slice `apply` touches beyond the HTTP face's own services. */
|
|
168
283
|
export interface CherryBridgeContext extends CherryHttpContext, CherryToolsContext {
|
|
169
284
|
effect?(callback: () => () => void): unknown
|
|
@@ -213,6 +328,13 @@ interface AssemblyState {
|
|
|
213
328
|
*/
|
|
214
329
|
rateBudgetPerSecond: number
|
|
215
330
|
perSessionCacheBytes: number
|
|
331
|
+
/**
|
|
332
|
+
* The current apply's TLS trust bag (issue #133), handed to BOTH dial sites —
|
|
333
|
+
* the pooled connection and pairing's temporary channel. Held on the state for
|
|
334
|
+
* the same reason as the two numeric values: a re-apply must move both faces
|
|
335
|
+
* onto the new config. `undefined` = Node's default trust store.
|
|
336
|
+
*/
|
|
337
|
+
wsTls: WsDialOptions | undefined
|
|
216
338
|
readonly connections: Map<string, DeviceConnection>
|
|
217
339
|
/**
|
|
218
340
|
* One subscription layer per device, sharing nothing with its siblings: the
|
|
@@ -275,6 +397,7 @@ const state: AssemblyState = {
|
|
|
275
397
|
identityError: undefined,
|
|
276
398
|
rateBudgetPerSecond: DEFAULT_RATE_BUDGET_PER_SECOND,
|
|
277
399
|
perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
|
|
400
|
+
wsTls: undefined,
|
|
278
401
|
connections: new Map(),
|
|
279
402
|
subscriptions: new Map(),
|
|
280
403
|
deviceStateListeners: new Set(),
|
|
@@ -363,6 +486,22 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
363
486
|
identity: requireIdentity(),
|
|
364
487
|
record,
|
|
365
488
|
rateBudgetPerSecond: state.rateBudgetPerSecond,
|
|
489
|
+
// Spread-if-present, never `wsOptions: undefined`: the option is optional,
|
|
490
|
+
// and an absent key is the honest "no override" (Node's trust store).
|
|
491
|
+
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
492
|
+
// The archive write-back (issue #143). The connection has already merged the
|
|
493
|
+
// addresses into its OWN record — this instance's next reconnect dials them
|
|
494
|
+
// — so the only job here is durability. It goes through
|
|
495
|
+
// `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
|
|
496
|
+
// carries the token of the PAIRING while the connection's mirrors the live
|
|
497
|
+
// one, and an `upsert` of the connection's copy would put a
|
|
498
|
+
// connection-scoped credential into the archive.
|
|
499
|
+
onAdvertisedEndpoints: (endpoints) => {
|
|
500
|
+
// `record` is THIS connection's clone: its `desktopIdentity` is the pin the
|
|
501
|
+
// answer was validated against, so a re-pair under the same deviceId cannot
|
|
502
|
+
// receive an address learned from the previous pairing.
|
|
503
|
+
state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
|
|
504
|
+
},
|
|
366
505
|
})
|
|
367
506
|
state.connectionsBuilt += 1
|
|
368
507
|
connection.onState((deviceState: DeviceState) => {
|
|
@@ -393,7 +532,18 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
393
532
|
try {
|
|
394
533
|
subsFor(deviceId).handleNotification(method, params)
|
|
395
534
|
} catch (error) {
|
|
396
|
-
|
|
535
|
+
// ONE bounded STRING (issue #161): the old form passed the error OBJECT,
|
|
536
|
+
// so the console printed the error's name/message/stack with nothing
|
|
537
|
+
// bounding it. The layer's handler throws for no schema-valid
|
|
538
|
+
// notification today, but THIS try also calls `subsFor(deviceId)` →
|
|
539
|
+
// `connectionFor()`, which does throw today (`no device …` when the
|
|
540
|
+
// record was removed mid-race) — and a future handler throw must not hand
|
|
541
|
+
// the console an object either. Ceiling: `[cherry-remote] notification "`
|
|
542
|
+
// (30) + the notification schema's `method` cap (128) + `" for ` (6) +
|
|
543
|
+
// deviceId + ` failed: ` (9) + LOG_TEXT_MAX (240).
|
|
544
|
+
console.warn(
|
|
545
|
+
`[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`,
|
|
546
|
+
)
|
|
397
547
|
}
|
|
398
548
|
})
|
|
399
549
|
state.connections.set(deviceId, connection)
|
|
@@ -526,6 +676,31 @@ function subsFor(deviceId: string): SessionSubscriptions {
|
|
|
526
676
|
return created
|
|
527
677
|
}
|
|
528
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Bounds the link-recovery line's session-id list (issue #155).
|
|
681
|
+
*
|
|
682
|
+
* The number of ENTRIES is bounded only by the peer-advertised hello
|
|
683
|
+
* `subscriptions` — a number the peer chooses, not one this assembly can size —
|
|
684
|
+
* and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
|
|
685
|
+
* `sessions.join(', ')` therefore emitted `entries × 258` characters into one
|
|
686
|
+
* info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
|
|
687
|
+
* ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
|
|
688
|
+
* text is peer-controlled (the ids are echoed back from what the peer accepted
|
|
689
|
+
* as subscriptions), so it needs a LOCAL bound like every other such log.
|
|
690
|
+
*
|
|
691
|
+
* `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
|
|
692
|
+
* 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
|
|
693
|
+
* own subscription cap fits WHOLE — the bound bites only on a peer that
|
|
694
|
+
* advertises more room, or on a malformed over-long id.
|
|
695
|
+
* `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
|
|
696
|
+
* is never clipped; only a malformed one is. A list that stops early names the
|
|
697
|
+
* count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
|
|
698
|
+
* silently. The bound is on the LOG only — the loop still re-ensures every
|
|
699
|
+
* session.
|
|
700
|
+
*/
|
|
701
|
+
const RECOVERY_SESSIONS_MAX = 320
|
|
702
|
+
const RECOVERY_SESSION_ID_MAX = 256
|
|
703
|
+
|
|
529
704
|
/**
|
|
530
705
|
* The reconnect recovery line (ruling 8): every session this device had
|
|
531
706
|
* subscribed is re-ensured at its LAST cursor, so the peer replays only what
|
|
@@ -546,10 +721,16 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
|
|
|
546
721
|
const sessions = subscriptions.activeSessions()
|
|
547
722
|
// INFO (spec §5 「订阅建立与恢复」): the recovery line announces what it is
|
|
548
723
|
// about to re-ensure, once per link recovery rather than once per session.
|
|
549
|
-
// Ids only — a session id is whitelisted, an event payload is not.
|
|
724
|
+
// Ids only — a session id is whitelisted, an event payload is not. The id
|
|
725
|
+
// list is bounded by {@link RECOVERY_SESSIONS_MAX} (issue #155); the
|
|
726
|
+
// `subscription(s)` count above it is the TRUE total, so a clipped list still
|
|
727
|
+
// says how many sessions are being recovered.
|
|
550
728
|
if (sessions.length > 0) {
|
|
551
729
|
console.info(
|
|
552
|
-
`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${
|
|
730
|
+
`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(
|
|
731
|
+
sessions,
|
|
732
|
+
{ max: RECOVERY_SESSIONS_MAX, itemMax: RECOVERY_SESSION_ID_MAX },
|
|
733
|
+
)}`,
|
|
553
734
|
)
|
|
554
735
|
}
|
|
555
736
|
|
|
@@ -566,7 +747,15 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
|
|
|
566
747
|
(cursor === undefined ? '' : ` at seq ${cursor.seq}`),
|
|
567
748
|
)
|
|
568
749
|
} catch (error) {
|
|
569
|
-
|
|
750
|
+
// ONE bounded STRING (issue #161). The catch is peer-triggered:
|
|
751
|
+
// `ensure` subscribes over the live link, and its failure message is the
|
|
752
|
+
// peer's JSON-RPC `error.message` verbatim (up to the 512-char failure
|
|
753
|
+
// schema cap) or a transport message. The old form handed the error
|
|
754
|
+
// OBJECT to the console, which then printed the stack plus every own
|
|
755
|
+
// property unbounded.
|
|
756
|
+
console.warn(
|
|
757
|
+
`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
|
|
758
|
+
)
|
|
570
759
|
}
|
|
571
760
|
}
|
|
572
761
|
}
|
|
@@ -603,6 +792,11 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
|
|
|
603
792
|
payload: request.payload,
|
|
604
793
|
store: state.store,
|
|
605
794
|
...(request.name === undefined ? {} : { name: request.name }),
|
|
795
|
+
// The same operator TLS trust the steady-state dial uses (issue #133):
|
|
796
|
+
// a desktop behind a private CA must be pairABLE over `wss` as well as
|
|
797
|
+
// reconnectable. Read from the assembly state, never from `request`: a
|
|
798
|
+
// caller cannot supply trust.
|
|
799
|
+
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
606
800
|
onVerificationCode: request.onVerificationCode,
|
|
607
801
|
// Pair bridging (ruling 10): the call site's stages reach the assembly's
|
|
608
802
|
// progress fan-out, which is what the SSE route consumes.
|
|
@@ -715,6 +909,7 @@ export function apply(ctx: CherryBridgeContext, config: CherryBridgeConfig = {})
|
|
|
715
909
|
const bridgeConfig = resolveBridgeConfig(config)
|
|
716
910
|
state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond
|
|
717
911
|
state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes
|
|
912
|
+
state.wsTls = bridgeConfig.wsTls
|
|
718
913
|
state.store = new DeviceStore(join(dataDir, 'devices.json'))
|
|
719
914
|
|
|
720
915
|
// A re-apply is a fresh assembly: the previous run's error summaries and
|