dsh-cherry-bridge 0.1.11 → 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/connection.ts CHANGED
@@ -2,7 +2,7 @@ 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 { remoteConnectPath } from '@cherrystudio/remote-protocol'
5
+ import { directEndpointUrl } from '@cherrystudio/remote-protocol'
6
6
  import { isChannelClosedError, JsonRpcClient, JsonRpcRequestError } from './rpc/jsonRpcClient.js'
7
7
  import type { JsonRpcRequestOptions } from './rpc/jsonRpcClient.js'
8
8
  import { toRemoteSocket } from './rpc/wsAdapter.js'
@@ -13,8 +13,8 @@ import {
13
13
  DEFAULT_TRANSPORT_RETRY_WAIT_MS,
14
14
  RATE_LIMIT_MAX_WAIT_MS,
15
15
  } from './defaults.js'
16
- import { urlHost } from './urlHost.js'
17
16
  import type { DeviceRecord } from './deviceStore.js'
17
+ import { clipText, LOG_TEXT_MAX } from './text.js'
18
18
 
19
19
  /**
20
20
  * Per-device persistent connection: the M1 capstone that owns the WebSocket,
@@ -36,13 +36,59 @@ export interface EffectiveLimits {
36
36
  subscriptions: number
37
37
  pageItems: number
38
38
  inFlightRequests: number
39
+ /**
40
+ * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
41
+ * stored BESIDE the limits because it is re-read by that same handshake.
42
+ *
43
+ * ABSENT, never defaulted: through desktop 2.1.7 the field does not exist,
44
+ * and 0.5.0's `agent.messages.list` params are a STRICT object with no
45
+ * `partsMode` — sending it to such a peer is a protocol violation, so absence
46
+ * has to be distinguishable from every number. `1` (2.1.8, where
47
+ * `RemoteConnection.ts:69` sets it unconditionally) is the only version that
48
+ * means "the summary arm is available"; any other number is stored verbatim
49
+ * and read as not-yet-known, because a version this client cannot speak is
50
+ * not evidence that it speaks 1.
51
+ *
52
+ * Re-read on EVERY hello, so a desktop upgraded between two handshakes is
53
+ * picked up on the reconnect that follows.
54
+ */
55
+ messageSummaries?: number
39
56
  }
40
57
 
58
+ /**
59
+ * Options handed to every `WebSocket` dial (issue #126).
60
+ *
61
+ * FLAT `tls.connect` options — `ws` forwards its own option bag straight to
62
+ * `tls.connect`, so `{ rejectUnauthorized: false }` or `{ ca: <pem> }` belong
63
+ * at the top level rather than under a `tls:` key.
64
+ */
65
+ export type WsDialOptions = { rejectUnauthorized?: boolean; ca?: string | Buffer | Array<string | Buffer> }
66
+
41
67
  export interface DeviceConnectionOptions {
42
68
  /** Local Ed25519 device identity (raw protobuf private key bytes). */
43
69
  identity: Uint8Array
44
- /** The paired device record; `hosts`/`port` are re-read on every attempt. */
70
+ /**
71
+ * The paired device record; `hosts`/`security`/`ports`/`port` are re-read on
72
+ * every attempt. `security` and `ports` are REQUIRED by schemaVersion 4 (the
73
+ * v2→v3 and v3→v4 migrations backfill them), so the `?.` in the STEADY-STATE
74
+ * dial guards an in-memory record assembled without the map (or constructed
75
+ * before validation), not a migrated one. That dial — this class — consumes
76
+ * both per-host maps; the pairing dial never reads the record (it dials the
77
+ * QR candidate's own host/port/scheme, which is why the record can persist
78
+ * them). Each host dials with its OWN port; the record-level `port` is the
79
+ * winner's at pairing, the operator's after a PATCH, and always the fallback.
80
+ */
45
81
  record: DeviceRecord
82
+ /**
83
+ * Options for every dial's `WebSocket` (issue #126) — in practice
84
+ * `rejectUnauthorized`/`ca`, for a desktop behind a TLS proxy whose
85
+ * certificate is not in Node's default trust store. The assembly injects it
86
+ * from plugin config `wsTls` at BOTH dial sites (the pool and pairing); tests
87
+ * may still pass it directly. Absent means Node's defaults. NEVER derived
88
+ * from the record: trust is a grant the OPERATOR makes, not a fact the
89
+ * desktop asserts about itself.
90
+ */
91
+ wsOptions?: WsDialOptions
46
92
  /**
47
93
  * Second-level smoothed request budget for business RPCs on the SHARED tier
48
94
  * — every business RPC except `agent.content.read`, which has its own tier
@@ -544,6 +590,8 @@ interface HelloResult {
544
590
  protocolVersion?: number
545
591
  limits?: Record<string, number>
546
592
  heartbeatMs?: number
593
+ /** The paged-message-summaries capability (issue #105), when advertised. */
594
+ agentMessageSummariesVersion?: number
547
595
  }
