dsh-cherry-bridge 0.1.10 → 0.2.0

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/httpApi.ts CHANGED
@@ -9,6 +9,7 @@ import { CONTRACT_DIFFERENCES, buildDiagnostics, readScopeSegment } from './diag
9
9
  import type { DiagnosticsError, DiagnosticsStats, SubscriptionStat } from './diagnostics.js'
10
10
  import type { ScopeDoc, ToolScopeState } from './toolScope.js'
11
11
  import type { RefreshSummary } from './toolScopeHost.js'
12
+ import { failureText } from './text.js'
12
13
 
13
14
  /**
14
15
  * The plugin's same-origin HTTP face (spec §3.7): device management REST plus
@@ -114,6 +115,10 @@ export interface DeviceSummary {
114
115
  enabled: boolean
115
116
  hosts: string[]
116
117
  port: number
118
+ /** The scheme each host is dialed with (issue #126); see `DeviceRecord`. */
119
+ security: Record<string, 'ws' | 'wss'>
120
+ /** The port each host is dialed with (issue #135); see `DeviceRecord`. */
121
+ ports: Record<string, number>
117
122
  }
118
123
 
119
124
  /** The `device-state` SSE payload. */
@@ -432,6 +437,9 @@ function writeQuarantineRefusal(res: HttpResponseLike, route: RouteContext, erro
432
437
  try {
433
438
  route.deps.onArchiveQuarantined?.(error)
434
439
  } catch (reportError) {
440
+ // LOCAL observer catch (issue #161 audit): the diagnostics ring is ours, so
441
+ // no peer text can reach this error — the object print (with its stack) is
442
+ // deliberate.
435
443
  console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
436
444
  }
437
445
  writeJson(res, 503, {
@@ -538,9 +546,20 @@ interface DevicePatch {
538
546
  }
539
547
 
540
548
  /**
541
- * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract, and `port` is
542
- * shared by every host (spec §3.2) — there is no per-host port and no merge
543
- * semantics to get wrong.
549
+ * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
550
+ *
551
+ * The map semantics this face owes (the record's per-host maps are DERIVED,
552
+ * never patched directly — there is no `ports` field in this body):
553
+ *
554
+ * - neither `hosts` nor `port` patched → the maps are unchanged;
555
+ * - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
556
+ * host gets `ws` and the record-level port;
557
+ * - a CHANGED `port` (with or without `hosts`) → EVERY final host is
558
+ * re-pointed to it. The face has one port field, so leaving per-host
559
+ * entries would make the edit inert for every listed host that already had
560
+ * one. Sending the CURRENT value is a no-op for the maps — the settings page
561
+ * sends both fields on every save, and a mixed-port record must not be
562
+ * flattened by a save that did not change the port.
544
563
  */
545
564
  function parsePatch(body: Record<string, unknown>): DevicePatch {
546
565
  const patch: DevicePatch = {}
@@ -573,6 +592,12 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
573
592
  if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
574
593
  return normalizeHost(entry)
575
594
  })
595
+ // Checked AFTER normalization: `[::1]` and `::1` collapse to one host. The
596
+ // scheme/port maps are keyed by host, so a duplicate has no single entry
597
+ // and silently last-wins — an ambiguous record, not an edit.
598
+ if (new Set(patch.hosts).size !== patch.hosts.length) {
599
+ throw new PatchError('hosts must not contain duplicates')
600
+ }
576
601
  }
577
602
 
578
603
  const port = body['port']
@@ -1053,6 +1078,8 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
1053
1078
  enabled: device.enabled,
1054
1079
  hosts: [...device.hosts],
1055
1080
  port: device.port,
1081
+ security: { ...device.security },
1082
+ ports: { ...device.ports },
1056
1083
  }))
1057
1084
  writeJson(res, 200, devices)
1058
1085
  }
