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/README.en.md +11 -4
- package/README.md +10 -4
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +906 -144
- package/docs/e2e-checklist.md +2 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -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 +181 -14
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
}
|
package/src/deviceStore.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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:`,
|
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
|
]
|