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/deviceStore.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import { dirname } from 'node:path'
|
|
14
|
+
import type { DirectEndpoint } from '@cherrystudio/remote-protocol'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Crash-durable, corruption-safe archive of paired Cherry Studio desktop
|
|
@@ -45,8 +46,51 @@ export interface DeviceRecord {
|
|
|
45
46
|
schemaVersion: number
|
|
46
47
|
deviceId: string
|
|
47
48
|
displayName: string
|
|
49
|
+
/**
|
|
50
|
+
* Ordered dial candidates. `security` and `ports` are keyed by these same
|
|
51
|
+
* hosts — one scheme and one port per candidate, exactly the shape the
|
|
52
|
+
* pairing dial already carries (`PairCandidate`), so a candidate and its
|
|
53
|
+
* address are added and replaced as one unit.
|
|
54
|
+
*/
|
|
48
55
|
hosts: string[]
|
|
56
|
+
/**
|
|
57
|
+
* The record-level dial port: the WINNER candidate's port at pairing, the
|
|
58
|
+
* operator's after a PATCH, and always the dial's fallback. A stored v4
|
|
59
|
+
* record carries an explicit `ports` entry for every host; this value is only
|
|
60
|
+
* the fallback for an in-memory record assembled before validation. Before
|
|
61
|
+
* schemaVersion 4 it was the only port — see `ports`.
|
|
62
|
+
*/
|
|
49
63
|
port: number
|
|
64
|
+
/**
|
|
65
|
+
* Dial scheme per host (issue #126). Every entry of `hosts` has exactly one
|
|
66
|
+
* key here; `wss` is a TLS-proxied desktop. Introduced with schemaVersion 3:
|
|
67
|
+
* a v2 archive had `hosts` + `port` only, and the migration backfills `ws`.
|
|
68
|
+
*/
|
|
69
|
+
security: Record<string, 'ws' | 'wss'>
|
|
70
|
+
/**
|
|
71
|
+
* Dial port per host (issue #135). Every entry of `hosts` has exactly one
|
|
72
|
+
* key here, and the value is the port that host was paired on. Introduced
|
|
73
|
+
* with schemaVersion 4: before it, one `port` was shared by every host (the
|
|
74
|
+
* winner candidate's), so a steady-state reconnect to a non-winner host
|
|
75
|
+
* dialed the right scheme at the WRONG port. The v3→v4 migration backfills
|
|
76
|
+
* `ports` from `port`; `port` itself stays the record-level fallback.
|
|
77
|
+
*/
|
|
78
|
+
ports: Record<string, number>
|
|
79
|
+
/**
|
|
80
|
+
* Addresses the operator has FORGOTTEN (issue #185): never dialed and never
|
|
81
|
+
* learned again, matched CASE-INSENSITIVELY and disjoint from `hosts` — a
|
|
82
|
+
* record may not dial what it has forgotten, and `assertValidRecord` refuses
|
|
83
|
+
* the overlap rather than picking a winner at read time.
|
|
84
|
+
*
|
|
85
|
+
* OPTIONAL on purpose, and therefore not in {@link REQUIRED_KEYS}. Absence is
|
|
86
|
+
* the total default ("nothing forgotten"); no invariant depends on the key
|
|
87
|
+
* being present, unlike `security`/`ports` whose absence would make a host
|
|
88
|
+
* undialable; and an older build ignores an unknown key instead of refusing
|
|
89
|
+
* the archive. A `schemaVersion` bump would invalidate every v4 archive (and
|
|
90
|
+
* every fixture built from the v4 shape) for a list whose empty value needs
|
|
91
|
+
* no migration.
|
|
92
|
+
*/
|
|
93
|
+
forgottenHosts?: string[]
|
|
50
94
|
desktopIdentity: string
|
|
51
95
|
grants: 'agent'[]
|
|
52
96
|
/** Paired with `tokenExpiresAt`: present together or absent together. */
|
|
@@ -77,7 +121,81 @@ export interface UnresolvedDevice {
|
|
|
77
121
|
export type DeviceResolution = DeviceRecord | AmbiguousDevice | UnresolvedDevice
|
|
78
122
|
|
|
79
123
|
/** The only `schemaVersion` this build reads and writes. */
|
|
80
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
124
|
+
export const CURRENT_SCHEMA_VERSION = 4
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The most candidate hosts one record may carry (issue #143).
|
|
128
|
+
*
|
|
129
|
+
* `hosts` is a DIAL list, not a table: every reconnect walks it with one
|
|
130
|
+
* `connectTimeoutMs` per address, so its LENGTH is a time cost — a 32-host
|
|
131
|
+
* record already allows a 320 s pass, which is the number the pairing walk's
|
|
132
|
+
* aggregate budget exists for. Pairing can only ever produce 32
|
|
133
|
+
* (`pairingQrSchema`'s `.max(32)`), and learning is held to the same bound: an
|
|
134
|
+
* enrichment may not build a record that no pairing could have produced.
|
|
135
|
+
*/
|
|
136
|
+
export const MAX_CANDIDATE_HOSTS = 32
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Adds the candidate addresses a peer ADVERTISED that this record does not
|
|
140
|
+
* already dial, returning how many hosts were added (issue #143).
|
|
141
|
+
*
|
|
142
|
+
* The rule is ADD-ONLY, deliberately:
|
|
143
|
+
*
|
|
144
|
+
* - a host already in `hosts` is left exactly as it is, INCLUDING its port and
|
|
145
|
+
* its scheme. The peer's snapshot describes where it listens NOW, while a
|
|
146
|
+
* record entry may be an operator's choice (a pinned port, a TLS proxy) that
|
|
147
|
+
* every reconnect must keep honouring; re-pointing it would silently rewrite
|
|
148
|
+
* the operator's address book on a schedule. "Already dials" is judged
|
|
149
|
+
* CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
|
|
150
|
+
* lowercases what it advertises, and a PATCHed record stores what the
|
|
151
|
+
* operator typed — without this, `MyNAS.example` and `mynas.example` would
|
|
152
|
+
* both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
|
|
153
|
+
* reconciled here; the PATCH path owns the archive's spelling, and it does
|
|
154
|
+
* not canonicalize either.)
|
|
155
|
+
* - nothing is removed HERE: a host the peer still advertises is re-learned
|
|
156
|
+
* after an operator PATCH drops it, because the peer is the source of truth
|
|
157
|
+
* for where it listens. The operator's answer to that is `forgetEndpoints`
|
|
158
|
+
* (issue #185), whose mark this function honours — addition stays add-only,
|
|
159
|
+
* and forgetting is a separate, explicit decision;
|
|
160
|
+
* - host, port and scheme are appended TOGETHER, which is what keeps the v4
|
|
161
|
+
* one-entry-per-host invariant true. That invariant is enforced on the READ
|
|
162
|
+
* path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
|
|
163
|
+
* there is NOT a quarantine (that class covers unreadable bytes): the throw
|
|
164
|
+
* escapes `parseArchive` out of `list()`, so a split entry would break every
|
|
165
|
+
* tool until an operator repaired the file;
|
|
166
|
+
* - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
|
|
167
|
+
* rather than scanning the rest.
|
|
168
|
+
*
|
|
169
|
+
* `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
|
|
170
|
+
* (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
|
|
171
|
+
* loopback destination), so every value written here is archive-valid without a
|
|
172
|
+
* second validation pass.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeAdvertisedEndpoints(
|
|
175
|
+
record: DeviceRecord,
|
|
176
|
+
endpoints: readonly DirectEndpoint[],
|
|
177
|
+
): number {
|
|
178
|
+
// One comparison rule, applied to what the record already dials AND to what
|
|
179
|
+
// this answer has added so far: a duplicate inside one answer must not take a
|
|
180
|
+
// second slot either.
|
|
181
|
+
const dialed = new Set(record.hosts.map((host) => host.toLowerCase()))
|
|
182
|
+
// A FORGOTTEN address is skipped here, not removed afterwards: the operator's
|
|
183
|
+
// decision outranks the peer's snapshot, and an answer that names it must not
|
|
184
|
+
// even spend a dial slot on it.
|
|
185
|
+
const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()))
|
|
186
|
+
let added = 0
|
|
187
|
+
for (const endpoint of endpoints) {
|
|
188
|
+
const key = endpoint.host.toLowerCase()
|
|
189
|
+
if (dialed.has(key) || forgotten.has(key)) continue
|
|
190
|
+
if (record.hosts.length >= MAX_CANDIDATE_HOSTS) break
|
|
191
|
+
record.hosts.push(endpoint.host)
|
|
192
|
+
record.ports[endpoint.host] = endpoint.port
|
|
193
|
+
record.security[endpoint.host] = endpoint.security
|
|
194
|
+
dialed.add(key)
|
|
195
|
+
added += 1
|
|
196
|
+
}
|
|
197
|
+
return added
|
|
198
|
+
}
|
|
81
199
|
|
|
82
200
|
const FILE_MODE = 0o600
|
|
83
201
|
|
|
@@ -105,15 +223,83 @@ type Migration = (record: DeviceRecordOnDisk) => DeviceRecordOnDisk
|
|
|
105
223
|
* `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
|
|
106
224
|
* real schema evolution starts at version 2 after release. A v1 archive is the
|
|
107
225
|
* current field set minus `schemaVersion`.
|
|
226
|
+
*
|
|
227
|
+
* `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
|
|
228
|
+
* one `port` and no scheme at all, so every host is backfilled `ws` — the
|
|
229
|
+
* plain dial v2 always used. An archive that already carries SOME entries
|
|
230
|
+
* (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
|
|
231
|
+
* record remembers is never overwritten by the default, because that would
|
|
232
|
+
* downgrade a TLS-proxied desktop to a dial that cannot connect.
|
|
233
|
+
*
|
|
234
|
+
* `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
|
|
235
|
+
* shared `port` (the winner candidate's), so the step backfills `ports` from
|
|
236
|
+
* `port` for every host — the only one v3 ever dialed. A half-migrated file
|
|
237
|
+
* keeps the entries it already carries, for the same reason the scheme step
|
|
238
|
+
* does: a remembered port is the address the pairing actually used, and
|
|
239
|
+
* overwriting it would point the host at the winner's port again.
|
|
240
|
+
*
|
|
241
|
+
* **A legacy file is read loosely, this build's own output strictly —
|
|
242
|
+
* deliberately.** Each step fills its map for the CURRENT host list and
|
|
243
|
+
* nothing else: an entry naming a host the older file no longer lists is
|
|
244
|
+
* silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
|
|
245
|
+
* degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
|
|
246
|
+
* port VALUE is not repaired here at all — the migrated entry then continues
|
|
247
|
+
* down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
|
|
248
|
+
* archive. That split is the point: a v2 or v3 file is a supported legacy shape
|
|
249
|
+
* and is normalised into one, while a v4 file is this build's own output and
|
|
250
|
+
* any deviation from it is a hand edit worth failing loudly on.
|
|
108
251
|
*/
|
|
109
252
|
const MIGRATIONS: Record<number, Migration> = {
|
|
110
253
|
1: (record) => ({ ...record, schemaVersion: 2 }),
|
|
254
|
+
2: (record) => ({ ...record, schemaVersion: 3, security: backfilledSecurity(record) }),
|
|
255
|
+
3: (record) => ({ ...record, schemaVersion: 4, ports: backfilledPorts(record) }),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Every host of a v2 record, defaulting anything it did not already say. */
|
|
259
|
+
function backfilledSecurity(
|
|
260
|
+
disk: Partial<DeviceRecord>,
|
|
261
|
+
): Record<string, 'ws' | 'wss'> {
|
|
262
|
+
// A hand-edited file can carry a non-array `hosts`; read it as empty here and
|
|
263
|
+
// let `assertValidRecord` name the real rule, instead of throwing a TypeError
|
|
264
|
+
// out of `.map` first.
|
|
265
|
+
const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
|
|
266
|
+
const existing = disk.security ?? {}
|
|
267
|
+
// `Object.hasOwn`, never `existing[host] ?? …`: on an empty map a host named
|
|
268
|
+
// `constructor`/`__proto__`/`toString` would otherwise pick up an INHERITED
|
|
269
|
+
// function and poison the map. A remembered own entry wins even when its
|
|
270
|
+
// value is null — a junk value is the validator's to reject, not to repair.
|
|
271
|
+
return Object.fromEntries(
|
|
272
|
+
hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : 'ws']),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Every host of a v3 record, defaulting anything it did not already say. */
|
|
277
|
+
function backfilledPorts(disk: Partial<DeviceRecord>): Record<string, number> {
|
|
278
|
+
// Same two rules as `backfilledSecurity`: non-array hosts read as empty, and
|
|
279
|
+
// only an OWN entry is remembered (an inherited function is not a port).
|
|
280
|
+
const hosts = Array.isArray(disk.hosts) ? disk.hosts : []
|
|
281
|
+
const existing = disk.ports ?? {}
|
|
282
|
+
// The cast states this function's EXIT shape, not a guarantee about the
|
|
283
|
+
// input: a malformed entry may omit `port`, and the `undefined` that would
|
|
284
|
+
// produce is rejected by `assertValidPorts` right after the migration chain.
|
|
285
|
+
const fallback = disk.port as number
|
|
286
|
+
return Object.fromEntries(
|
|
287
|
+
hosts.map((host): [string, number] => [
|
|
288
|
+
host,
|
|
289
|
+
Object.hasOwn(existing, host) ? existing[host] : fallback,
|
|
290
|
+
]),
|
|
291
|
+
)
|
|
111
292
|
}
|
|
112
293
|
|
|
113
294
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
295
|
+
* The field set of a v1 entry: what the store wrote before it carried a
|
|
296
|
+
* version at all.
|
|
297
|
+
*
|
|
298
|
+
* FROZEN. It must not track the current record — a v1 archive predates every
|
|
299
|
+
* later field, so demanding one here (as `'security'` was, briefly) fails the
|
|
300
|
+
* completeness gate on an archive that is perfectly intact and quarantines it
|
|
301
|
+
* instead of migrating it. Later fields are the migration chain's business
|
|
302
|
+
* (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
|
|
117
303
|
*/
|
|
118
304
|
const V1_KEYS = [
|
|
119
305
|
'deviceId',
|
|
@@ -125,7 +311,18 @@ const V1_KEYS = [
|
|
|
125
311
|
'enabled',
|
|
126
312
|
] as const satisfies readonly (keyof DeviceRecord)[]
|
|
127
313
|
|
|
128
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
|
|
316
|
+
* this is not a historical snapshot: it grows with the record, and it is what
|
|
317
|
+
* `upsert` and the post-migration exit demand (which is why a v4 entry without
|
|
318
|
+
* `security`/`ports` never reaches its own validator).
|
|
319
|
+
*/
|
|
320
|
+
const REQUIRED_KEYS = [
|
|
321
|
+
...V1_KEYS,
|
|
322
|
+
'security',
|
|
323
|
+
'ports',
|
|
324
|
+
'schemaVersion',
|
|
325
|
+
] as const satisfies readonly (keyof DeviceRecord)[]
|
|
129
326
|
|
|
130
327
|
/**
|
|
131
328
|
* A version-less entry reads as the legacy (v1) shape only when every v1 field
|
|
@@ -153,6 +350,22 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
|
|
|
153
350
|
if (!Array.isArray(disk.hosts)) {
|
|
154
351
|
throw new Error('device archive entry field hosts must be an array')
|
|
155
352
|
}
|
|
353
|
+
// The record-level port is the fallback a hosts-only PATCH derives a NEW
|
|
354
|
+
// host's entry from, so it is judged like any map value: a hand-edited v4
|
|
355
|
+
// record with a valid `ports` map but a junk `port` would otherwise make that
|
|
356
|
+
// PATCH 400 (naming a field absent from its body). The message names the
|
|
357
|
+
// field, never the value. Before this it was the one unchecked record number.
|
|
358
|
+
if (
|
|
359
|
+
typeof disk.port !== 'number' ||
|
|
360
|
+
!Number.isInteger(disk.port) ||
|
|
361
|
+
disk.port < 1 ||
|
|
362
|
+
disk.port > 65_535
|
|
363
|
+
) {
|
|
364
|
+
throw new Error('device archive entry field port must be an integer in 1..65535')
|
|
365
|
+
}
|
|
366
|
+
assertValidSecurity(disk.hosts, disk.security)
|
|
367
|
+
assertValidPorts(disk.hosts, disk.ports)
|
|
368
|
+
assertValidForgotten(disk.hosts, disk.forgottenHosts)
|
|
156
369
|
|
|
157
370
|
// accessToken / tokenExpiresAt live and die together.
|
|
158
371
|
const hasToken = disk.accessToken !== undefined
|
|
@@ -166,6 +379,147 @@ function assertValidRecord(disk: DeviceRecordOnDisk): DeviceRecord {
|
|
|
166
379
|
return disk as DeviceRecord
|
|
167
380
|
}
|
|
168
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The `security` map must be EXACTLY one entry per host, each a known scheme.
|
|
384
|
+
*
|
|
385
|
+
* Keyed by host, so a record can carry an entry the dial will never consult
|
|
386
|
+
* (a host it no longer lists) and a host the dial will fall back on silently.
|
|
387
|
+
* Both are hand-edited-archive states, and both are rejected rather than
|
|
388
|
+
* repaired in memory: the archive a human edits is the archive the store
|
|
389
|
+
* judges, and a silent repair would hide the very mistake. The message names
|
|
390
|
+
* the offending HOSTS (they are addresses an operator chose, not secrets).
|
|
391
|
+
*/
|
|
392
|
+
function assertValidSecurity(hosts: string[], security: unknown): void {
|
|
393
|
+
// NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
|
|
394
|
+
// missing `security` before this runs (a current-version archive is written
|
|
395
|
+
// by this build, so an absent map there is breakage, not a legacy shape). The
|
|
396
|
+
// arm exists for the OTHER entry point — `upsert`, which callers may reach
|
|
397
|
+
// with an in-memory record assembled by hand — and for the length of time a
|
|
398
|
+
// record exists between construction and its first validation.
|
|
399
|
+
if (security === undefined) return
|
|
400
|
+
if (security === null || typeof security !== 'object' || Array.isArray(security)) {
|
|
401
|
+
throw new Error('device archive entry field security must be an object keyed by host')
|
|
402
|
+
}
|
|
403
|
+
const map = security as Record<string, unknown>
|
|
404
|
+
const wanted = new Set(hosts)
|
|
405
|
+
const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
|
|
406
|
+
if (unknownHost !== undefined) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`device archive entry field security names host "${unknownHost}", which is not in hosts`,
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
for (const host of hosts) {
|
|
412
|
+
const scheme = map[host]
|
|
413
|
+
if (scheme !== 'ws' && scheme !== 'wss') {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`device archive entry field security["${host}"] must be "ws" or "wss"`,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The `ports` map must be EXACTLY one entry per host, each an integer port.
|
|
423
|
+
*
|
|
424
|
+
* The same reasoning as {@link assertValidSecurity}: an entry for a host the
|
|
425
|
+
* record no longer lists would never be consulted, and a missing one would
|
|
426
|
+
* leave that host on the record-level (winner's) port — the exact failure the
|
|
427
|
+
* map exists to prevent. Both are rejected rather than repaired. The
|
|
428
|
+
* message names the offending HOST, never the value (a port is not a secret, but
|
|
429
|
+
* echoing a junk value back from a hand-edited file adds nothing actionable).
|
|
430
|
+
*/
|
|
431
|
+
function assertValidPorts(hosts: string[], ports: unknown): void {
|
|
432
|
+
// NOT reached with `undefined` through the store: `REQUIRED_KEYS` rejects a
|
|
433
|
+
// missing `ports` before this runs. Same other-entry-point reason as
|
|
434
|
+
// `assertValidSecurity`: `upsert` and construction-to-first-validation.
|
|
435
|
+
if (ports === undefined) return
|
|
436
|
+
if (ports === null || typeof ports !== 'object' || Array.isArray(ports)) {
|
|
437
|
+
throw new Error('device archive entry field ports must be an object keyed by host')
|
|
438
|
+
}
|
|
439
|
+
const map = ports as Record<string, unknown>
|
|
440
|
+
const wanted = new Set(hosts)
|
|
441
|
+
const unknownHost = Object.keys(map).find((host) => !wanted.has(host))
|
|
442
|
+
if (unknownHost !== undefined) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`device archive entry field ports names host "${unknownHost}", which is not in hosts`,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
for (const host of hosts) {
|
|
448
|
+
const port = map[host]
|
|
449
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`device archive entry field ports["${host}"] must be an integer in 1..65535`,
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* `forgottenHosts`, when present, must be a list of non-empty strings with no
|
|
459
|
+
* case-variant duplicates and no overlap with `hosts`.
|
|
460
|
+
*
|
|
461
|
+
* Each rule refuses a state whose MEANING is ambiguous: two spellings of one
|
|
462
|
+
* host make "is this address suppressed?" depend on which entry the comparison
|
|
463
|
+
* hit first, and a host that is both dialed and forgotten makes the dial list
|
|
464
|
+
* and the suppression list disagree about the same address. The message names
|
|
465
|
+
* the offending host — these are addresses an operator chose, not secrets.
|
|
466
|
+
*/
|
|
467
|
+
function assertValidForgotten(hosts: string[], forgotten: unknown): void {
|
|
468
|
+
if (forgotten === undefined) return
|
|
469
|
+
if (!Array.isArray(forgotten)) {
|
|
470
|
+
throw new Error('device archive entry field forgottenHosts must be an array')
|
|
471
|
+
}
|
|
472
|
+
const dialed = new Set(hosts.map((host) => host.toLowerCase()))
|
|
473
|
+
const seen = new Set<string>()
|
|
474
|
+
for (const entry of forgotten) {
|
|
475
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
476
|
+
throw new Error('device archive entry field forgottenHosts must list non-empty strings')
|
|
477
|
+
}
|
|
478
|
+
const key = entry.toLowerCase()
|
|
479
|
+
if (seen.has(key)) {
|
|
480
|
+
throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`)
|
|
481
|
+
}
|
|
482
|
+
if (dialed.has(key)) {
|
|
483
|
+
throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`)
|
|
484
|
+
}
|
|
485
|
+
seen.add(key)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Repairs ONE cross-build state on the READ path: a host listed in both
|
|
491
|
+
* `hosts` and `forgottenHosts` (issue #185).
|
|
492
|
+
*
|
|
493
|
+
* A build that predates the field PRESERVES it but cannot honour it, so a
|
|
494
|
+
* downgrade followed by a still-advertising desktop appends the host to
|
|
495
|
+
* `hosts` while the mark stays. Upgrading again then yields a record that says
|
|
496
|
+
* both "dial it" and "never dial it". Refusing it would brick every device tool
|
|
497
|
+
* until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
|
|
498
|
+
* list — what the running build actually did — and drops the mark, with a
|
|
499
|
+
* warning naming the device and host (addresses are not secrets).
|
|
500
|
+
*
|
|
501
|
+
* The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
|
|
502
|
+
* `rememberEndpoints` never produce the overlap, and `assertValidRecord`
|
|
503
|
+
* refuses it. This runs ONLY on the read path, before that assertion.
|
|
504
|
+
*/
|
|
505
|
+
function reconcileForgottenMarks(disk: DeviceRecordOnDisk): DeviceRecordOnDisk {
|
|
506
|
+
const forgotten = disk.forgottenHosts
|
|
507
|
+
if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk
|
|
508
|
+
const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()))
|
|
509
|
+
const kept = forgotten.filter(
|
|
510
|
+
(host) => !(typeof host === 'string' && dialed.has(host.toLowerCase())),
|
|
511
|
+
)
|
|
512
|
+
if (kept.length === forgotten.length) return disk
|
|
513
|
+
for (const host of forgotten) {
|
|
514
|
+
if (typeof host !== 'string' || !dialed.has(host.toLowerCase())) continue
|
|
515
|
+
console.warn(
|
|
516
|
+
`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and ` +
|
|
517
|
+
`forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
return { ...disk, forgottenHosts: kept }
|
|
521
|
+
}
|
|
522
|
+
|
|
169
523
|
/**
|
|
170
524
|
* Read path, fixed order: loose parse -> completeness gate -> version judgement
|
|
171
525
|
* -> migration -> assert the required `DeviceRecord` type.
|
|
@@ -228,7 +582,9 @@ function parseRecord(raw: unknown, archiveVersion: number): DeviceRecord {
|
|
|
228
582
|
migrated = migrate(migrated)
|
|
229
583
|
}
|
|
230
584
|
|
|
231
|
-
|
|
585
|
+
// A cross-build repair, never a write-path policy: see
|
|
586
|
+
// `reconcileForgottenMarks` for why the read path is the forgiving one.
|
|
587
|
+
return assertValidRecord(reconcileForgottenMarks(migrated))
|
|
232
588
|
}
|
|
233
589
|
|
|
234
590
|
function parseArchive(parsed: unknown): ArchiveFile {
|
|
@@ -382,6 +738,31 @@ export class QuarantinedArchiveError extends Error {
|
|
|
382
738
|
}
|
|
383
739
|
}
|
|
384
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
|
|
743
|
+
* would leave a record with NO dial candidate.
|
|
744
|
+
*
|
|
745
|
+
* A record with nothing to dial is worse than the stale address the operator
|
|
746
|
+
* wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
|
|
747
|
+
* with a non-empty `hosts` list writes a fresh one, and the archive would look
|
|
748
|
+
* healthy the whole time. The check is ATOMIC — the whole call refuses, so a
|
|
749
|
+
* name for the last host cannot remove a different host on its way to failing.
|
|
750
|
+
* Nothing is written when this is thrown.
|
|
751
|
+
*/
|
|
752
|
+
export class LastCandidateHostError extends Error {
|
|
753
|
+
readonly deviceId: string
|
|
754
|
+
|
|
755
|
+
constructor(deviceId: string) {
|
|
756
|
+
super(
|
|
757
|
+
`device ${deviceId}: refusing to forget the last dial candidate — the record would have ` +
|
|
758
|
+
'nothing to dial. Keep at least one address.',
|
|
759
|
+
)
|
|
760
|
+
this.name = 'LastCandidateHostError'
|
|
761
|
+
this.deviceId = deviceId
|
|
762
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
385
766
|
/**
|
|
386
767
|
* Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
|
|
387
768
|
* copy must not mask the original problem) and returns the path it landed at.
|
|
@@ -575,6 +956,135 @@ export class DeviceStore {
|
|
|
575
956
|
this.#persist(devices)
|
|
576
957
|
}
|
|
577
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Merges freshly learned addresses into one record and writes ONLY when that
|
|
961
|
+
* record gained a host (issue #143), returning the number added.
|
|
962
|
+
*
|
|
963
|
+
* It exists so the enrichment is an ARCHIVE operation rather than an
|
|
964
|
+
* `upsert` the assembly assembles by hand, and so a merge that adds nothing
|
|
965
|
+
* never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
|
|
966
|
+
* every connect, and a stable desktop would otherwise pay a full atomic write
|
|
967
|
+
* per reconnect. The merge runs on a CLONE and is committed as a new list,
|
|
968
|
+
* mirroring `upsert`'s discipline — the store never adopts a caller's object
|
|
969
|
+
* graph, and a write refused at {@link #persist}'s own gate cannot have
|
|
970
|
+
* half-edited the record the archive still holds.
|
|
971
|
+
*
|
|
972
|
+
* `desktopIdentity` is the pin the answering connection belongs to: the
|
|
973
|
+
* record must still name that desktop. A RE-PAIR under the same `deviceId`
|
|
974
|
+
* (the desktop regenerated its identity) would otherwise take an address
|
|
975
|
+
* learned from the previous pairing — a stale candidate whose every dial then
|
|
976
|
+
* fails the Noise pin, permanently, because learning never removes.
|
|
977
|
+
*
|
|
978
|
+
* An unknown `deviceId` is a no-op — the record may have been removed (or
|
|
979
|
+
* re-paired) between the RPC and this write.
|
|
980
|
+
*/
|
|
981
|
+
learnEndpoints(deviceId: string, endpoints: readonly DirectEndpoint[], desktopIdentity: string): number {
|
|
982
|
+
const devices = this.#load()
|
|
983
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
984
|
+
if (index < 0) return 0
|
|
985
|
+
if (devices[index]!.desktopIdentity !== desktopIdentity) return 0
|
|
986
|
+
const merged = structuredClone(devices[index]!)
|
|
987
|
+
const added = mergeAdvertisedEndpoints(merged, endpoints)
|
|
988
|
+
if (added === 0) return 0
|
|
989
|
+
const next = [...devices]
|
|
990
|
+
next[index] = merged
|
|
991
|
+
this.#persist(next)
|
|
992
|
+
return added
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* FORGETS addresses (issue #185): drops each named host from the dial list —
|
|
997
|
+
* host, scheme and port as one unit — and records it so the next
|
|
998
|
+
* advertisement cannot put it back. Returns the hosts that left the dial list
|
|
999
|
+
* (`removed`) and the marks this call added (`marked`).
|
|
1000
|
+
*
|
|
1001
|
+
* This is the operation learning cannot provide. `connection.endpoints` is
|
|
1002
|
+
* asked on every handshake, so an address the desktop still advertises
|
|
1003
|
+
* returns after any removal the operator makes by PATCH; forgetting is the
|
|
1004
|
+
* explicit decision that outranks it.
|
|
1005
|
+
*
|
|
1006
|
+
* - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
|
|
1007
|
+
* spelling, the peer's schema lowercases what it advertises), and the mark
|
|
1008
|
+
* records the ARCHIVE's spelling when the host was dialed, the caller's
|
|
1009
|
+
* otherwise.
|
|
1010
|
+
* - Naming an address the record does not dial is legal and still marks it:
|
|
1011
|
+
* suppression is the point, and a pre-emptive forget must survive until
|
|
1012
|
+
* the peer advertises the address.
|
|
1013
|
+
* - Removing the LAST candidate throws {@link LastCandidateHostError} and
|
|
1014
|
+
* writes NOTHING — no host, no scheme, no port, no mark.
|
|
1015
|
+
* - A call that changes no byte (the host was already forgotten) does not
|
|
1016
|
+
* rewrite (or fsync) the archive.
|
|
1017
|
+
*
|
|
1018
|
+
* The write goes through `#persist` on a CLONE with the result re-validated,
|
|
1019
|
+
* so a caller-supplied junk host can never land a record the next read would
|
|
1020
|
+
* refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
|
|
1021
|
+
*/
|
|
1022
|
+
forgetEndpoints(deviceId: string, hosts: readonly string[]): { removed: string[]; marked: string[] } {
|
|
1023
|
+
const devices = this.#load()
|
|
1024
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
1025
|
+
if (index < 0) return { removed: [], marked: [] }
|
|
1026
|
+
const merged = structuredClone(devices[index]!)
|
|
1027
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()))
|
|
1028
|
+
const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()))
|
|
1029
|
+
if (removed.length > 0 && removed.length === merged.hosts.length) {
|
|
1030
|
+
throw new LastCandidateHostError(deviceId)
|
|
1031
|
+
}
|
|
1032
|
+
for (const host of removed) {
|
|
1033
|
+
delete merged.security[host]
|
|
1034
|
+
delete merged.ports[host]
|
|
1035
|
+
}
|
|
1036
|
+
merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()))
|
|
1037
|
+
const forgotten = merged.forgottenHosts ?? []
|
|
1038
|
+
const known = new Set(forgotten.map((host) => host.toLowerCase()))
|
|
1039
|
+
const marked: string[] = []
|
|
1040
|
+
for (const host of hosts) {
|
|
1041
|
+
const key = host.toLowerCase()
|
|
1042
|
+
if (known.has(key)) continue
|
|
1043
|
+
const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host
|
|
1044
|
+
forgotten.push(spelling)
|
|
1045
|
+
known.add(key)
|
|
1046
|
+
marked.push(spelling)
|
|
1047
|
+
}
|
|
1048
|
+
if (removed.length === 0 && marked.length === 0) return { removed: [], marked: [] }
|
|
1049
|
+
merged.forgottenHosts = forgotten
|
|
1050
|
+
assertValidRecord(merged)
|
|
1051
|
+
const next = [...devices]
|
|
1052
|
+
next[index] = merged
|
|
1053
|
+
this.#persist(next)
|
|
1054
|
+
return { removed, marked }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Lifts a forget mark (issue #185), returning the marks that were removed.
|
|
1059
|
+
*
|
|
1060
|
+
* It does NOT dial the host again: it only lets the next handshake's
|
|
1061
|
+
* advertisement add it back through the ordinary learning path, with the
|
|
1062
|
+
* port and scheme the peer reports at that moment. An operator who wants the
|
|
1063
|
+
* address back immediately PATCHes it into `hosts`, which clears the mark on
|
|
1064
|
+
* the same write.
|
|
1065
|
+
*
|
|
1066
|
+
* No-op (and no write) when the device is unknown, nothing is forgotten, or
|
|
1067
|
+
* none of the named hosts is marked.
|
|
1068
|
+
*/
|
|
1069
|
+
rememberEndpoints(deviceId: string, hosts: readonly string[]): { restored: string[] } {
|
|
1070
|
+
const devices = this.#load()
|
|
1071
|
+
const index = devices.findIndex((record) => record.deviceId === deviceId)
|
|
1072
|
+
if (index < 0) return { restored: [] }
|
|
1073
|
+
const current = devices[index]!.forgottenHosts ?? []
|
|
1074
|
+
if (current.length === 0) return { restored: [] }
|
|
1075
|
+
const wanted = new Set(hosts.map((host) => host.toLowerCase()))
|
|
1076
|
+
const restored = current.filter((host) => wanted.has(host.toLowerCase()))
|
|
1077
|
+
if (restored.length === 0) return { restored: [] }
|
|
1078
|
+
const merged = structuredClone(devices[index]!)
|
|
1079
|
+
merged.forgottenHosts = (merged.forgottenHosts ?? []).filter(
|
|
1080
|
+
(host) => !wanted.has(host.toLowerCase()),
|
|
1081
|
+
)
|
|
1082
|
+
const next = [...devices]
|
|
1083
|
+
next[index] = merged
|
|
1084
|
+
this.#persist(next)
|
|
1085
|
+
return { restored }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
578
1088
|
#load(): DeviceRecord[] {
|
|
579
1089
|
if (this.#devices !== undefined) return this.#devices
|
|
580
1090
|
|
|
@@ -587,6 +1097,8 @@ export class DeviceStore {
|
|
|
587
1097
|
// `list`/`resolve` keep working (RF4's spirit — a corrupt archive never
|
|
588
1098
|
// takes the plugin down), but nothing may write over the evidence.
|
|
589
1099
|
this.#quarantine = error.withCorruptPath(quarantine(error.file))
|
|
1100
|
+
// LOCAL fs/parse cause, never peer text (issue #161 audit): the object
|
|
1101
|
+
// print keeps the cause's stack, which is the useful part here.
|
|
590
1102
|
console.warn(
|
|
591
1103
|
`device archive ${this.#file} is unreadable and was quarantined; serving an empty ` +
|
|
592
1104
|
`read-only archive and refusing all writes until the bad file is moved aside:`,
|
package/src/diagnostics.ts
CHANGED
|
@@ -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
|
]
|