@@ -1255,11 +1282,13 @@ function deviceById(deps: CherryHttpDeps, deviceId: string): DeviceRecord {
1255
1282
  const found = deps.store.list().find((device) => device.deviceId === deviceId)
1256
1283
  if (found !== undefined) return found
1257
1284
  return {
1258
- schemaVersion: 2,
1285
+ schemaVersion: 4,
1259
1286
  deviceId,
1260
1287
  displayName: '',
1261
1288
  hosts: [],
1262
1289
  port: 0,
1290
+ security: {},
1291
+ ports: {},
1263
1292
  desktopIdentity: '',
1264
1293
  grants: [],
1265
1294
  enabled: false,
@@ -1360,13 +1389,36 @@ async function patchDevice(
1360
1389
  const previousEnabled = existing.enabled
1361
1390
  const previousHosts = existing.hosts
1362
1391
  const previousPort = existing.port
1392
+ const previousSecurity = existing.security
1393
+ const previousPorts = existing.ports
1394
+ // The per-host port map is DERIVED from the patch, never patched directly
1395
+ // (there is no `ports` field in the body):
1396
+ // - no address field → unchanged;
1397
+ // - `hosts` replaced → a staying host keeps its own port, a new host gets the
1398
+ // record-level `port`;
1399
+ // - a CHANGED `port` (with or without `hosts`) → EVERY final host is
1400
+ // re-pointed; one port field on this face, so leaving per-host entries
1401
+ // would make the edit inert. Sending the CURRENT value is a no-op for the
1402
+ // map (see `parsePatch`), because the settings page sends both fields on
1403
+ // every save.
1404
+ const nextPorts =
1405
+ patch.port !== undefined && patch.port !== previousPort
1406
+ ? portsFor(patch.hosts ?? previousHosts, {}, patch.port)
1407
+ : patch.hosts === undefined
1408
+ ? previousPorts
1409
+ : portsFor(patch.hosts, previousPorts, previousPort)
1363
1410
  const next: DeviceRecord = {
1364
1411
  ...existing,
1365
1412
  ...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
1366
1413
  ...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
1367
- // hosts is WHOLE-REPLACE: the patch wins outright, never merges.
1368
- ...(patch.hosts === undefined ? {} : { hosts: patch.hosts }),
1414
+ // hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
1415
+ // scheme map is keyed by host, so replacing the host list drops the
1416
+ // entries for hosts that are gone and defaults the new ones to `ws` —
1417
+ // keeping the OLD schemes indexed by the NEW list would be a stale
1418
+ // pairing of host to scheme (issue #126).
1419
+ ...(patch.hosts === undefined ? {} : { hosts: patch.hosts, security: securityFor(patch.hosts, previousSecurity) }),
1369
1420
  ...(patch.port === undefined ? {} : { port: patch.port }),
1421
+ ports: nextPorts,
1370
1422
  }
1371
1423
 
1372
1424
  try {
@@ -1389,7 +1441,8 @@ async function patchDevice(
1389
1441
  // `connect()` to rebuild from the record we just wrote.
1390
1442
  const addressChanged =
1391
1443
  (patch.hosts !== undefined && !sameHosts(patch.hosts, previousHosts)) ||
1392
- (patch.port !== undefined && patch.port !== previousPort)
1444
+ (patch.port !== undefined && patch.port !== previousPort) ||
1445
+ !sameSecurity(next.security, previousSecurity)
1393
1446
  const enabledChanged = patch.enabled !== undefined && patch.enabled !== previousEnabled
1394
1447
 
1395
1448
  if (addressChanged) {
@@ -1409,6 +1462,11 @@ async function patchDevice(
1409
1462
  try {
1410
1463
  track(route.deps.connect(deviceId).connect(), warnConnect(deviceId))
1411
1464
  } catch (error) {
1465
+ // LOCAL: only the synchronous construction failure lands here
1466
+ // (`connectionFor` cannot find the record, or the identity failed to
1467
+ // load). The DIAL's rejection carries peer text and is rendered by
1468
+ // `warnConnect` below; this arm keeps the object print and its stack
1469
+ // (issue #161's boundary).
1412
1470
  console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error)
1413
1471
  }
1414
1472
  }
@@ -1428,12 +1486,23 @@ async function patchDevice(
1428
1486
  enabled: next.enabled,
1429
1487
  hosts: [...next.hosts],
1430
1488
  port: next.port,
1489
+ security: { ...next.security },
1490
+ ports: { ...next.ports },
1431
1491
  })
1432
1492
  }
1433
1493
 
1434
1494
  function warnConnect(deviceId: string): (error: unknown) => void {
1435
1495
  return (error: unknown) => {
1436
- console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed`, error)
1496
+ // ONE bounded STRING (issue #161). This is the arm that receives the
1497
+ // lifecycle rejection. The common case is `AllHostsUnreachableError`, whose
1498
+ // message embeds the last per-host error as `last: <message>` — a `ws` /
1499
+ // channel failure, or an RPC refusal's peer message (capped at 512 by the
1500
+ // failure schema). The terminal arms add only local wrappers (`identity
1501
+ // mismatch`, `protocol version incompatible`, `authenticate rejected
1502
+ // (<reason>)` — an enum). The old form handed the error OBJECT to the
1503
+ // console. Ceiling: `[cherry-remote] device ` (23) + deviceId +
1504
+ // ` lifecycle change failed: ` (26) + LOG_TEXT_MAX (240).
1505
+ console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`)
1437
1506
  }
1438
1507
  }
1439
1508
 
@@ -1442,6 +1511,56 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
1442
1511
  return a.length === b.length && a.every((host, index) => host === b[index])
1443
1512
  }
1444
1513
 
1514
+ /**
1515
+ * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
1516
+ * keeps the scheme it was dialed with, a host the list just gained defaults to
1517
+ * `ws`, and hosts the list dropped leave no entry behind. An edit is an
1518
+ * address edit, not a scheme edit — re-typing the same host must not silently
1519
+ * demote a TLS-proxied desktop to a plain dial.
1520
+ */
1521
+ function securityFor(
1522
+ hosts: readonly string[],
1523
+ previous: Record<string, 'ws' | 'wss'>,
1524
+ ): Record<string, 'ws' | 'wss'> {
1525
+ // `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
1526
+ // (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
1527
+ // Object.prototype and poison the map the store then rejects.
1528
+ return Object.fromEntries(
1529
+ hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : 'ws']),
1530
+ )
1531
+ }
1532
+
1533
+ /**
1534
+ * The port map for a host list (issue #135): each named host keeps the port it
1535
+ * was dialed with, a host the list just gained defaults to `fallback` (the
1536
+ * record-level port), and hosts the list dropped leave no entry behind.
1537
+ *
1538
+ * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
1539
+ * `port` patch): there is no remembered entry to keep, by design — one port
1540
+ * field on this face must move the whole list. Same `Object.hasOwn` rule as
1541
+ * `securityFor`: an inherited function is not a remembered port.
1542
+ */
1543
+ function portsFor(
1544
+ hosts: readonly string[],
1545
+ previous: Record<string, number>,
1546
+ fallback: number,
1547
+ ): Record<string, number> {
1548
+ return Object.fromEntries(
1549
+ hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]),
1550
+ )
1551
+ }
1552
+
1553
+ /** Order-insensitive comparison for two scheme maps. */
1554
+ function sameSecurity(
1555
+ a: Record<string, 'ws' | 'wss'>,
1556
+ b: Record<string, 'ws' | 'wss'>,
1557
+ ): boolean {
1558
+ const keys = Object.keys(a)
1559
+ if (keys.length !== Object.keys(b).length) return false
1560
+ return keys.every((key) => a[key] === b[key])
1561
+ }
1562
+
1563
+
1445
1564
  /** Drops the pooled connection, closing any live socket first. */