548
596
 
549
597
  interface AuthResult {
@@ -740,8 +788,11 @@ export class DeviceConnection {
740
788
  #token: string | undefined
741
789
  #tokenExpiresAt: number | undefined
742
790
 
791
+ readonly #wsOptions: WsDialOptions | undefined
792
+
743
793
  constructor(opts: DeviceConnectionOptions) {
744
794
  this.record = opts.record
795
+ this.#wsOptions = opts.wsOptions
745
796
  this.#identity = opts.identity
746
797
  this.#limiter = new TieredRateLimiter(
747
798
  opts.rateBudgetPerSecond ?? DEFAULT_RATE_BUDGET_PER_SECOND,
@@ -967,6 +1018,10 @@ export class DeviceConnection {
967
1018
  if (error instanceof IdentityMismatchError) {
968
1019
  this.#running = false
969
1020
  this.#setState('identity-mismatch')
1021
+ // DELIBERATELY not clipped: this message is a COMPILE-TIME LITERAL
1022
+ // ("peer static key does not match the pinned desktopIdentity") with no
1023
+ // peer bytes in it, so there is nothing here to bound. The other two
1024
+ // error lines below interpolate text the peer controls.
970
1025
  console.error(
971
1026
  `[cherry-remote] device ${this.record.deviceId}: ${error.message}; ` +
972
1027
  'marked identity-mismatch and disconnected — re-pair the device',
@@ -978,9 +1033,19 @@ export class DeviceConnection {
978
1033
  if (error instanceof ProtocolVersionError) {
979
1034
  this.#running = false
980
1035
  this.#setState('offline')
1036
+ // BOUNDED (issue #155): the `cause` is the peer's OWN hello-echo text —
1037
+ // `ProtocolVersionError` is built from `String(<the peer's RPC failure>)`
1038
+ // — so the message can carry up to a whole 64 KiB record of peer text.
1039
+ // Ceiling: 56 fixed characters + the `deviceId` + LOG_TEXT_MAX (240,
1040
+ // marker included) + the 37-character upgrade suffix. The id is
1041
+ // deliberately NOT clipped (a whitelisted identifier an operator
1042
+ // correlates on), and it is peer-assigned (`pairing.get`'s approved
1043
+ // result, `opaqueId` ≤ 256), so the bound moves with it: 8-character
1044
+ // fixture id → 341; a 36-char UUID → 369; the schema worst case (256) →
1045
+ // 589.
981
1046
  console.error(
982
1047
  `[cherry-remote] device ${this.record.deviceId}: protocol version incompatible ` +
983
- `(${error.message}); upgrade Cherry Studio / the plugin`,
1048
+ `(${clipText(error.message, LOG_TEXT_MAX)}); upgrade Cherry Studio / the plugin`,
984
1049
  )
985
1050
  this.#cycle.reject(new Error(`device ${this.record.deviceId}: protocol version incompatible`))
986
1051
  return
@@ -1014,9 +1079,19 @@ export class DeviceConnection {
1014
1079
  ? ' — all candidate addresses were unreachable; if the desktop moved, update ' +
1015
1080
  'them via the device card 地址维护 (address maintenance)'
1016
1081
  : ''
1082
+ const detail = error instanceof Error ? error.message : String(error)
1017
1083
  console.warn(
1018
1084
  `[cherry-remote] device ${this.record.deviceId} offline ` +
1019
- `(${error instanceof Error ? error.message : String(error)}); reconnecting in ${delay}ms${hint}`,
1085
+ // BOUNDED (issue #155): this text is peer-influenced — an RPC failure
1086
+ // carries the peer's own `error.message` verbatim (an authenticate
1087
+ // refusal, a hello fault), and a transport error carries the system's.
1088
+ // Only the 64 KiB record budget bounded it before. Ceiling: 33 fixed
1089
+ // characters + the `deviceId` + LOG_TEXT_MAX (240, marker included) +
1090
+ // `); reconnecting in <delay>ms` (26 at the 60,000 ms backoff cap) +
1091
+ // the all-hosts hint (125). The id is NOT clipped (same decision as
1092
+ // above), so a 36-char UUID gives 460 and the schema worst case (256)
1093
+ // gives 680, against 432 for the 8-character fixture id.
1094
+ `(${clipText(detail, LOG_TEXT_MAX)}); reconnecting in ${delay}ms${hint}`,
1020
1095
  )
1021
1096
  this.#setTimer('backoff', setTimeout(() => this.#tryEstablish(), delay))
1022
1097
  }
@@ -1041,6 +1116,12 @@ export class DeviceConnection {
1041
1116
  * network failures fall through to the next candidate — the §6
1042
1117
  * 「候选地址全部不可达」 verdict only holds when EVERY host failed.
1043
1118
  *
1119
+ * Each candidate dials with ITS OWN port from `record.ports`; the record-level
1120
+ * `port` (the winner's at pairing, the operator's after a PATCH) is the
1121
+ * fallback for an in-memory record assembled without the map (issue #135).
1122
+ * Before that, one port was shared by every host, so a reconnect to a
1123
+ * non-winner host dialed the right scheme at the wrong port.
1124
+ *
1044
1125
  * "Per-host" covers the CHANNEL-LEVEL handshake failures as well as the dial:
1045
1126
  * a socket that opens and then dies mid-handshake (the peer accepted the TCP
1046
1127
  * connection and went away, a proxy reset it, the Noise exchange was cut) is a
@@ -1081,7 +1162,11 @@ export class DeviceConnection {
1081
1162
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError()
1082
1163
  let link: LiveLink
1083
1164
  try {
1084
- link = await this.#dial(host, this.record.port, epoch)
1165
+ // Per-host port, with the record-level (winner's) port as the fallback
1166
+ // for an in-memory record assembled without the map — the same `?.`
1167
+ // default the scheme gets in #dial (issue #135).
1168
+ const port = this.record.ports?.[host] ?? this.record.port
1169
+ link = await this.#dial(host, port, epoch)
1085
1170
  } catch (error) {
1086
1171
  if (error instanceof IdentityMismatchError) throw error
1087
1172
  lastError = error instanceof Error ? error : new Error(String(error))
@@ -1117,10 +1202,17 @@ export class DeviceConnection {
1117
1202
  }
1118
1203
 
1119
1204
  async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
1120
- // Bare IPv6 hosts (the archive's normalized form) must be bracketed into
1121
- // a URL authority or `new WebSocket` throws Invalid URL.
1122
- const url = `ws://${urlHost(host)}:${port}${remoteConnectPath}`
1123
- const ws = new WebSocket(url)
1205
+ // (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
1206
+ // The URL comes from the SAME helper the pairing dial uses (issue #126):
1207
+ // it brackets bare IPv6 hosts (the archive's normalized form — splicing one
1208
+ // raw into a URL makes `new WebSocket` throw Invalid URL) and selects
1209
+ // `ws:`/`wss:` from the record's own scheme for this host. Persisting the
1210
+ // scheme is what makes a TLS-proxied desktop RE-connectable: #124 paired
1211
+ // over `wss` and this dial then rebuilt `ws://` for every later session.
1212
+ const ws = new WebSocket(
1213
+ directEndpointUrl({ host, port, security: this.record.security?.[host] ?? 'ws' }),
1214
+ this.#wsOptions,
1215
+ )
1124
1216
  const controller = new AbortController()
1125
1217
 
1126
1218
  const opened = new Promise<void>((resolve) => ws.once('open', () => resolve()))
@@ -1207,11 +1299,18 @@ export class DeviceConnection {
1207
1299
  throw error instanceof Error ? error : new Error(String(error))
1208
1300
  }
1209
1301
 
1302
+ // REBUILT, never patched: the object is replaced on every hello, so a
1303
+ // reconnect to a peer that no longer advertises the capability (or a
1304
+ // downgrade) cannot inherit a stale `messageSummaries` from the previous
1305
+ // link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
1210
1306
  this.#limits = {
1211
1307
  heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
1212
1308
  subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
1213
1309
  pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
1214
1310
  inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
1311
+ ...(typeof hello.agentMessageSummariesVersion === 'number'
1312
+ ? { messageSummaries: hello.agentMessageSummariesVersion }
1313
+ : {}),
1215
1314
  }
1216
1315
 
1217
1316
  // Size the admission window from the peer's OWN concurrency budget now that
@@ -1764,6 +1863,9 @@ export class DeviceConnection {
1764
1863
  try {
1765
1864
  cb(next)
1766
1865
  } catch (error) {
1866
+ // LOCAL observer fan-out (our own listeners), so no peer text can reach
1867
+ // this error: the object print keeps the stack, which is the useful
1868
+ // part here (issue #161's boundary).
1767
1869
  console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error)
1768
1870
  }
1769
1871
  }
@@ -1771,6 +1873,8 @@ export class DeviceConnection {
1771
1873
  try {
1772
1874
  cb(next)
1773
1875
  } catch (error) {
1876
+ // LOCAL: `waitForSettled`'s internal closure, no peer text — the object
1877
+ // print (and its stack) is deliberate (issue #161's boundary).
1774
1878
  console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error)
1775
1879
  }
1776
1880
  }
@@ -45,8 +45,36 @@ export interface DeviceRecord {
45
45
  schemaVersion: number
46
46
  deviceId: string
47
47
  displayName: string
48
+ /**
49
+ * Ordered dial candidates. `security` and `ports` are keyed by these same
50
+ * hosts — one scheme and one port per candidate, exactly the shape the
51
+ * pairing dial already carries (`PairCandidate`), so a candidate and its
52
+ * address are added and replaced as one unit.
53
+ */
48
54
  hosts: string[]
55
+ /**
56
+ * The record-level dial port: the WINNER candidate's port at pairing, the
57
+ * operator's after a PATCH, and always the dial's fallback. A stored v4
58
+ * record carries an explicit `ports` entry for every host; this value is only
59
+ * the fallback for an in-memory record assembled before validation. Before
60
+ * schemaVersion 4 it was the only port — see `ports`.
61
+ */
49
62
  port: number
63
+ /**
64
+ * Dial scheme per host (issue #126). Every entry of `hosts` has exactly one
65
+ * key here; `wss` is a TLS-proxied desktop. Introduced with schemaVersion 3:
66
+ * a v2 archive had `hosts` + `port` only, and the migration backfills `ws`.
67
+ */
68
+ security: Record<string, 'ws' | 'wss'>
69
+ /**
70
+ * Dial port per host (issue #135). Every entry of `hosts` has exactly one
71
+ * key here, and the value is the port that host was paired on. Introduced
72
+ * with schemaVersion 4: before it, one `port` was shared by every host (the
73
+ * winner candidate's), so a steady-state reconnect to a non-winner host
74
+ * dialed the right scheme at the WRONG port. The v3→v4 migration backfills
75
+ * `ports` from `port`; `port` itself stays the record-level fallback.
76
+ */
77
+ ports: Record<string, number>
50
78
  desktopIdentity: string
51
79
  grants: 'agent'[]
52
80
  /** Paired with `tokenExpiresAt`: present together or absent together. */
@@ -77,7 +105,7 @@ export interface UnresolvedDevice {
77
105
  export type DeviceResolution = DeviceRecord | AmbiguousDevice | UnresolvedDevice
78
106
 
79
107
  /** The only `schemaVersion` this build reads and writes. */
80
- export const CURRENT_SCHEMA_VERSION = 2
108
+ export const CURRENT_SCHEMA_VERSION = 4
81
109
 
82
110
  const FILE_MODE = 0o600
83
111
 
@@ -105,15 +133,83 @@ type Migration = (record: DeviceRecordOnDisk) => DeviceRecordOnDisk
105
133
  * `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
106
134
  * real schema evolution starts at version 2 after release. A v1 archive is the
107
135
  * current field set minus `schemaVersion`.
136
+ *
137
+ * `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
138
+ * one `port` and no scheme at all, so every host is backfilled `ws` — the
139
+ * plain dial v2 always used. An archive that already carries SOME entries
140
+ * (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
141
+ * record remembers is never overwritten by the default, because that would
142
+ * downgrade a TLS-proxied desktop to a dial that cannot connect.
143
+ *
144
+ * `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
145
+ * shared `port` (the winner candidate's), so the step backfills `ports` from
146
+ * `port` for every host — the only one v3 ever dialed. A half-migrated file
147
+ * keeps the entries it already carries, for the same reason the scheme step
148
+ * does: a remembered port is the address the pairing actually used, and
149
+ * overwriting it would point the host at the winner's port again.
150
+ *
151
+ * **A legacy file is read loosely, this build's own output strictly —
152
+ * deliberately.** Each step fills its map for the CURRENT host list and
153
+ * nothing else: an entry naming a host the older file no longer lists is
154
+ * silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
155
+ * degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
156
+ * port VALUE is not repaired here at all — the migrated entry then continues
157
+ * down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
158
+ * archive. That split is the point: a v2 or v3 file is a supported legacy shape
159
+ * and is normalised into one, while a v4 file is this build's own output and
160
+ * any deviation from it is a hand edit worth failing loudly on.
108
161
  */
109
162
  const MIGRATIONS: Record<number, Migration> = {
110
163
  1: (record) => ({ ...record, schemaVersion: 2 }),
164
+ 2: (record) => ({ ...record, schemaVersion: 3, security: backfilledSecurity(record) }),
165
+ 3: (record) => ({ ...record, schemaVersion: 4, ports: backfilledPorts(record) }),
166
+ }
167
+
168
+ /** Every host of a v2 record, defaulting anything it did not already say. */
169
+ function backfilledSecurity(
170
+ disk: Partial<DeviceRecord>,
171
+ ): Record<string, 'ws' | 'wss'> {
172
+ // A hand-edited file can carry a non-array `hosts`; read it as empty here and
173
+ // let `assertValidRecord` name the real rule, instead of throwing a TypeError
174
+ // out of `.map` first.
175
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
176
+ const existing = disk.security ?? {}
177
+ // `Object.hasOwn`, never `existing[host] ?? …`: on an empty map a host named
178
+ // `constructor`/`__proto__`/`toString` would otherwise pick up an INHERITED
179
+ // function and poison the map. A remembered own entry wins even when its
180
+ // value is null — a junk value is the validator's to reject, not to repair.
181
+ return Object.fromEntries(
182
+ hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : 'ws']),
183
+ )
184
+ }
185
+
186
+ /** Every host of a v3 record, defaulting anything it did not already say. */
187
+ function backfilledPorts(disk: Partial<DeviceRecord>): Record<string, number> {
188
+ // Same two rules as `backfilledSecurity`: non-array hosts read as empty, and
189
+ // only an OWN entry is remembered (an inherited function is not a port).
190
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
191
+ const existing = disk.ports ?? {}
192
+ // The cast states this function's EXIT shape, not a guarantee about the
193
+ // input: a malformed entry may omit `port`, and the `undefined` that would
194
+ // produce is rejected by `assertValidPorts` right after the migration chain.
195
+ const fallback = disk.port as number
196
+ return Object.fromEntries(
197
+ hosts.map((host): [string, number] => [
198
+ host,
199
+ Object.hasOwn(existing, host) ? existing[host] : fallback,
200
+ ]),
201
+ )
111
202
  }
112
203
 
113
204
  /**
114
- * Every field of the *current* set except `schemaVersion` — v1 is defined as
115
- * the current field set minus the version key, so these are exactly the keys a
116
- * complete v1 archive must carry.
205
+ * The field set of a v1 entry: what the store wrote before it carried a
206
+ * version at all.
207
+ *
208
+ * FROZEN. It must not track the current record — a v1 archive predates every
209
+ * later field, so demanding one here (as `'security'` was, briefly) fails the
210
+ * completeness gate on an archive that is perfectly intact and quarantines it
211
+ * instead of migrating it. Later fields are the migration chain's business
212
+ * (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
117
213
  */
118
214
  const V1_KEYS = [
119
215
  'deviceId',
@@ -125,7 +221,18 @@ const V1_KEYS = [
125
221
  'enabled',
126
222
  ] as const satisfies readonly (keyof DeviceRecord)[]
127
223
 
128
- const REQUIRED_KEYS = [...V1_KEYS, 'schemaVersion'] as const satisfies readonly (keyof DeviceRecord)[]
224
+ /**
225
+ * Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
226
+ * this is not a historical snapshot: it grows with the record, and it is what
227
+ * `upsert` and the post-migration exit demand (which is why a v4 entry without
228
+ * `security`/`ports` never reaches its own validator).
229
+ */
230
+ const REQUIRED_KEYS = [
231
+ ...V1_KEYS,
232
+ 'security',
233
+ 'ports',
234
+ 'schemaVersion',
235
+ ] as const satisfies readonly (keyof DeviceRecord)[]
129
236
 
130
237
  /**
131
238
  * A version-less entry reads as the legacy (v1) shape only when every v1 field
@@ -153,6 +260,21 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
153
260
  if (!Array.isArray(disk.hosts)) {
154
261
  throw new Error('device archive entry field hosts must be an array')
155
262
  }
263
+ // The record-level port is the fallback a hosts-only PATCH derives a NEW
264
+ // host's entry from, so it is judged like any map value: a hand-edited v4
265
+ // record with a valid `ports` map but a junk `port` would otherwise make that
266
+ // PATCH 400 (naming a field absent from its body). The message names the
267
+ // field, never the value. Before this it was the one unchecked record number.
268
+ if (
269
+ typeof disk.port !== 'number' ||
270
+ !Number.isInteger(disk.port) ||
271
+ disk.port < 1 ||
272
+ disk.port > 65_535
273
+ ) {
274
+ throw new Error('device archive entry field port must be an integer in 1..65535')
275
+ }
276
+ assertValidSecurity(disk.hosts, disk.security)
277
+ assertValidPorts(disk.hosts, disk.ports)
156
278
 
157
279
  // accessToken / tokenExpiresAt live and die together.
158
280
  const hasToken = disk.accessToken !== undefined
@@ -166,6 +288,81 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
166
288
  return disk as DeviceRecord
167
289
  }
168
290
 
291
+ /**
292
+ * The `security` map must be EXACTLY one entry per host, each a known scheme.
293
+ *
294
+ * Keyed by host, so a record can carry an entry the dial will never consult
295
+ * (a host it no longer lists) and a host the dial will fall back on silently.
296
+ * Both are hand-edited-archive states, and both are rejected rather than
297
+ * repaired in memory: the archive a human edits is the archive the store
298
+ * judges, and a silent repair would hide the very mistake. The message names
299
+ * the offending HOSTS (they are addresses an operator chose, not secrets).
300
+ */
301
+ function assertValidSecurity(hosts: string[], security: unknown): void {
302
+ // NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
303
+ // missing `security` before this runs (a current-version archive is written
304
+ // by this build, so an absent map there is breakage, not a legacy shape). The
305
+ // arm exists for the OTHER entry point — `upsert`, which callers may reach
306
+ // with an in-memory record assembled by hand — and for the length of time a
307
+ // record exists between construction and its first validation.
308
+ if (security === undefined) return
309
+ if (security === null || typeof security !== 'object' || Array.isArray(security)) {
310
+ throw new Error('device archive entry field security must be an object keyed by host')
311
+ }
312
+ const map = security as Record<string, unknown>
313
+ const wanted = new Set(hosts)
314
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
315
+ if (unknownHost !== undefined) {
316
+ throw new Error(
317
+ `device archive entry field security names host "${unknownHost}", which is not in hosts`,
318
+ )
319
+ }
320
+ for (const host of hosts) {
321
+ const scheme = map[host]
322
+ if (scheme !== 'ws' && scheme !== 'wss') {
323
+ throw new Error(
324
+ `device archive entry field security["${host}"] must be "ws" or "wss"`,
325
+ )
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * The `ports` map must be EXACTLY one entry per host, each an integer port.
332
+ *
333
+ * The same reasoning as {@link assertValidSecurity}: an entry for a host the
334
+ * record no longer lists would never be consulted, and a missing one would
335
+ * leave that host on the record-level (winner's) port — the exact failure the
336
+ * map exists to prevent. Both are rejected rather than repaired. The
337
+ * message names the offending HOST, never the value (a port is not a secret, but
338
+ * echoing a junk value back from a hand-edited file adds nothing actionable).
339
+ */
340
+ function assertValidPorts(hosts: string[], ports: unknown): void {
341
+ // NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
342
+ // missing `ports` before this runs. Same other-entry-point reason as
343
+ // `assertValidSecurity`: `upsert` and construction-to-first-validation.
344
+ if (ports === undefined) return
345
+ if (ports === null || typeof ports !== 'object' || Array.isArray(ports)) {
346
+ throw new Error('device archive entry field ports must be an object keyed by host')
347
+ }
348
+ const map = ports as Record<string, unknown>
349
+ const wanted = new Set(hosts)
350
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
351
+ if (unknownHost !== undefined) {
352
+ throw new Error(
353
+ `device archive entry field ports names host "${unknownHost}", which is not in hosts`,
354
+ )
355
+ }
356
+ for (const host of hosts) {
357
+ const port = map[host]
358
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
359
+ throw new Error(
360
+ `device archive entry field ports["${host}"] must be an integer in 1..65535`,
361
+ )
362
+ }
363
+ }
364
+ }
365
+
169
366
  /**
170
367
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
171
368
  * -> migration -> assert the required `DeviceRecord` type.
@@ -587,6 +784,8 @@ export class DeviceStore {
587
784
  // `list`/`resolve` keep working (RF4's spirit — a corrupt archive never
588
785
  // takes the plugin down), but nothing may write over the evidence.
589
786
  this.#quarantine = error.withCorruptPath(quarantine(error.file))
787
+ // LOCAL fs/parse cause, never peer text (issue #161 audit): the object
788
+ // print keeps the cause's stack, which is the useful part here.
590
789
  console.warn(
591
790
  `device archive ${this.#file} is unreadable and was quarantined; serving an empty ` +
592
791
  `read-only archive and refusing all writes until the bad file is moved aside:`,
@@ -193,6 +193,21 @@ export interface DiagnosticsDevice {
193
193
  */
194
194
  desktopIdentity: string
195
195
  limits?: EffectiveLimits
196
+ /**
197
+ * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105) —
198
+ * the capability bit that decides whether `agent.messages.list` may carry
199
+ * `partsMode: 'paged'`.
200
+ *
201
+ * Its own field, not a fifth key inside `limits`: it is a VERSION, not a
202
+ * count, and `limits` is documented as the four numeric caps. Present only
203
+ * when a LIVE connection reported it (the same rule as `limits`: the endpoint
204
+ * never dials to find out). ABSENT therefore means "not advertised **or** not
205
+ * connected" — every desktop through 2.1.7, and any device whose link is
206
+ * down at the moment of the call. One `curl` against
207
+ * `GET /cherry-remote/api/diagnostics` **while the device is connected**
208
+ * answers "does this desktop speak paged summaries?" without a packet capture.
209
+ */
210
+ messageSummaries?: number
196
211
  }
197
212
 
198
213
  /** One session's LOCAL subscription bookkeeping. */
@@ -500,6 +515,9 @@ export function sanitizeDevice(
500
515
  pageItems: limits.pageItems,
501
516
  inFlightRequests: limits.inFlightRequests,
502
517
  },
518
+ // Own field, own whitelist entry: a single number copied out of the
519
+ // hello, never the reply itself.
520
+ ...(limits.messageSummaries === undefined ? {} : { messageSummaries: limits.messageSummaries }),
503
521
  }),
504
522
  }
505
523
  }
@@ -522,9 +540,20 @@ function projectDevice(raw: unknown): DiagnosticsDevice {
522
540
  ? desktopIdentity
523
541
  : digestIdentity(desktopIdentity),
524
542
  ...projectLimits(value['limits']),
543
+ ...projectMessageSummaries(value['messageSummaries']),
525
544
  }
526
545
  }
527
546
 
547
+ /**
548
+ * Re-projects the ONE capability field, dropping anything that is not a finite
549
+ * number: a version this report cannot read is reported as absent (not
550
+ * advertised), which is the safe side — the field only ever amplifies what the
551
+ * peer's hello said.
552
+ */
553
+ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
554
+ return typeof raw === 'number' && Number.isFinite(raw) ? { messageSummaries: raw } : {}
555
+ }
556
+
528
557
  /**
529
558
  * Re-projects the four numeric limit keys, dropping any foreign key that a
530
559
  * caller's `limits` object might carry. A whitelist inside the whitelist: the
@@ -843,4 +872,5 @@ export const DIAGNOSTICS_DEVICE_KEYS: readonly string[] = [
843
872
  'port',
844
873
  'desktopIdentity',
845
874
  'limits',
875
+ 'messageSummaries',
846
876
  ]