1446
1565
  function evict(route: RouteContext, deviceId: string): void {
1447
1566
  try {
@@ -1555,6 +1674,8 @@ async function startPairing(req: HttpRequestLike, res: HttpResponseLike, route:
1555
1674
  try {
1556
1675
  route.deps.onArchiveQuarantined?.(error)
1557
1676
  } catch (reportError) {
1677
+ // LOCAL observer catch (issue #161 audit): same ring as the PATCH
1678
+ // route's, no peer text — the object print is deliberate.
1558
1679
  console.warn('[cherry-remote] reporting the quarantined archive failed', reportError)
1559
1680
  }
1560
1681
  // The shared instruction VERBATIM, with only the two contextual paths
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 two config values that reach constructor arguments, with their defaults
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 to the plugin config's numeric keys. */
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,9 @@ 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 }),
366
492
  })
367
493
  state.connectionsBuilt += 1
368
494
  connection.onState((deviceState: DeviceState) => {
@@ -393,7 +519,18 @@ function connectionFor(deviceId: string): DeviceConnection {
393
519
  try {
394
520
  subsFor(deviceId).handleNotification(method, params)
395
521
  } catch (error) {
396
- console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed`, error)
522
+ // ONE bounded STRING (issue #161): the old form passed the error OBJECT,
523
+ // so the console printed the error's name/message/stack with nothing
524
+ // bounding it. The layer's handler throws for no schema-valid
525
+ // notification today, but THIS try also calls `subsFor(deviceId)` →
526
+ // `connectionFor()`, which does throw today (`no device …` when the
527
+ // record was removed mid-race) — and a future handler throw must not hand
528
+ // the console an object either. Ceiling: `[cherry-remote] notification "`
529
+ // (30) + the notification schema's `method` cap (128) + `" for ` (6) +
530
+ // deviceId + ` failed: ` (9) + LOG_TEXT_MAX (240).
531
+ console.warn(
532
+ `[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`,
533
+ )
397
534
  }
398
535
  })
399
536
  state.connections.set(deviceId, connection)
@@ -526,6 +663,31 @@ function subsFor(deviceId: string): SessionSubscriptions {
526
663
  return created
527
664
  }
528
665
 
666
+ /**
667
+ * Bounds the link-recovery line's session-id list (issue #155).
668
+ *
669
+ * The number of ENTRIES is bounded only by the peer-advertised hello
670
+ * `subscriptions` — a number the peer chooses, not one this assembly can size —
671
+ * and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
672
+ * `sessions.join(', ')` therefore emitted `entries × 258` characters into one
673
+ * info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
674
+ * ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
675
+ * text is peer-controlled (the ids are echoed back from what the peer accepted
676
+ * as subscriptions), so it needs a LOCAL bound like every other such log.
677
+ *
678
+ * `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
679
+ * 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
680
+ * own subscription cap fits WHOLE — the bound bites only on a peer that
681
+ * advertises more room, or on a malformed over-long id.
682
+ * `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
683
+ * is never clipped; only a malformed one is. A list that stops early names the
684
+ * count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
685
+ * silently. The bound is on the LOG only — the loop still re-ensures every
686
+ * session.
687
+ */
688
+ const RECOVERY_SESSIONS_MAX = 320
689
+ const RECOVERY_SESSION_ID_MAX = 256
690
+
529
691
  /**
530
692
  * The reconnect recovery line (ruling 8): every session this device had
531
693
  * subscribed is re-ensured at its LAST cursor, so the peer replays only what
@@ -546,10 +708,16 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
546
708
  const sessions = subscriptions.activeSessions()
547
709
  // INFO (spec §5 「订阅建立与恢复」): the recovery line announces what it is
548
710
  // 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.
711
+ // Ids only — a session id is whitelisted, an event payload is not. The id
712
+ // list is bounded by {@link RECOVERY_SESSIONS_MAX} (issue #155); the
713
+ // `subscription(s)` count above it is the TRUE total, so a clipped list still
714
+ // says how many sessions are being recovered.
550
715
  if (sessions.length > 0) {
551
716
  console.info(
552
- `[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${sessions.join(', ')}`,
717
+ `[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(
718
+ sessions,
719
+ { max: RECOVERY_SESSIONS_MAX, itemMax: RECOVERY_SESSION_ID_MAX },
720
+ )}`,
553
721
  )
554
722
  }
555
723
 
@@ -566,7 +734,15 @@ async function recoverSubscriptions(deviceId: string): Promise<void> {
566
734
  (cursor === undefined ? '' : ` at seq ${cursor.seq}`),
567
735
  )
568
736
  } catch (error) {
569
- console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed`, error)
737
+ // ONE bounded STRING (issue #161). The catch is peer-triggered:
738
+ // `ensure` subscribes over the live link, and its failure message is the
739
+ // peer's JSON-RPC `error.message` verbatim (up to the 512-char failure
740
+ // schema cap) or a transport message. The old form handed the error
741
+ // OBJECT to the console, which then printed the stack plus every own
742
+ // property unbounded.
743
+ console.warn(
744
+ `[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
745
+ )
570
746
  }
571
747
  }
572
748
  }
@@ -603,6 +779,11 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
603
779
  payload: request.payload,
604
780
  store: state.store,
605
781
  ...(request.name === undefined ? {} : { name: request.name }),
782
+ // The same operator TLS trust the steady-state dial uses (issue #133):
783
+ // a desktop behind a private CA must be pairABLE over `wss` as well as
784
+ // reconnectable. Read from the assembly state, never from `request`: a
785
+ // caller cannot supply trust.
786
+ ...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
606
787
  onVerificationCode: request.onVerificationCode,
607
788
  // Pair bridging (ruling 10): the call site's stages reach the assembly's
608
789
  // progress fan-out, which is what the SSE route consumes.
@@ -715,6 +896,7 @@ export function apply(ctx: CherryBridgeContext, config: CherryBridgeConfig = {})
715
896
  const bridgeConfig = resolveBridgeConfig(config)
716
897
  state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond
717
898
  state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes
899
+ state.wsTls = bridgeConfig.wsTls
718
900
  state.store = new DeviceStore(join(dataDir, 'devices.json'))
719
901
 
720
902
  // A re-apply is a fresh assembly: the previous run's error summaries and