dsh-cherry-bridge 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +12 -5
- package/README.md +11 -5
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +1363 -170
- package/docs/e2e-checklist.md +9 -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 +803 -56
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
- 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/dist/index.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { WebSocket } from "ws";
|
|
8
|
-
import { jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema,
|
|
8
|
+
import { directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
9
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
9
10
|
const FILE_MODE = 384;
|
|
10
11
|
/**
|
|
11
12
|
* Version-to-version migrations, keyed by the version they upgrade from.
|
|
@@ -13,15 +14,70 @@ const FILE_MODE = 384;
|
|
|
13
14
|
* `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
|
|
14
15
|
* real schema evolution starts at version 2 after release. A v1 archive is the
|
|
15
16
|
* current field set minus `schemaVersion`.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
*
|
|
18
|
+
* `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
|
|
19
|
+
* one `port` and no scheme at all, so every host is backfilled `ws` — the
|
|
20
|
+
* plain dial v2 always used. An archive that already carries SOME entries
|
|
21
|
+
* (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
|
|
22
|
+
* record remembers is never overwritten by the default, because that would
|
|
23
|
+
* downgrade a TLS-proxied desktop to a dial that cannot connect.
|
|
24
|
+
*
|
|
25
|
+
* `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
|
|
26
|
+
* shared `port` (the winner candidate's), so the step backfills `ports` from
|
|
27
|
+
* `port` for every host — the only one v3 ever dialed. A half-migrated file
|
|
28
|
+
* keeps the entries it already carries, for the same reason the scheme step
|
|
29
|
+
* does: a remembered port is the address the pairing actually used, and
|
|
30
|
+
* overwriting it would point the host at the winner's port again.
|
|
31
|
+
*
|
|
32
|
+
* **A legacy file is read loosely, this build's own output strictly —
|
|
33
|
+
* deliberately.** Each step fills its map for the CURRENT host list and
|
|
34
|
+
* nothing else: an entry naming a host the older file no longer lists is
|
|
35
|
+
* silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
|
|
36
|
+
* degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
|
|
37
|
+
* port VALUE is not repaired here at all — the migrated entry then continues
|
|
38
|
+
* down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
|
|
39
|
+
* archive. That split is the point: a v2 or v3 file is a supported legacy shape
|
|
40
|
+
* and is normalised into one, while a v4 file is this build's own output and
|
|
41
|
+
* any deviation from it is a hand edit worth failing loudly on.
|
|
42
|
+
*/
|
|
43
|
+
const MIGRATIONS = {
|
|
44
|
+
1: (record) => ({
|
|
45
|
+
...record,
|
|
46
|
+
schemaVersion: 2
|
|
47
|
+
}),
|
|
48
|
+
2: (record) => ({
|
|
49
|
+
...record,
|
|
50
|
+
schemaVersion: 3,
|
|
51
|
+
security: backfilledSecurity(record)
|
|
52
|
+
}),
|
|
53
|
+
3: (record) => ({
|
|
54
|
+
...record,
|
|
55
|
+
schemaVersion: 4,
|
|
56
|
+
ports: backfilledPorts(record)
|
|
57
|
+
})
|
|
58
|
+
};
|
|
59
|
+
/** Every host of a v2 record, defaulting anything it did not already say. */
|
|
60
|
+
function backfilledSecurity(disk) {
|
|
61
|
+
const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
|
|
62
|
+
const existing = disk.security ?? {};
|
|
63
|
+
return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : "ws"]));
|
|
64
|
+
}
|
|
65
|
+
/** Every host of a v3 record, defaulting anything it did not already say. */
|
|
66
|
+
function backfilledPorts(disk) {
|
|
67
|
+
const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
|
|
68
|
+
const existing = disk.ports ?? {};
|
|
69
|
+
const fallback = disk.port;
|
|
70
|
+
return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : fallback]));
|
|
71
|
+
}
|
|
21
72
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
73
|
+
* The field set of a v1 entry: what the store wrote before it carried a
|
|
74
|
+
* version at all.
|
|
75
|
+
*
|
|
76
|
+
* FROZEN. It must not track the current record — a v1 archive predates every
|
|
77
|
+
* later field, so demanding one here (as `'security'` was, briefly) fails the
|
|
78
|
+
* completeness gate on an archive that is perfectly intact and quarantines it
|
|
79
|
+
* instead of migrating it. Later fields are the migration chain's business
|
|
80
|
+
* (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
|
|
25
81
|
*/
|
|
26
82
|
const V1_KEYS = [
|
|
27
83
|
"deviceId",
|
|
@@ -32,7 +88,18 @@ const V1_KEYS = [
|
|
|
32
88
|
"grants",
|
|
33
89
|
"enabled"
|
|
34
90
|
];
|
|
35
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
|
|
93
|
+
* this is not a historical snapshot: it grows with the record, and it is what
|
|
94
|
+
* `upsert` and the post-migration exit demand (which is why a v4 entry without
|
|
95
|
+
* `security`/`ports` never reaches its own validator).
|
|
96
|
+
*/
|
|
97
|
+
const REQUIRED_KEYS = [
|
|
98
|
+
...V1_KEYS,
|
|
99
|
+
"security",
|
|
100
|
+
"ports",
|
|
101
|
+
"schemaVersion"
|
|
102
|
+
];
|
|
36
103
|
/**
|
|
37
104
|
* A version-less entry reads as the legacy (v1) shape only when every v1 field
|
|
38
105
|
* is present. A missing `schemaVersion` alone means v1; a missing
|
|
@@ -44,14 +111,61 @@ function hasCompleteFieldSet(disk) {
|
|
|
44
111
|
}
|
|
45
112
|
/** Asserts the required `DeviceRecord` shape. The read path's exit. */
|
|
46
113
|
function assertValidRecord(disk) {
|
|
47
|
-
if (disk.schemaVersion !==
|
|
114
|
+
if (disk.schemaVersion !== 4) throw new Error(`device archive entry has unsupported schemaVersion ${String(disk.schemaVersion)}; expected 4`);
|
|
48
115
|
const missing = REQUIRED_KEYS.filter((key) => disk[key] === void 0);
|
|
49
116
|
if (missing.length > 0) throw new Error(`device archive entry is missing required field(s): ${missing.join(", ")}`);
|
|
50
117
|
if (!Array.isArray(disk.hosts)) throw new Error("device archive entry field hosts must be an array");
|
|
118
|
+
if (typeof disk.port !== "number" || !Number.isInteger(disk.port) || disk.port < 1 || disk.port > 65535) throw new Error("device archive entry field port must be an integer in 1..65535");
|
|
119
|
+
assertValidSecurity(disk.hosts, disk.security);
|
|
120
|
+
assertValidPorts(disk.hosts, disk.ports);
|
|
51
121
|
if (disk.accessToken !== void 0 !== (disk.tokenExpiresAt !== void 0)) throw new Error("device archive entry must carry accessToken and tokenExpiresAt together or not at all");
|
|
52
122
|
return disk;
|
|
53
123
|
}
|
|
54
124
|
/**
|
|
125
|
+
* The `security` map must be EXACTLY one entry per host, each a known scheme.
|
|
126
|
+
*
|
|
127
|
+
* Keyed by host, so a record can carry an entry the dial will never consult
|
|
128
|
+
* (a host it no longer lists) and a host the dial will fall back on silently.
|
|
129
|
+
* Both are hand-edited-archive states, and both are rejected rather than
|
|
130
|
+
* repaired in memory: the archive a human edits is the archive the store
|
|
131
|
+
* judges, and a silent repair would hide the very mistake. The message names
|
|
132
|
+
* the offending HOSTS (they are addresses an operator chose, not secrets).
|
|
133
|
+
*/
|
|
134
|
+
function assertValidSecurity(hosts, security) {
|
|
135
|
+
if (security === void 0) return;
|
|
136
|
+
if (security === null || typeof security !== "object" || Array.isArray(security)) throw new Error("device archive entry field security must be an object keyed by host");
|
|
137
|
+
const map = security;
|
|
138
|
+
const wanted = new Set(hosts);
|
|
139
|
+
const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
|
|
140
|
+
if (unknownHost !== void 0) throw new Error(`device archive entry field security names host "${unknownHost}", which is not in hosts`);
|
|
141
|
+
for (const host of hosts) {
|
|
142
|
+
const scheme = map[host];
|
|
143
|
+
if (scheme !== "ws" && scheme !== "wss") throw new Error(`device archive entry field security["${host}"] must be "ws" or "wss"`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The `ports` map must be EXACTLY one entry per host, each an integer port.
|
|
148
|
+
*
|
|
149
|
+
* The same reasoning as {@link assertValidSecurity}: an entry for a host the
|
|
150
|
+
* record no longer lists would never be consulted, and a missing one would
|
|
151
|
+
* leave that host on the record-level (winner's) port — the exact failure the
|
|
152
|
+
* map exists to prevent. Both are rejected rather than repaired. The
|
|
153
|
+
* message names the offending HOST, never the value (a port is not a secret, but
|
|
154
|
+
* echoing a junk value back from a hand-edited file adds nothing actionable).
|
|
155
|
+
*/
|
|
156
|
+
function assertValidPorts(hosts, ports) {
|
|
157
|
+
if (ports === void 0) return;
|
|
158
|
+
if (ports === null || typeof ports !== "object" || Array.isArray(ports)) throw new Error("device archive entry field ports must be an object keyed by host");
|
|
159
|
+
const map = ports;
|
|
160
|
+
const wanted = new Set(hosts);
|
|
161
|
+
const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
|
|
162
|
+
if (unknownHost !== void 0) throw new Error(`device archive entry field ports names host "${unknownHost}", which is not in hosts`);
|
|
163
|
+
for (const host of hosts) {
|
|
164
|
+
const port = map[host];
|
|
165
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`device archive entry field ports["${host}"] must be an integer in 1..65535`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
55
169
|
* Read path, fixed order: loose parse -> completeness gate -> version judgement
|
|
56
170
|
* -> migration -> assert the required `DeviceRecord` type.
|
|
57
171
|
*
|
|
@@ -74,7 +188,7 @@ function parseRecord(raw, archiveVersion) {
|
|
|
74
188
|
if (declared === void 0 && archiveVersion > 1) throw new Error(`device archive entry has no schemaVersion but its archive is schemaVersion ${archiveVersion}; legacy (v1) entries are only valid inside a schemaVersion-1 archive`);
|
|
75
189
|
if (declared !== void 0 && declared !== archiveVersion) throw new Error(`device archive entry declares schemaVersion ${declared} but its archive is schemaVersion ${archiveVersion}; the archive version is authoritative`);
|
|
76
190
|
let migrated = disk;
|
|
77
|
-
for (let step = archiveVersion; step <
|
|
191
|
+
for (let step = archiveVersion; step < 4; step += 1) {
|
|
78
192
|
const migrate = MIGRATIONS[step];
|
|
79
193
|
if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
|
|
80
194
|
migrated = migrate(migrated);
|
|
@@ -85,7 +199,7 @@ function parseArchive(parsed) {
|
|
|
85
199
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
|
|
86
200
|
const archive = parsed;
|
|
87
201
|
if (typeof archive.schemaVersion !== "number") throw new Error("device archive is missing a numeric schemaVersion");
|
|
88
|
-
if (archive.schemaVersion >
|
|
202
|
+
if (archive.schemaVersion > 4) throw new Error(`device archive declares schemaVersion ${archive.schemaVersion}, newer than supported 4; refusing to read it`);
|
|
89
203
|
if (!Array.isArray(archive.devices)) throw new Error("device archive is missing a devices array");
|
|
90
204
|
const archiveVersion = archive.schemaVersion;
|
|
91
205
|
return {
|
|
@@ -95,7 +209,7 @@ function parseArchive(parsed) {
|
|
|
95
209
|
}
|
|
96
210
|
function serializeArchive(devices) {
|
|
97
211
|
return `${JSON.stringify({
|
|
98
|
-
schemaVersion:
|
|
212
|
+
schemaVersion: 4,
|
|
99
213
|
devices
|
|
100
214
|
}, null, 2)}\n`;
|
|
101
215
|
}
|
|
@@ -376,7 +490,7 @@ var DeviceStore = class {
|
|
|
376
490
|
}
|
|
377
491
|
const devices = archive === void 0 ? [] : archive.devices;
|
|
378
492
|
this.#devices = devices;
|
|
379
|
-
if (archive !== void 0 && archive.schemaVersion <
|
|
493
|
+
if (archive !== void 0 && archive.schemaVersion < 4) this.#persist(devices);
|
|
380
494
|
return devices;
|
|
381
495
|
}
|
|
382
496
|
#persist(devices) {
|
|
@@ -471,6 +585,124 @@ async function generateIdentity(dataDir, path) {
|
|
|
471
585
|
return parseIdentity(key);
|
|
472
586
|
}
|
|
473
587
|
//#endregion
|
|
588
|
+
//#region src/text.ts
|
|
589
|
+
/**
|
|
590
|
+
* Bounded rendering for peer- and payload-controlled text (issue #155).
|
|
591
|
+
*
|
|
592
|
+
* The ONE helper this audit STANDARDISED ON — not the first attempt at the
|
|
593
|
+
* idea: `src/diagnostics.ts`'s `truncateErrorMessage` (the diagnostics error
|
|
594
|
+
* digest) and `src/rpc/jsonRpcClient.ts`'s `bounded()` (the frame-drop
|
|
595
|
+
* warning's classification fields) predate it and stay where they are, for
|
|
596
|
+
* their own callers. #155 is where the rendering rule was stated once and
|
|
597
|
+
* applied to the SIX sites the audit found — four files: pairing's unknown-key
|
|
598
|
+
* warning and stage verdict, the link-recovery line, the two connection error
|
|
599
|
+
* lines, and the JSON-RPC notification-handler line. Each was unbounded for a
|
|
600
|
+
* different "why not" (a 10 KiB payload cap, a hello-advertised field, the
|
|
601
|
+
* 64 KiB record budget), which is exactly how the class of defect recurs: each
|
|
602
|
+
* site answered the question for itself.
|
|
603
|
+
*
|
|
604
|
+
* The rule these helpers establish is stated once, here: bound the text where
|
|
605
|
+
* it is RENDERED (never where it is carried — an error message is a value
|
|
606
|
+
* until a log line or a verdict makes it text), name the amount removed with
|
|
607
|
+
* the `… (+N chars)` marker, and never drop a list ENTRY silently (the
|
|
608
|
+
* `(+K more)` count is explicit).
|
|
609
|
+
*
|
|
610
|
+
* Issue #161 swept the SAME SHAPE at the sites #155 did not name — a catch
|
|
611
|
+
* handing an error OBJECT to `console.warn`'s second argument — on the paths
|
|
612
|
+
* PEER traffic reaches. One renderer states the answer there, {@link
|
|
613
|
+
* failureText}: the message alone, bounded. A purely LOCAL catch keeps the
|
|
614
|
+
* object print, deliberately: its stack is the useful part and no peer text
|
|
615
|
+
* can reach it (fs quarantine, the host registry, our own listener / settle
|
|
616
|
+
* fan-out, a REST route's synchronous construction). The two earlier forms it
|
|
617
|
+
* did NOT absorb — `truncateErrorMessage` (the diagnostics digest) and
|
|
618
|
+
* `bounded()` (the frame-drop path) — are left alone: each emits a different
|
|
619
|
+
* marker (`…[truncated]`, a bare `…`) that callers and docs depend on, so
|
|
620
|
+
* folding would change output rather than converge on the rule.
|
|
621
|
+
*/
|
|
622
|
+
/**
|
|
623
|
+
* Clips `text` into `max` characters, the `… (+N chars)` marker included in
|
|
624
|
+
* the count. `kept` is a fixed point — the number of removed characters sets
|
|
625
|
+
* the marker's own digits — and it is the LARGEST prefix that fits, so a
|
|
626
|
+
* caller's ceiling is used without ever being exceeded.
|
|
627
|
+
*
|
|
628
|
+
* The final guard never ends the kept prefix between a UTF-16 surrogate pair: a
|
|
629
|
+
* clip there would emit a lone surrogate, which is not valid unicode text (the
|
|
630
|
+
* peer's `unicodeText` params face refuses it) and would travel in a
|
|
631
|
+
* user-visible verdict. Dropping one char pays for a marker that can grow by
|
|
632
|
+
* one digit at a 9→10 boundary, and the marker is re-derived from the new
|
|
633
|
+
* dropped count. Pinned by `tests/unit/text.test.ts` — the branch used to be a
|
|
634
|
+
* declared coverage gap while the clip point was private to pairing.
|
|
635
|
+
*/
|
|
636
|
+
function clipText(text, max) {
|
|
637
|
+
if (text.length <= max) return text;
|
|
638
|
+
let kept = max;
|
|
639
|
+
for (;;) {
|
|
640
|
+
const next = max - `… (+${text.length - kept} chars)`.length;
|
|
641
|
+
if (next === kept) break;
|
|
642
|
+
if (next < 0) return "…".slice(0, max);
|
|
643
|
+
kept = next;
|
|
644
|
+
}
|
|
645
|
+
const before = text.charCodeAt(kept - 1);
|
|
646
|
+
const after = text.charCodeAt(kept);
|
|
647
|
+
if (kept > 0 && before >= 55296 && before <= 56319 && after >= 56320 && after <= 57343) kept -= 1;
|
|
648
|
+
return `${text.slice(0, kept)}… (+${text.length - kept} chars)`;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Renders `items` as a comma-joined list under `limits.max`, each item clipped
|
|
652
|
+
* to `limits.itemMax` FIRST. When anything is left out the list says so:
|
|
653
|
+
* ` (+K more)` where K is the number of items NOT named.
|
|
654
|
+
*
|
|
655
|
+
* Caller contract: `itemMax <= max`, so the first item always fits and a list
|
|
656
|
+
* carrying a single over-long entry never renders an empty one. The marker
|
|
657
|
+
* rides ON TOP of `limits.max` — it is a count, not an item, so the named text
|
|
658
|
+
* never has to absorb its length. `[]` renders as `''`.
|
|
659
|
+
*
|
|
660
|
+
* Pairing's progress line (`stageHosts`) deliberately does NOT use this helper:
|
|
661
|
+
* it always shows the FIRST host in full even above its own budget, because
|
|
662
|
+
* that host is the run's identity. That is not an oversight, and it is not a
|
|
663
|
+
* reason to weaken the contract here.
|
|
664
|
+
*/
|
|
665
|
+
function boundedJoin(items, limits) {
|
|
666
|
+
const rendered = items.map((item) => clipText(item, limits.itemMax));
|
|
667
|
+
const first = rendered[0];
|
|
668
|
+
if (first === void 0) return "";
|
|
669
|
+
let text = first;
|
|
670
|
+
let shown = 1;
|
|
671
|
+
for (; shown < rendered.length; shown += 1) {
|
|
672
|
+
if (text.length + 2 + rendered[shown].length > limits.max) break;
|
|
673
|
+
text += `, ${rendered[shown]}`;
|
|
674
|
+
}
|
|
675
|
+
const omitted = items.length - shown;
|
|
676
|
+
return omitted > 0 ? `${text} (+${omitted} more)` : text;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Renders ONE caught failure for a log line: the message alone, clipped to
|
|
680
|
+
* {@link LOG_TEXT_MAX}.
|
|
681
|
+
*
|
|
682
|
+
* This is the ONE form a caught failure takes in a PEER-TRIGGERED catch (issue
|
|
683
|
+
* #161): a catch reached from peer traffic or a peer-triggered callback. The
|
|
684
|
+
* old shape — `console.warn(prefix, error)` — handed the console the OBJECT.
|
|
685
|
+
* On Node the console prints the error's name, message and stack (own
|
|
686
|
+
* properties only when inspected), so the peer-controlled message and the
|
|
687
|
+
* stack were both unbounded; for a defensive catch the stack is the noise.
|
|
688
|
+
*
|
|
689
|
+
* A caught failure from a purely LOCAL path (an fs copy, the host registry, our
|
|
690
|
+
* own observers) keeps the object print: its stack is the useful part and no
|
|
691
|
+
* peer text can reach it.
|
|
692
|
+
*
|
|
693
|
+
* TOTAL by construction: it runs INSIDE catch blocks, so a captured value whose
|
|
694
|
+
* `toString` (or an Error subclass whose `message` getter) throws must not
|
|
695
|
+
* escape the catch. The extraction is guarded and falls back to the fixed token
|
|
696
|
+
* `<unrenderable failure>`.
|
|
697
|
+
*/
|
|
698
|
+
function failureText(error) {
|
|
699
|
+
try {
|
|
700
|
+
return clipText(error instanceof Error ? error.message : String(error), 240);
|
|
701
|
+
} catch {
|
|
702
|
+
return "<unrenderable failure>";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
474
706
|
//#region src/rpc/jsonRpcClient.ts
|
|
475
707
|
/** Default cap on business requests in flight on a single connection. */
|
|
476
708
|
const DEFAULT_MAX_IN_FLIGHT = 8;
|
|
@@ -896,7 +1128,7 @@ var JsonRpcClient = class {
|
|
|
896
1128
|
for (const handler of [...this.handlers]) try {
|
|
897
1129
|
handler(method, params);
|
|
898
1130
|
} catch (error) {
|
|
899
|
-
console.warn(`[cherry-remote] notification handler for "${method}" threw
|
|
1131
|
+
console.warn(`[cherry-remote] notification handler for "${bounded(method)}" threw: ${failureText(error)}`);
|
|
900
1132
|
}
|
|
901
1133
|
return;
|
|
902
1134
|
}
|
|
@@ -1887,12 +2119,15 @@ function sanitizeDevice(record, state, limits) {
|
|
|
1887
2119
|
hosts: [...record.hosts],
|
|
1888
2120
|
port: record.port,
|
|
1889
2121
|
desktopIdentity: digestIdentity(record.desktopIdentity),
|
|
1890
|
-
...limits === void 0 ? {} : {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2122
|
+
...limits === void 0 ? {} : {
|
|
2123
|
+
limits: {
|
|
2124
|
+
heartbeatMs: limits.heartbeatMs,
|
|
2125
|
+
subscriptions: limits.subscriptions,
|
|
2126
|
+
pageItems: limits.pageItems,
|
|
2127
|
+
inFlightRequests: limits.inFlightRequests
|
|
2128
|
+
},
|
|
2129
|
+
...limits.messageSummaries === void 0 ? {} : { messageSummaries: limits.messageSummaries }
|
|
2130
|
+
}
|
|
1896
2131
|
};
|
|
1897
2132
|
}
|
|
1898
2133
|
/** Narrows an unknown bag to the report's own shapes, dropping everything else. */
|
|
@@ -1907,10 +2142,20 @@ function projectDevice(raw) {
|
|
|
1907
2142
|
hosts: Array.isArray(value["hosts"]) ? value["hosts"].map((host) => String(host)) : [],
|
|
1908
2143
|
port: typeof value["port"] === "number" ? value["port"] : 0,
|
|
1909
2144
|
desktopIdentity: desktopIdentity === "unknown" || /^[0-9a-f]{12}$/.test(desktopIdentity) ? desktopIdentity : digestIdentity(desktopIdentity),
|
|
1910
|
-
...projectLimits(value["limits"])
|
|
2145
|
+
...projectLimits(value["limits"]),
|
|
2146
|
+
...projectMessageSummaries(value["messageSummaries"])
|
|
1911
2147
|
};
|
|
1912
2148
|
}
|
|
1913
2149
|
/**
|
|
2150
|
+
* Re-projects the ONE capability field, dropping anything that is not a finite
|
|
2151
|
+
* number: a version this report cannot read is reported as absent (not
|
|
2152
|
+
* advertised), which is the safe side — the field only ever amplifies what the
|
|
2153
|
+
* peer's hello said.
|
|
2154
|
+
*/
|
|
2155
|
+
function projectMessageSummaries(raw) {
|
|
2156
|
+
return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
1914
2159
|
* Re-projects the four numeric limit keys, dropping any foreign key that a
|
|
1915
2160
|
* caller's `limits` object might carry. A whitelist inside the whitelist: the
|
|
1916
2161
|
* shape is rebuilt field by field rather than spread, so a future addition to
|
|
@@ -2305,21 +2550,6 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
|
|
|
2305
2550
|
*/
|
|
2306
2551
|
const MAX_RESUME_TOKEN_CHARS = 4096;
|
|
2307
2552
|
//#endregion
|
|
2308
|
-
//#region src/urlHost.ts
|
|
2309
|
-
/**
|
|
2310
|
-
* Renders a host as a URL authority component. The device archive and pairing
|
|
2311
|
-
* flow store BARE hosts (plan-mandated normalization), so a bare IPv6 literal
|
|
2312
|
-
* must be bracketed before it is spliced into a URL — `ws://::1:35543/...`
|
|
2313
|
-
* is not a valid URL and `new WebSocket(...)` throws synchronously on it.
|
|
2314
|
-
*
|
|
2315
|
-
* Shared home for the dial sites: both `connection.ts` (per-device dial) and
|
|
2316
|
-
* `pairing.ts` (candidate dial) splice hosts into URLs and must bracket the
|
|
2317
|
-
* same way.
|
|
2318
|
-
*/
|
|
2319
|
-
function urlHost(host) {
|
|
2320
|
-
return host.includes(":") ? `[${host}]` : host;
|
|
2321
|
-
}
|
|
2322
|
-
//#endregion
|
|
2323
2553
|
//#region src/connection.ts
|
|
2324
2554
|
/**
|
|
2325
2555
|
* The transport call the dial hands a socket to (see
|
|
@@ -2818,8 +3048,10 @@ var DeviceConnection = class {
|
|
|
2818
3048
|
#pendingPings = /* @__PURE__ */ new Set();
|
|
2819
3049
|
#token;
|
|
2820
3050
|
#tokenExpiresAt;
|
|
3051
|
+
#wsOptions;
|
|
2821
3052
|
constructor(opts) {
|
|
2822
3053
|
this.record = opts.record;
|
|
3054
|
+
this.#wsOptions = opts.wsOptions;
|
|
2823
3055
|
this.#identity = opts.identity;
|
|
2824
3056
|
this.#limiter = new TieredRateLimiter(opts.rateBudgetPerSecond ?? 32, contentBudgetFor(opts.rateBudgetPerSecond));
|
|
2825
3057
|
this.#backoffInitialMs = opts.backoffInitialMs ?? DEFAULT_BACKOFF_INITIAL_MS;
|
|
@@ -2997,7 +3229,7 @@ var DeviceConnection = class {
|
|
|
2997
3229
|
if (error instanceof ProtocolVersionError) {
|
|
2998
3230
|
this.#running = false;
|
|
2999
3231
|
this.#setState("offline");
|
|
3000
|
-
console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${error.message}); upgrade Cherry Studio / the plugin`);
|
|
3232
|
+
console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${clipText(error.message, 240)}); upgrade Cherry Studio / the plugin`);
|
|
3001
3233
|
this.#cycle.reject(/* @__PURE__ */ new Error(`device ${this.record.deviceId}: protocol version incompatible`));
|
|
3002
3234
|
return;
|
|
3003
3235
|
}
|
|
@@ -3015,7 +3247,8 @@ var DeviceConnection = class {
|
|
|
3015
3247
|
if (!this.#running) return;
|
|
3016
3248
|
const delay = this.#backoffDelay();
|
|
3017
3249
|
const hint = isAllHostsUnreachable(error) ? " — all candidate addresses were unreachable; if the desktop moved, update them via the device card 地址维护 (address maintenance)" : "";
|
|
3018
|
-
|
|
3250
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3251
|
+
console.warn(`[cherry-remote] device ${this.record.deviceId} offline (${clipText(detail, 240)}); reconnecting in ${delay}ms${hint}`);
|
|
3019
3252
|
this.#setTimer("backoff", setTimeout(() => this.#tryEstablish(), delay));
|
|
3020
3253
|
}
|
|
3021
3254
|
#backoffDelay() {
|
|
@@ -3029,6 +3262,12 @@ var DeviceConnection = class {
|
|
|
3029
3262
|
* network failures fall through to the next candidate — the §6
|
|
3030
3263
|
* 「候选地址全部不可达」 verdict only holds when EVERY host failed.
|
|
3031
3264
|
*
|
|
3265
|
+
* Each candidate dials with ITS OWN port from `record.ports`; the record-level
|
|
3266
|
+
* `port` (the winner's at pairing, the operator's after a PATCH) is the
|
|
3267
|
+
* fallback for an in-memory record assembled without the map (issue #135).
|
|
3268
|
+
* Before that, one port was shared by every host, so a reconnect to a
|
|
3269
|
+
* non-winner host dialed the right scheme at the wrong port.
|
|
3270
|
+
*
|
|
3032
3271
|
* "Per-host" covers the CHANNEL-LEVEL handshake failures as well as the dial:
|
|
3033
3272
|
* a socket that opens and then dies mid-handshake (the peer accepted the TCP
|
|
3034
3273
|
* connection and went away, a proxy reset it, the Noise exchange was cut) is a
|
|
@@ -3046,7 +3285,8 @@ var DeviceConnection = class {
|
|
|
3046
3285
|
if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
|
|
3047
3286
|
let link;
|
|
3048
3287
|
try {
|
|
3049
|
-
|
|
3288
|
+
const port = this.record.ports?.[host] ?? this.record.port;
|
|
3289
|
+
link = await this.#dial(host, port, epoch);
|
|
3050
3290
|
} catch (error) {
|
|
3051
3291
|
if (error instanceof IdentityMismatchError) throw error;
|
|
3052
3292
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -3067,8 +3307,11 @@ var DeviceConnection = class {
|
|
|
3067
3307
|
throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
|
|
3068
3308
|
}
|
|
3069
3309
|
async #dial(host, port, epoch) {
|
|
3070
|
-
const
|
|
3071
|
-
|
|
3310
|
+
const ws = new WebSocket(directEndpointUrl({
|
|
3311
|
+
host,
|
|
3312
|
+
port,
|
|
3313
|
+
security: this.record.security?.[host] ?? "ws"
|
|
3314
|
+
}), this.#wsOptions);
|
|
3072
3315
|
const controller = new AbortController();
|
|
3073
3316
|
const opened = new Promise((resolve) => ws.once("open", () => resolve()));
|
|
3074
3317
|
const failure = new Promise((_, reject) => {
|
|
@@ -3133,7 +3376,8 @@ var DeviceConnection = class {
|
|
|
3133
3376
|
heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
3134
3377
|
subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
|
|
3135
3378
|
pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
|
|
3136
|
-
inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests
|
|
3379
|
+
inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
3380
|
+
...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
|
|
3137
3381
|
};
|
|
3138
3382
|
link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
|
|
3139
3383
|
let auth;
|
|
@@ -3498,24 +3742,56 @@ var PairingError = class extends Error {
|
|
|
3498
3742
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
3499
3743
|
}
|
|
3500
3744
|
};
|
|
3501
|
-
const PAYLOAD_TYPE = "cherry-studio-pair";
|
|
3502
|
-
const PAYLOAD_VERSION = 2;
|
|
3503
3745
|
/** Overall payload ceiling: a QR code never approaches this. */
|
|
3504
3746
|
const MAX_PAYLOAD_BYTES = 10240;
|
|
3505
|
-
/**
|
|
3506
|
-
|
|
3747
|
+
/**
|
|
3748
|
+
* Bounds the unknown-key WARNING (issue #155). The keys in that warning are
|
|
3749
|
+
* payload-CONTROLLED NAMES, and `MAX_PAYLOAD_BYTES` is no bound at all on the
|
|
3750
|
+
* line: a 10 KiB payload carrying 32 unknown keys of ~256 characters used to
|
|
3751
|
+
* join into ~8.2 KB of attacker-chosen text (the measured fixture is 8,192
|
|
3752
|
+
* characters of keys and an 8,312-character line) on the operator's log.
|
|
3753
|
+
*
|
|
3754
|
+
* `UNKNOWN_FIELDS_MAX` is the joined list's budget — the same 240-character
|
|
3755
|
+
* working budget as `LOG_TEXT_MAX`, so "one bounded peer detail" means one
|
|
3756
|
+
* number for one detail-sized face (pairing's `FAILURE_SEGMENT_MAX` is the
|
|
3757
|
+
* same NUMBER bounding a different quantity: a whole `<label>: <detail>`).
|
|
3758
|
+
* `UNKNOWN_FIELD_MAX` clips ONE
|
|
3759
|
+
* key: 64 characters is longer than any field name the protocol defines, so a
|
|
3760
|
+
* clipped key is always a malformed or hostile payload, never a real typo the
|
|
3761
|
+
* operator needs whole. A list that stops early names the count (`(+K more)`);
|
|
3762
|
+
* entries are never dropped silently.
|
|
3763
|
+
*/
|
|
3764
|
+
const UNKNOWN_FIELDS_MAX = 240;
|
|
3765
|
+
const UNKNOWN_FIELD_MAX = 64;
|
|
3507
3766
|
/** The claim's deviceName cap; a hostname may reach 253, so it is truncated. */
|
|
3508
3767
|
const DEVICE_NAME_MAX = 64;
|
|
3509
3768
|
/** Bounds a single candidate host string (IPv6 literals are ~45). */
|
|
3510
3769
|
const HOST_MAX$1 = 256;
|
|
3511
3770
|
/** Matches the protocol's unicodeText: no lone UTF-16 surrogates. */
|
|
3512
3771
|
const LONE_SURROGATE_RE$1 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
3513
|
-
/**
|
|
3514
|
-
|
|
3772
|
+
/**
|
|
3773
|
+
* The keys each QR ARM consumes (selected by `v`). A payload field outside
|
|
3774
|
+
* the MATCHED arm's set is ignored with a warning — the desktop names hosts
|
|
3775
|
+
* the design does not use. One union set would silently swallow a v2 payload
|
|
3776
|
+
* carrying a spurious `endpoints` (or v3 carrying `ips`/`port`); the arm
|
|
3777
|
+
* split keeps the pre-schema warning behavior.
|
|
3778
|
+
*/
|
|
3779
|
+
const V2_KEYS = /* @__PURE__ */ new Set([
|
|
3515
3780
|
"v",
|
|
3516
3781
|
"t",
|
|
3517
|
-
"
|
|
3782
|
+
"name",
|
|
3518
3783
|
"ips",
|
|
3784
|
+
"port",
|
|
3785
|
+
"invitationId",
|
|
3786
|
+
"invitationSecret",
|
|
3787
|
+
"desktopIdentity",
|
|
3788
|
+
"protocolVersions"
|
|
3789
|
+
]);
|
|
3790
|
+
const V3_KEYS = /* @__PURE__ */ new Set([
|
|
3791
|
+
"v",
|
|
3792
|
+
"t",
|
|
3793
|
+
"name",
|
|
3794
|
+
"endpoints",
|
|
3519
3795
|
"invitationId",
|
|
3520
3796
|
"invitationSecret",
|
|
3521
3797
|
"desktopIdentity",
|
|
@@ -3537,12 +3813,12 @@ function invalid(message) {
|
|
|
3537
3813
|
function isUnicodeText(value) {
|
|
3538
3814
|
return !LONE_SURROGATE_RE$1.test(value);
|
|
3539
3815
|
}
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
return
|
|
3816
|
+
/** Rejections quote issue PATHS (deduplicated, sorted). The "never inputs"
|
|
3817
|
+
* promise is the CALL SITE's property — only `issue.path` is read here; an
|
|
3818
|
+
* editor reaching for `issue.message` or `String(error)` must re-check what
|
|
3819
|
+
* zod embeds there before keeping that promise. */
|
|
3820
|
+
function schemaIssuePaths(error) {
|
|
3821
|
+
return [...new Set(error.issues.map((issue) => issue.path.join(".") || "payload"))].sort().join(", ");
|
|
3546
3822
|
}
|
|
3547
3823
|
/**
|
|
3548
3824
|
* Normalizes one candidate host (ruling 2). The QR may carry IPv6 literals in
|
|
@@ -3566,9 +3842,9 @@ function normalizeHost$1(raw) {
|
|
|
3566
3842
|
return inner;
|
|
3567
3843
|
}
|
|
3568
3844
|
/**
|
|
3569
|
-
* Parses and validates the pairing payload
|
|
3570
|
-
*
|
|
3571
|
-
*
|
|
3845
|
+
* Parses and validates the pairing payload against the published
|
|
3846
|
+
* `pairingQrSchema` (v2 or v3). Unknown fields are ignored (with a warning);
|
|
3847
|
+
* every field judgement is the schema's, capped by the local size rule.
|
|
3572
3848
|
*/
|
|
3573
3849
|
function parsePayload(payload) {
|
|
3574
3850
|
if (Buffer.byteLength(payload, "utf8") > MAX_PAYLOAD_BYTES) invalid(`payload exceeds the ${MAX_PAYLOAD_BYTES}-byte limit`);
|
|
@@ -3580,38 +3856,100 @@ function parsePayload(payload) {
|
|
|
3580
3856
|
}
|
|
3581
3857
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) invalid("payload must be a JSON object");
|
|
3582
3858
|
const raw = parsed;
|
|
3583
|
-
if (raw["v"] !== PAYLOAD_VERSION) invalid(`payload version must be ${PAYLOAD_VERSION}`);
|
|
3584
|
-
if (raw["t"] !== PAYLOAD_TYPE) invalid(`payload type must be "${PAYLOAD_TYPE}"`);
|
|
3585
|
-
const ignored = Object.keys(raw).filter((key) => !KNOWN_FIELDS.has(key));
|
|
3586
|
-
if (ignored.length > 0) console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${ignored.join(", ")}`);
|
|
3587
|
-
const port = raw["port"];
|
|
3588
|
-
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) invalid("payload field \"port\" must be an integer in 1..65535");
|
|
3589
3859
|
const ips = raw["ips"];
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
const
|
|
3595
|
-
|
|
3596
|
-
|
|
3860
|
+
const input = Array.isArray(ips) ? {
|
|
3861
|
+
...raw,
|
|
3862
|
+
ips: ips.map((entry) => typeof entry === "string" ? normalizeHost$1(entry) : entry)
|
|
3863
|
+
} : raw;
|
|
3864
|
+
const result = pairingQrSchema.safeParse(input);
|
|
3865
|
+
if (!result.success) invalid(`payload rejected by the pairing QR schema (v2 or v3): ${schemaIssuePaths(result.error)}`);
|
|
3866
|
+
const qr = result.data;
|
|
3867
|
+
const ignored = Object.keys(raw).filter((key) => !(qr.v === 2 ? V2_KEYS : V3_KEYS).has(key));
|
|
3868
|
+
if (ignored.length > 0) console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${boundedJoin(ignored, {
|
|
3869
|
+
max: UNKNOWN_FIELDS_MAX,
|
|
3870
|
+
itemMax: UNKNOWN_FIELD_MAX
|
|
3871
|
+
})}`);
|
|
3872
|
+
const candidates = qr.v === 2 ? qr.ips.map((host) => ({
|
|
3873
|
+
host,
|
|
3874
|
+
port: qr.port,
|
|
3875
|
+
security: "ws"
|
|
3876
|
+
})) : qr.endpoints.map((endpoint) => ({
|
|
3877
|
+
host: endpoint.host,
|
|
3878
|
+
port: endpoint.port,
|
|
3879
|
+
security: endpoint.security
|
|
3880
|
+
}));
|
|
3881
|
+
const seenHosts = /* @__PURE__ */ new Set();
|
|
3882
|
+
for (const candidate of candidates) {
|
|
3883
|
+
if (!Number.isInteger(candidate.port) || candidate.port < 1 || candidate.port > 65535) invalid(`payload endpoint "${candidate.host}": port must be an integer in 1..65535`);
|
|
3884
|
+
if (seenHosts.has(candidate.host)) invalid(`payload endpoints must not repeat host "${candidate.host}"`);
|
|
3885
|
+
seenHosts.add(candidate.host);
|
|
3886
|
+
}
|
|
3887
|
+
for (const field of [
|
|
3888
|
+
"invitationId",
|
|
3889
|
+
"invitationSecret",
|
|
3890
|
+
"desktopIdentity"
|
|
3891
|
+
]) if (!isUnicodeText(qr[field])) invalid(`payload field "${field}" is not valid unicode text`);
|
|
3892
|
+
try {
|
|
3893
|
+
peerIdFromString(qr.desktopIdentity);
|
|
3894
|
+
} catch {
|
|
3895
|
+
invalid("payload field \"desktopIdentity\" is not a peer id");
|
|
3896
|
+
}
|
|
3597
3897
|
return {
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
protocolVersions: versions.map((version) => {
|
|
3604
|
-
if (typeof version !== "number" || !Number.isInteger(version)) invalid("payload field \"protocolVersions\" must contain integers");
|
|
3605
|
-
return version;
|
|
3606
|
-
})
|
|
3898
|
+
candidates,
|
|
3899
|
+
invitationId: qr.invitationId,
|
|
3900
|
+
invitationSecret: qr.invitationSecret,
|
|
3901
|
+
desktopIdentity: qr.desktopIdentity,
|
|
3902
|
+
protocolVersions: qr.protocolVersions
|
|
3607
3903
|
};
|
|
3608
3904
|
}
|
|
3609
3905
|
const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS = [1];
|
|
3610
3906
|
const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
3907
|
+
/**
|
|
3908
|
+
* ONE aggregate budget for the candidate DIAL walk (issue #141). A v3 QR admits
|
|
3909
|
+
* up to 32 endpoints, so 32 serial `connectTimeoutMs` dials could spend 320 s
|
|
3910
|
+
* on an invitation that lives 120 s. It bounds the SUM, never one attempt.
|
|
3911
|
+
*/
|
|
3912
|
+
const DEFAULT_DIAL_BUDGET_MS = 3e4;
|
|
3611
3913
|
const DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
3612
3914
|
/** The protocol invitationMs: 120s of invitation, and our poll race guard. */
|
|
3613
3915
|
const DEFAULT_POLL_TIMEOUT_MS = 12e4;
|
|
3614
3916
|
const VERIFICATION_CODE_RE = /^[0-9]{6}$/;
|
|
3917
|
+
/** Bounds the close frame's reason text quoted into a candidate failure. */
|
|
3918
|
+
const CLOSE_REASON_MAX = 200;
|
|
3919
|
+
/**
|
|
3920
|
+
* Bounds ONE candidate failure segment of the joined `unreachable` verdict
|
|
3921
|
+
* (issue #148). Unbounded, a v3 QR's 32 endpoints × a 253-char host — repeated
|
|
3922
|
+
* again inside the dial error — reached ~17-18 KB in one tool result that
|
|
3923
|
+
* `cherry_pair` passes through whole. The bound is per SEGMENT, never a
|
|
3924
|
+
* "first N + and M more" cut: issue #141's contract is that every candidate,
|
|
3925
|
+
* attempted or not, is NAMED.
|
|
3926
|
+
*
|
|
3927
|
+
* `FAILURE_LABEL_MAX` clips an over-long `host:port` in the HOST and keeps the
|
|
3928
|
+
* PORT (`<host prefix>…:<port>`) — a prefix without its port names no address.
|
|
3929
|
+
* `FAILURE_SEGMENT_MAX` bounds the whole `<label>: <detail>`: the detail is
|
|
3930
|
+
* clipped to the room the label leaves, and the `… (+N chars)` marker's own
|
|
3931
|
+
* length is charged to the bound. 32 candidates then join to at most
|
|
3932
|
+
* 32 × 240 + 31 × 2 = 7,742 characters.
|
|
3933
|
+
*/
|
|
3934
|
+
const FAILURE_LABEL_MAX = 128;
|
|
3935
|
+
const FAILURE_SEGMENT_MAX = 240;
|
|
3936
|
+
/**
|
|
3937
|
+
* Bounds the progress line's HOST TEXT (issue #152). `report()` runs once per
|
|
3938
|
+
* stage transition and used to join every candidate host: a v3 QR admits 32
|
|
3939
|
+
* endpoints and a host name may be 253 characters, so one stage wrote ~8 KB.
|
|
3940
|
+
*
|
|
3941
|
+
* The FIRST host is always shown — a line with no host at all would lose the
|
|
3942
|
+
* run's identity — then further hosts are appended while the host NAMES fit
|
|
3943
|
+
* this budget; a truncated list carries an explicit `(+K more)` marker that
|
|
3944
|
+
* rides ON TOP of the budget (the marker is a COUNT, not a host name).
|
|
3945
|
+
*
|
|
3946
|
+
* Invariant: the host names consume ≤ `STAGE_HOSTS_CHARS`, with ONE exception
|
|
3947
|
+
* — a FIRST host that alone exceeds it, still shown because the identity rule
|
|
3948
|
+
* outranks the bound. A LATER over-long host can never enter the bounded
|
|
3949
|
+
* section (`used` already holds the first host, so it fails the fit test) and
|
|
3950
|
+
* is omitted into the marker.
|
|
3951
|
+
*/
|
|
3952
|
+
const STAGE_HOSTS_CHARS = 160;
|
|
3615
3953
|
const noop = () => {};
|
|
3616
3954
|
/** A callable silent libp2p logger (same shape as the connection layer's). */
|
|
3617
3955
|
function makeSilentLogger() {
|
|
@@ -3626,8 +3964,75 @@ const silentComponentLogger = { forComponent: () => makeSilentLogger() };
|
|
|
3626
3964
|
function sleep$1(ms) {
|
|
3627
3965
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3628
3966
|
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Tags a PRE-OPEN `ws` error as a candidate-dial failure (issue #142). The
|
|
3969
|
+
* `websocket:` prefix is the KIND: the operator's fix is this address or its
|
|
3970
|
+
* TLS, not the network — the contrast with `timeout:` is the whole point of the
|
|
3971
|
+
* prefix, and it is what the joined `unreachable` verdict shows. The `ws` error
|
|
3972
|
+
* carries the discriminator a bare message drops — an OS `code`
|
|
3973
|
+
* (`ECONNREFUSED`, `ENOTFOUND`, a TLS code) or a numeric ws code — so it is
|
|
3974
|
+
* appended, and only when it is actually a string or a number (never
|
|
3975
|
+
* `undefined`/`null`).
|
|
3976
|
+
*/
|
|
3977
|
+
function webSocketFailure(error) {
|
|
3978
|
+
const code = error.code;
|
|
3979
|
+
const suffix = typeof code === "string" || typeof code === "number" ? ` (code ${code})` : "";
|
|
3980
|
+
return /* @__PURE__ */ new Error(`websocket: ${error.message}${suffix}`);
|
|
3981
|
+
}
|
|
3982
|
+
/**
|
|
3983
|
+
* Tags a POST-OPEN channel failure as a candidate-dial failure (issue #147).
|
|
3984
|
+
* `channel:` is the THIRD kind, deliberately not `websocket:`: the upgrade
|
|
3985
|
+
* completed, so the address works and only the channel's establishment failed
|
|
3986
|
+
* (Noise handshake, remote identity proof, protocol offer). Blaming the
|
|
3987
|
+
* address would send the operator to inspect something that demonstrably
|
|
3988
|
+
* answered. No code suffix is appended: a Noise-stage error can carry its own
|
|
3989
|
+
* string code (`ERR_INVALID_CRYPTO_EXCHANGE`), but that is an internal label
|
|
3990
|
+
* rather than the ws/OS discriminator the other kinds keep, so it is
|
|
3991
|
+
* deliberately dropped.
|
|
3992
|
+
*/
|
|
3993
|
+
function channelFailure(error) {
|
|
3994
|
+
return /* @__PURE__ */ new Error(`channel: ${error.message}`);
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* The ONE text form of a candidate failure (issue #148). All three
|
|
3998
|
+
* `failures.push` sites render through it, so the segment bound cannot hold on
|
|
3999
|
+
* one arm (budget-exhausted, dial failure, claim fall-through) and fail on
|
|
4000
|
+
* another. A short label and a short detail are returned BYTE FOR BYTE; the
|
|
4001
|
+
* enforcement is never a rewrite of ordinary text.
|
|
4002
|
+
*
|
|
4003
|
+
* The detail goes through the SHARED {@link clipText} (issue #155), the same
|
|
4004
|
+
* `… (+N chars)` form every other peer-controlled log detail uses; the clip
|
|
4005
|
+
* point is no longer private to pairing.
|
|
4006
|
+
*/
|
|
4007
|
+
function failureSegment(label, detail) {
|
|
4008
|
+
const boundedLabel = clipFailureLabel(label);
|
|
4009
|
+
const room = FAILURE_SEGMENT_MAX - boundedLabel.length - 2;
|
|
4010
|
+
if (room <= 0) return boundedLabel.slice(0, FAILURE_SEGMENT_MAX);
|
|
4011
|
+
return `${boundedLabel}: ${clipText(detail, room)}`;
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* Clips a `host:port` label in the HOST, keeping the port (see
|
|
4015
|
+
* {@link FAILURE_LABEL_MAX}).
|
|
4016
|
+
*
|
|
4017
|
+
* Identification here is PREFIX + PORT, not uniqueness: two schema-distinct
|
|
4018
|
+
* 253-char hosts that agree on their first ~121 characters AND on the port clip
|
|
4019
|
+
* to identical labels (ports may repeat — only hosts are deduped). Each still
|
|
4020
|
+
* contributes its own segment, so #141's per-candidate count is intact.
|
|
4021
|
+
*/
|
|
4022
|
+
function clipFailureLabel(label) {
|
|
4023
|
+
if (label.length <= FAILURE_LABEL_MAX) return label;
|
|
4024
|
+
const colon = label.lastIndexOf(":");
|
|
4025
|
+
if (colon < 0) return `${label.slice(0, 127)}…`;
|
|
4026
|
+
const suffix = `…${label.slice(colon)}`;
|
|
4027
|
+
const hostRoom = FAILURE_LABEL_MAX - suffix.length;
|
|
4028
|
+
return hostRoom > 0 ? `${label.slice(0, hostRoom)}${suffix}` : suffix.slice(0, FAILURE_LABEL_MAX);
|
|
4029
|
+
}
|
|
3629
4030
|
/** Dials one candidate address and completes the Noise handshake.
|
|
3630
4031
|
*
|
|
4032
|
+
* The URL is the published `directEndpointUrl` — it brackets IPv6 literals,
|
|
4033
|
+
* appends the fixed `/v1/remote/connect` path, and selects `ws:` or `wss:`
|
|
4034
|
+
* from the candidate's own `security` (v3 TLS-proxy endpoints dial TLS).
|
|
4035
|
+
*
|
|
3631
4036
|
* Both halves start concurrently (`Promise.all`): the peer's accept half is
|
|
3632
4037
|
* already running on its side, so a sequential dial would deadlock the
|
|
3633
4038
|
* prelude.
|
|
@@ -3638,28 +4043,54 @@ function sleep$1(ms) {
|
|
|
3638
4043
|
* rather than assumed: the payload's `desktopIdentity` is the peer the Noise
|
|
3639
4044
|
* XX handshake must prove, so a swapped-in impostor fails the handshake
|
|
3640
4045
|
* instead of being pinned as the desktop.
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
4046
|
+
*
|
|
4047
|
+
* A failure carries one of THREE kinds into the joined `unreachable` verdict,
|
|
4048
|
+
* because each names the operator's next check: `timeout:` (the per-attempt
|
|
4049
|
+
* timer won its race — check the network), `websocket:` (the socket failed
|
|
4050
|
+
* BEFORE the upgrade — check this address or its TLS), and `channel:` (the
|
|
4051
|
+
* socket came up and the channel failed AFTER it — check the peer's identity
|
|
4052
|
+
* or the protocol offer). A stalled handshake stays `timeout:`: the dial
|
|
4053
|
+
* promise's own timer settles first, so the abort it triggers cannot win.
|
|
4054
|
+
*/
|
|
4055
|
+
async function dial(candidate, identity, desktopIdentity, protocolVersions, timeoutMs, wsOptions) {
|
|
4056
|
+
const { host, port } = candidate;
|
|
4057
|
+
const ws = new WebSocket(directEndpointUrl(candidate), wsOptions);
|
|
3644
4058
|
const controller = new AbortController();
|
|
3645
4059
|
let timer;
|
|
3646
4060
|
const timedOut = new Promise((_, reject) => {
|
|
3647
|
-
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`connect to ${host}:${port} timed out`)), timeoutMs);
|
|
4061
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout: connect to ${host}:${port} timed out`)), timeoutMs);
|
|
3648
4062
|
});
|
|
3649
4063
|
try {
|
|
3650
4064
|
const connecting = (async () => {
|
|
3651
4065
|
await new Promise((resolve, reject) => {
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
4066
|
+
const settle = (outcome) => {
|
|
4067
|
+
ws.off("open", onOpen);
|
|
4068
|
+
ws.off("error", onError);
|
|
4069
|
+
ws.off("close", onClose);
|
|
4070
|
+
outcome();
|
|
4071
|
+
};
|
|
4072
|
+
const onOpen = () => settle(resolve);
|
|
4073
|
+
const onError = (error) => settle(() => reject(webSocketFailure(error)));
|
|
4074
|
+
const onClose = (code, reason) => {
|
|
4075
|
+
const text = reason.toString("utf8").slice(0, CLOSE_REASON_MAX);
|
|
4076
|
+
settle(() => reject(/* @__PURE__ */ new Error(`websocket: closed before open (code ${code})${text === "" ? "" : ` ${text}`}`)));
|
|
4077
|
+
};
|
|
4078
|
+
ws.once("open", onOpen);
|
|
4079
|
+
ws.once("error", onError);
|
|
4080
|
+
ws.on("close", onClose);
|
|
3662
4081
|
});
|
|
4082
|
+
try {
|
|
4083
|
+
const stream = new RemoteSocketStream(toRemoteSocket(ws), silentComponentLogger.forComponent("pairing"), "outbound");
|
|
4084
|
+
return await connectSecureChannel(stream, {
|
|
4085
|
+
identity,
|
|
4086
|
+
logger: silentComponentLogger,
|
|
4087
|
+
protocolVersions: [...protocolVersions],
|
|
4088
|
+
signal: controller.signal,
|
|
4089
|
+
remoteIdentity: desktopIdentity
|
|
4090
|
+
});
|
|
4091
|
+
} catch (error) {
|
|
4092
|
+
throw channelFailure(error instanceof Error ? error : new Error(String(error)));
|
|
4093
|
+
}
|
|
3663
4094
|
})();
|
|
3664
4095
|
const channel = await Promise.race([connecting, timedOut.finally(() => controller.abort(/* @__PURE__ */ new Error("pairing connect timed out")))]);
|
|
3665
4096
|
return {
|
|
@@ -3692,6 +4123,27 @@ function rpcReason(error) {
|
|
|
3692
4123
|
}
|
|
3693
4124
|
}
|
|
3694
4125
|
/**
|
|
4126
|
+
* Renders the progress line's host list under {@link STAGE_HOSTS_CHARS} (the
|
|
4127
|
+
* invariant lives on that constant). A truncated list names the first host,
|
|
4128
|
+
* then says how many candidates it is NOT naming — the count is explicit so a
|
|
4129
|
+
* reader can tell a short host list from a bounded one.
|
|
4130
|
+
*/
|
|
4131
|
+
function stageHosts(hosts) {
|
|
4132
|
+
const [first, ...rest] = hosts;
|
|
4133
|
+
if (first === void 0) return "";
|
|
4134
|
+
let text = first;
|
|
4135
|
+
let used = first.length;
|
|
4136
|
+
let shown = 1;
|
|
4137
|
+
for (const host of rest) {
|
|
4138
|
+
if (used + host.length + 2 > STAGE_HOSTS_CHARS) break;
|
|
4139
|
+
text += `, ${host}`;
|
|
4140
|
+
used += host.length + 2;
|
|
4141
|
+
shown += 1;
|
|
4142
|
+
}
|
|
4143
|
+
const omitted = hosts.length - shown;
|
|
4144
|
+
return omitted > 0 ? `${text} (+${omitted} more)` : text;
|
|
4145
|
+
}
|
|
4146
|
+
/**
|
|
3695
4147
|
* Pairs this DSH profile with a Cherry Studio desktop from its pairing payload,
|
|
3696
4148
|
* returning the PERSISTED record (ruling 6).
|
|
3697
4149
|
*
|
|
@@ -3712,35 +4164,45 @@ async function pairWithPayload(opts) {
|
|
|
3712
4164
|
const payload = parsePayload(opts.payload);
|
|
3713
4165
|
const onStatus = opts.onStatus ?? noop;
|
|
3714
4166
|
const report = (stage) => {
|
|
3715
|
-
console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${payload.
|
|
4167
|
+
console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${stageHosts(payload.candidates.map((candidate) => candidate.host))})`);
|
|
3716
4168
|
onStatus(stage);
|
|
3717
4169
|
};
|
|
3718
4170
|
report("validate");
|
|
3719
4171
|
const supported = opts.supportedProtocolVersions ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS;
|
|
3720
4172
|
const offered = payload.protocolVersions.filter((version) => supported.includes(version));
|
|
3721
4173
|
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
4174
|
+
const dialBudgetMs = typeof opts.dialBudgetMs === "number" && Number.isFinite(opts.dialBudgetMs) ? opts.dialBudgetMs : DEFAULT_DIAL_BUDGET_MS;
|
|
4175
|
+
const dialDeadline = performance.now() + dialBudgetMs;
|
|
3722
4176
|
report("connect");
|
|
3723
4177
|
const failures = [];
|
|
3724
|
-
|
|
4178
|
+
let approved;
|
|
4179
|
+
for (const candidate of payload.candidates) {
|
|
4180
|
+
const label = `${candidate.host}:${candidate.port}`;
|
|
4181
|
+
const remaining = dialDeadline - performance.now();
|
|
4182
|
+
if (remaining <= 0) {
|
|
4183
|
+
failures.push(failureSegment(label, `not attempted (the ${dialBudgetMs} ms dial budget was exhausted before this candidate)`));
|
|
4184
|
+
continue;
|
|
4185
|
+
}
|
|
3725
4186
|
let temp;
|
|
3726
4187
|
try {
|
|
3727
|
-
temp = await dial(
|
|
4188
|
+
temp = await dial(candidate, opts.identity, payload.desktopIdentity, offered, Math.min(connectTimeoutMs, remaining), opts.wsOptions);
|
|
3728
4189
|
} catch (error) {
|
|
3729
|
-
failures.push(
|
|
4190
|
+
failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
|
|
3730
4191
|
continue;
|
|
3731
4192
|
}
|
|
3732
4193
|
try {
|
|
3733
|
-
|
|
4194
|
+
approved = await claimAndAwait(temp.client, payload, offered, opts, report, candidate);
|
|
3734
4195
|
destroy(temp);
|
|
3735
|
-
|
|
3736
|
-
return record;
|
|
4196
|
+
break;
|
|
3737
4197
|
} catch (error) {
|
|
3738
4198
|
destroy(temp);
|
|
3739
4199
|
if (error instanceof PairingError) throw error;
|
|
3740
|
-
failures.push(
|
|
4200
|
+
failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
|
|
3741
4201
|
}
|
|
3742
4202
|
}
|
|
3743
|
-
throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
|
|
4203
|
+
if (approved === void 0) throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
|
|
4204
|
+
opts.store.upsert(approved);
|
|
4205
|
+
return approved;
|
|
3744
4206
|
}
|
|
3745
4207
|
/**
|
|
3746
4208
|
* The RPC half of one candidate: confirm the protocol, claim, present the code,
|
|
@@ -3748,7 +4210,7 @@ async function pairWithPayload(opts) {
|
|
|
3748
4210
|
* deviceId, grants and token (ruling 3) — the claim receipt carries the code
|
|
3749
4211
|
* and nothing else.
|
|
3750
4212
|
*/
|
|
3751
|
-
async function claimAndAwait(client, payload, protocolVersions, opts, onStatus) {
|
|
4213
|
+
async function claimAndAwait(client, payload, protocolVersions, opts, onStatus, candidate) {
|
|
3752
4214
|
const deviceName = (opts.name ?? hostname()).slice(0, DEVICE_NAME_MAX);
|
|
3753
4215
|
try {
|
|
3754
4216
|
await client.request("connection.hello", { protocolVersions: [...protocolVersions] });
|
|
@@ -3782,7 +4244,7 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
|
|
|
3782
4244
|
}
|
|
3783
4245
|
if (result.status === "rejected") throw new PairingError("rejected", "the desktop rejected this pairing request — verify that the 6-digit code shown on the desktop side matches the code this harness displayed, then generate a fresh pairing payload and try again");
|
|
3784
4246
|
if (result.status === "expired") throw new PairingError("expired", "the pairing invitation has expired — regenerate the pairing code in Cherry Studio (the remote-access QR/payload) and retry");
|
|
3785
|
-
if (result.status === "approved") return recordFrom(payload, result, deviceName);
|
|
4247
|
+
if (result.status === "approved") return recordFrom(payload, result, deviceName, candidate);
|
|
3786
4248
|
if (Date.now() >= deadline) throw new PairingError("expired", "the pairing invitation expired while waiting for approval — regenerate the pairing code in Cherry Studio and retry");
|
|
3787
4249
|
await sleep$1(interval);
|
|
3788
4250
|
}
|
|
@@ -3793,18 +4255,34 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
|
|
|
3793
4255
|
* `displayName` is the name the claim actually carried — the record must not
|
|
3794
4256
|
* disagree with what the desktop shows for this device, and re-deriving it
|
|
3795
4257
|
* here would lose the 64-char truncation.
|
|
3796
|
-
|
|
3797
|
-
|
|
4258
|
+
*
|
|
4259
|
+
* The address projection: every candidate host is kept (the v2 record always
|
|
4260
|
+
* did, and reconnect re-walks them), and `port` is the one the pairing actually
|
|
4261
|
+
* completed on — the WINNER's port, and the record-level fallback. A mixed-port
|
|
4262
|
+
* v3 payload is now persisted FAITHFULLY, per host, through `ports` (issue
|
|
4263
|
+
* #135): the 2.1.8 generation side emits one endpoint per custom-address
|
|
4264
|
+
* invitation (`ApiGatewayService.getRemoteEndpoint`:
|
|
4265
|
+
* `advertisedEndpoint ? [advertisedEndpoint] : …`), but a QR that does carry a
|
|
4266
|
+
* mixed list must still reconnect each host on its own port. `security` IS
|
|
4267
|
+
* persisted (issue #126), one entry per host, taken from the very candidates the
|
|
4268
|
+
* walk dialed — so a schemaVersion-4 record remembers the scheme the pairing
|
|
4269
|
+
* actually used, and the steady-state dial (src/connection.ts) rebuilds the
|
|
4270
|
+
* same URL instead of a plain `ws://`. Before that, #124 paired a TLS endpoint
|
|
4271
|
+
* and made it unreachable on every reconnect.
|
|
4272
|
+
*/
|
|
4273
|
+
function recordFrom(payload, approved, deviceName, winner) {
|
|
3798
4274
|
const grants = (approved.authorization?.grants ?? []).map((grant) => grant.domain).filter((domain) => domain === "agent");
|
|
3799
4275
|
if (grants.length === 0) throw new PairingError("rejected", "the desktop approved no agent capability for this device");
|
|
3800
4276
|
const tokenExpiresAt = Date.parse(approved.expiresAt);
|
|
3801
4277
|
if (!Number.isFinite(tokenExpiresAt)) throw new PairingError("rejected", `the desktop approved the pairing with an unreadable token expiry (${JSON.stringify(approved.expiresAt)}); re-pair the device`);
|
|
3802
4278
|
return {
|
|
3803
|
-
schemaVersion:
|
|
4279
|
+
schemaVersion: 4,
|
|
3804
4280
|
deviceId: approved.deviceId,
|
|
3805
4281
|
displayName: deviceName,
|
|
3806
|
-
hosts:
|
|
3807
|
-
port:
|
|
4282
|
+
hosts: payload.candidates.map((candidate) => candidate.host),
|
|
4283
|
+
port: winner.port,
|
|
4284
|
+
security: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.security])),
|
|
4285
|
+
ports: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.port])),
|
|
3808
4286
|
desktopIdentity: payload.desktopIdentity,
|
|
3809
4287
|
grants,
|
|
3810
4288
|
accessToken: approved.accessToken,
|
|
@@ -3822,7 +4300,7 @@ function asPairingError(error, stage) {
|
|
|
3822
4300
|
if (error instanceof PairingError) return error;
|
|
3823
4301
|
const reason = rpcReason(error);
|
|
3824
4302
|
if (reason !== void 0) return new PairingError("rejected", `the desktop refused the pairing ${stage} (${reason})`);
|
|
3825
|
-
return new PairingError("unreachable", `pairing ${stage} failed
|
|
4303
|
+
return new PairingError("unreachable", failureSegment(`pairing ${stage} failed`, error instanceof Error ? error.message : String(error)));
|
|
3826
4304
|
}
|
|
3827
4305
|
//#endregion
|
|
3828
4306
|
//#region src/subscriptions.ts
|
|
@@ -4345,7 +4823,7 @@ var SessionSubscriptions = class {
|
|
|
4345
4823
|
try {
|
|
4346
4824
|
await this.#rpc.request("agent.subscriptions.close", { subscriptionId });
|
|
4347
4825
|
} catch (error) {
|
|
4348
|
-
console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed
|
|
4826
|
+
console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed: ${failureText(error)}`);
|
|
4349
4827
|
}
|
|
4350
4828
|
}
|
|
4351
4829
|
/**
|
|
@@ -4680,6 +5158,42 @@ const TERMINAL_EXECUTION_STATUSES = [
|
|
|
4680
5158
|
];
|
|
4681
5159
|
/** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
|
|
4682
5160
|
const PAGE_ITEMS_FALLBACK = 100;
|
|
5161
|
+
/**
|
|
5162
|
+
* The page this bridge asks for when the peer's hello advertised
|
|
5163
|
+
* `agentMessageSummariesVersion: 1` (issue #105).
|
|
5164
|
+
*
|
|
5165
|
+
* WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
|
|
5166
|
+
* exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
|
|
5167
|
+
* schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
|
|
5168
|
+
* record cannot hold and the transport kills the connection (`defaults.ts`).
|
|
5169
|
+
* What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
|
|
5170
|
+
* revision, role, status and optional usage/model/failure, and NO `partIds`.
|
|
5171
|
+
*
|
|
5172
|
+
* WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
|
|
5173
|
+
* the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
|
|
5174
|
+
* prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
|
|
5175
|
+
* at a halved limit, so its reply records stay under `recordBytes` = 65,536
|
|
5176
|
+
* whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
|
|
5177
|
+
* shape our walk already tolerates) instead of a killed link. The single-item
|
|
5178
|
+
* `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
|
|
5179
|
+
* exception arm, and a schema-valid summary row cannot reach it: measured
|
|
5180
|
+
* against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
|
|
5181
|
+
* 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
|
|
5182
|
+
* ≈37 KB — under the 62,279 budget, so the refusal exists for full
|
|
5183
|
+
* `partIds`-bearing rows, not summaries.
|
|
5184
|
+
*
|
|
5185
|
+
* 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
|
|
5186
|
+
* all: `#aggregate` still intersects this with the peer's NEGOTIATED
|
|
5187
|
+
* `pageItems`, so a peer advertising less keeps its size. Residual exposure —
|
|
5188
|
+
* a hypothetical peer advertising the capability WITHOUT a page budget —
|
|
5189
|
+
* contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
|
|
5190
|
+
* the same "pragmatic, not provable" class `defaults.ts` already scopes.
|
|
5191
|
+
*
|
|
5192
|
+
* The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
|
|
5193
|
+
* in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
|
|
5194
|
+
* fattest item there is.
|
|
5195
|
+
*/
|
|
5196
|
+
const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK;
|
|
4683
5197
|
/** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
|
|
4684
5198
|
const MAX_AGGREGATE_LIMIT = 1e3;
|
|
4685
5199
|
/**
|
|
@@ -4768,6 +5282,33 @@ const DEFAULT_PARTS_SAFETY_BOUND = 1e4;
|
|
|
4768
5282
|
* returning a partial (or empty) result as though it were complete.
|
|
4769
5283
|
*/
|
|
4770
5284
|
const MAX_AGGREGATE_PAGES = 1e4;
|
|
5285
|
+
/**
|
|
5286
|
+
* The `agent.messages.list` request for ONE message walk, and the page size to
|
|
5287
|
+
* ask for — the ONE place the paged-summaries dialect is decided (issue #105).
|
|
5288
|
+
*
|
|
5289
|
+
* THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
|
|
5290
|
+
* `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
|
|
5291
|
+
* through 2.1.7 ships — is a STRICT object with no such key, so sending it
|
|
5292
|
+
* there is a protocol violation, not a wasted field. Both message-walk faces
|
|
5293
|
+
* (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
|
|
5294
|
+
* the same reason the resume token has one helper: two call sites spelling the
|
|
5295
|
+
* dialect would eventually disagree about which peer may hear it.
|
|
5296
|
+
*
|
|
5297
|
+
* With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
|
|
5298
|
+
* summary rows are metadata, so the byte-safe clamp no longer bounds anything
|
|
5299
|
+
* they can carry. Without it, the clamp stays exactly as it was: the
|
|
5300
|
+
* count-paged peer still returns `partIds`-bearing rows.
|
|
5301
|
+
*/
|
|
5302
|
+
function messageListRequest(connection) {
|
|
5303
|
+
if (connection.limits.messageSummaries === 1) return {
|
|
5304
|
+
params: { partsMode: "paged" },
|
|
5305
|
+
maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS
|
|
5306
|
+
};
|
|
5307
|
+
return {
|
|
5308
|
+
params: {},
|
|
5309
|
+
maxPerPage: 16
|
|
5310
|
+
};
|
|
5311
|
+
}
|
|
4771
5312
|
/** Token prefix for a peer page position; the one `#aggregate` can send back. */
|
|
4772
5313
|
const RESUME_CURSOR_PREFIX = "cursor:";
|
|
4773
5314
|
/** Token prefix for "strictly older than this message id". */
|
|
@@ -4881,6 +5422,59 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1048576;
|
|
|
4881
5422
|
*/
|
|
4882
5423
|
const MESSAGE_CACHE_BYTES_PER_DEVICE = 33554432;
|
|
4883
5424
|
/**
|
|
5425
|
+
* How many command bodies the engine remembers PER DEVICE, keyed by the
|
|
5426
|
+
* CALLER-SUPPLIED `commandId` (issues #115, #118).
|
|
5427
|
+
*
|
|
5428
|
+
* Why a memory exists at all: a command's recovery path is RECEIPT. A reply
|
|
5429
|
+
* lost on the wire is answered by re-sending the ORIGINAL body under the
|
|
5430
|
+
* original id, and the desktop's ledger replays the recorded outcome. That
|
|
5431
|
+
* promise was impossible for `cherry_send`, whose body carries a DERIVED field
|
|
5432
|
+
* (`expectedIdleRevision`) that moves with every successful send: the retry
|
|
5433
|
+
* quoted a fresh baseline, the body differed from the recorded one, and the peer
|
|
5434
|
+
* answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
|
|
5435
|
+
* what already landed. So the engine remembers the EXACT body it emitted, per
|
|
5436
|
+
* (device, caller id, method), and a same-id retry replays it verbatim. The
|
|
5437
|
+
* same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
|
|
5438
|
+
* #118): their DERIVED fields were re-derived, and the retry usually met a
|
|
5439
|
+
* pre-check (`unknown-interaction`, `no-active-execution`) before the body was
|
|
5440
|
+
* ever compared — the memory is consulted before those, which is why it holds
|
|
5441
|
+
* the whole issued body rather than a way to rebuild one.
|
|
5442
|
+
*
|
|
5443
|
+
* What an entry holds is one short body: a session id, the caller's text (at
|
|
5444
|
+
* most the protocol's 32,768 characters, pre-validated before it is ever
|
|
5445
|
+
* stored), one revision string — or, for respond/cancel, the interaction's
|
|
5446
|
+
* staleness triple / the execution to abort, plus the caller-owned arguments
|
|
5447
|
+
* the fingerprint compares. 128 is "more than any live retry window needs,
|
|
5448
|
+
* small enough to be trivial": a caller retries a handful of command ids around
|
|
5449
|
+
* a lost reply, and each is recoverable while it stays in this FIFO.
|
|
5450
|
+
* Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
|
|
5451
|
+
* a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
|
|
5452
|
+
* reaches the peer, which refuses the differing body exactly as it would
|
|
5453
|
+
* without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
|
|
5454
|
+
* at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
|
|
5455
|
+
* `execution-mismatch`) — the pre-fix failure mode, which names real state.
|
|
5456
|
+
*
|
|
5457
|
+
* PER DEVICE, like every engine cache: the id is the caller's, but the peer
|
|
5458
|
+
* that settled it is not, and a second desktop must never be sent a body minted
|
|
5459
|
+
* against the first.
|
|
5460
|
+
*
|
|
5461
|
+
* INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
|
|
5462
|
+
* value needs 129 live command ids before it evicts.
|
|
5463
|
+
*/
|
|
5464
|
+
const COMMAND_BODY_ENTRIES_PER_DEVICE = 128;
|
|
5465
|
+
/**
|
|
5466
|
+
* The three command methods whose bodies the engine remembers (see
|
|
5467
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
|
|
5468
|
+
*
|
|
5469
|
+
* Named once, because they are used twice each by design: as the remembered
|
|
5470
|
+
* entry's `method`, and as the method the entry is compared against on lookup.
|
|
5471
|
+
* Spelling them at both ends is how a lookup could start matching the wrong
|
|
5472
|
+
* tool without any type noticing.
|
|
5473
|
+
*/
|
|
5474
|
+
const SEND_METHOD = "agent.messages.send";
|
|
5475
|
+
const RESPOND_METHOD = "agent.interactions.respond";
|
|
5476
|
+
const CANCEL_METHOD = "agent.executions.cancel";
|
|
5477
|
+
/**
|
|
4884
5478
|
* EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
|
|
4885
5479
|
* legally answers ONE empty page with an advancing cursor (the pin→entity
|
|
4886
5480
|
* section transition, `{items: [], nextCursor: 'entity:'}`), so a single empty
|
|
@@ -5053,6 +5647,44 @@ function failureReason(error) {
|
|
|
5053
5647
|
if (isRecord(data) && typeof data["reason"] === "string") return data["reason"];
|
|
5054
5648
|
}
|
|
5055
5649
|
/**
|
|
5650
|
+
* The phrase this hint leaves behind, so applying it twice reads once.
|
|
5651
|
+
*/
|
|
5652
|
+
const PEER_REFUSAL_FINAL_MARK = "retrying the same request cannot help";
|
|
5653
|
+
/**
|
|
5654
|
+
* The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
|
|
5655
|
+
* carrying `details.retryable === false`, which is the desktop's per-item byte
|
|
5656
|
+
* budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
|
|
5657
|
+
* `itemBytes` / `budgetBytes`), issue #105.
|
|
5658
|
+
*
|
|
5659
|
+
* WHY THE WARNING HAS TO BE ADDED: the official client marks every
|
|
5660
|
+
* `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
|
|
5661
|
+
* exponential backoff, so a byte-budget refusal never converges — and the
|
|
5662
|
+
* condition really is permanent: the same item over the same budget will be
|
|
5663
|
+
* over it again. A caller (or a model reading the error) that retries learns
|
|
5664
|
+
* nothing but spends the peer's rate window.
|
|
5665
|
+
*
|
|
5666
|
+
* The sentence is deliberately narrower than the reason: only the
|
|
5667
|
+
* `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
|
|
5668
|
+
* the upload window, the subscription cap, a legacy refusal with no `details` —
|
|
5669
|
+
* keeps its own text, because some of those ARE worth a later retry.
|
|
5670
|
+
*
|
|
5671
|
+
* Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
|
|
5672
|
+
* structured `data` and the {@link JsonRpcRequestError} identity stay the
|
|
5673
|
+
* peer's, so branching callers see exactly what it sent.
|
|
5674
|
+
*/
|
|
5675
|
+
function withPeerRefusalHint(error) {
|
|
5676
|
+
if (!(error instanceof JsonRpcRequestError)) return error;
|
|
5677
|
+
if (failureReason(error) !== "RESOURCE_EXHAUSTED") return error;
|
|
5678
|
+
const data = error.data;
|
|
5679
|
+
if (!isRecord(data) || !isRecord(data["details"]) || data["details"]["retryable"] !== false) return error;
|
|
5680
|
+
if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error;
|
|
5681
|
+
const kind = optionalString(data["details"]["kind"]);
|
|
5682
|
+
const cause = kind === "ITEM_TOO_LARGE" ? "an ITEM_TOO_LARGE page item over its byte budget" : "the same item";
|
|
5683
|
+
const tail = kind === "ITEM_TOO_LARGE" ? "the item ITSELF exceeds the peer’s per-item budget, so no page size or request shape can fetch it (nor the items behind it — upstream #21951)" : "ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget";
|
|
5684
|
+
error.message = `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); retrying the same request cannot help — ${tail}`;
|
|
5685
|
+
return error;
|
|
5686
|
+
}
|
|
5687
|
+
/**
|
|
5056
5688
|
* One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
|
|
5057
5689
|
* digest the ref advertises (or there was no digest to check against).
|
|
5058
5690
|
*
|
|
@@ -5093,6 +5725,79 @@ function unavailableReason(error) {
|
|
|
5093
5725
|
*/
|
|
5094
5726
|
const IDEMPOTENCY_HINT = "the peer already recorded this commandId with a different body; regenerate the commandId for a retry whose parameters changed — never reuse an id with a different body (re-send the ORIGINAL body to replay the original receipt).";
|
|
5095
5727
|
/**
|
|
5728
|
+
* The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
|
|
5729
|
+
* exactly this), used when the ENGINE is the one that knows the body differs —
|
|
5730
|
+
* a caller-supplied `commandId` it has already emitted with other arguments
|
|
5731
|
+
* (issue #115).
|
|
5732
|
+
*
|
|
5733
|
+
* The refusal is LOCAL because the outcome is already decided: the peer's ledger
|
|
5734
|
+
* holds the recorded body, so a forwarded body would come back as this same
|
|
5735
|
+
* conflict, one round trip later, at the cost of a wire call that can never
|
|
5736
|
+
* succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
|
|
5737
|
+
* (and the existing conflict tests) match on the prefix.
|
|
5738
|
+
*/
|
|
5739
|
+
const COMMAND_BODY_DIFFERS_MESSAGE = "Command body differs from the recorded command";
|
|
5740
|
+
/**
|
|
5741
|
+
* The JSON-RPC error code the transport reserves for protocol failures (the
|
|
5742
|
+
* server answers every peer-refused request with it). Used for the LOCAL
|
|
5743
|
+
* {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
|
|
5744
|
+
* forwarded one would have been: a `JsonRpcRequestError` carrying
|
|
5745
|
+
* `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
|
|
5746
|
+
* request was ever sent.
|
|
5747
|
+
*
|
|
5748
|
+
* A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
|
|
5749
|
+
* `JsonRpcRequestError` docblock: that module exports no code constant, and
|
|
5750
|
+
* adding one only to name this number would widen the published surface for it.
|
|
5751
|
+
*/
|
|
5752
|
+
const JSON_RPC_PROTOCOL_ERROR_CODE = 1e3;
|
|
5753
|
+
/**
|
|
5754
|
+
* The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
|
|
5755
|
+
* reason and sentence, wrapped with the same hints a forwarded conflict earns,
|
|
5756
|
+
* and never sent anywhere.
|
|
5757
|
+
*
|
|
5758
|
+
* ONE constructor for every command that remembers its body (send, respond,
|
|
5759
|
+
* cancel): the caller must not have to learn a second vocabulary for one
|
|
5760
|
+
* condition, and a test matches the prefix `Command body differs` whichever
|
|
5761
|
+
* tool produced it.
|
|
5762
|
+
*/
|
|
5763
|
+
function commandBodyDiffers() {
|
|
5764
|
+
const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
|
|
5765
|
+
reason: "IDEMPOTENCY_CONFLICT",
|
|
5766
|
+
message: COMMAND_BODY_DIFFERS_MESSAGE
|
|
5767
|
+
});
|
|
5768
|
+
withRateLimitHint(withIdempotencyHint(conflict));
|
|
5769
|
+
return conflict;
|
|
5770
|
+
}
|
|
5771
|
+
/**
|
|
5772
|
+
* DEEP structural equality, for the caller-owned half of a remembered command
|
|
5773
|
+
* body (issue #118).
|
|
5774
|
+
*
|
|
5775
|
+
* `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
|
|
5776
|
+
* same answers in a different KEY ORDER are sending the SAME answer, and a
|
|
5777
|
+
* plain `!==` (or a JSON string comparison) would refuse the retry as a
|
|
5778
|
+
* different body — turning the receipt recovery into a local conflict on the
|
|
5779
|
+
* exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
|
|
5780
|
+
* reordering it really is a different value.
|
|
5781
|
+
*
|
|
5782
|
+
* Deliberately small: the values compared here are tool arguments decoded from
|
|
5783
|
+
* JSON, so only JSON's two container kinds, the primitives, and `undefined`
|
|
5784
|
+
* (an omitted optional argument) need to be understood.
|
|
5785
|
+
*/
|
|
5786
|
+
function sameCommandValue(left, right) {
|
|
5787
|
+
if (left === right) return true;
|
|
5788
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
5789
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
5790
|
+
if (left.length !== right.length) return false;
|
|
5791
|
+
return left.every((value, index) => sameCommandValue(value, right[index]));
|
|
5792
|
+
}
|
|
5793
|
+
if (isRecord(left) && isRecord(right)) {
|
|
5794
|
+
const leftKeys = Object.keys(left);
|
|
5795
|
+
if (leftKeys.length !== Object.keys(right).length) return false;
|
|
5796
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]));
|
|
5797
|
+
}
|
|
5798
|
+
return false;
|
|
5799
|
+
}
|
|
5800
|
+
/**
|
|
5096
5801
|
* Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
|
|
5097
5802
|
* gets {@link IDEMPOTENCY_HINT} appended to its message.
|
|
5098
5803
|
*
|
|
@@ -5275,17 +5980,52 @@ const CHERRY_TOOL_NAMES = [
|
|
|
5275
5980
|
* those tasks add mutating tools without touching the host seam.
|
|
5276
5981
|
*/
|
|
5277
5982
|
function registerCherryTools(ctx, deps, options = {}) {
|
|
5983
|
+
return registerCherryToolsWithEngine(ctx, deps, options).dispose;
|
|
5984
|
+
}
|
|
5985
|
+
/**
|
|
5986
|
+
* The same registration, with the ENGINE it built handed back.
|
|
5987
|
+
*
|
|
5988
|
+
* Exists for TESTS: the engine's memory bookkeeping (the remembered command
|
|
5989
|
+
* bodies, the bounds they obey) is private to it on purpose, and the only
|
|
5990
|
+
* honest way to pin "bounded" and "dropped with the device" is to observe it
|
|
5991
|
+
* through this one seam instead of inferring it from retry outcomes that are
|
|
5992
|
+
* identical for an evicted entry and for one that never existed.
|
|
5993
|
+
*
|
|
5994
|
+
* Both this function and `export class CherryToolEngine` are SHIPPED names
|
|
5995
|
+
* (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
|
|
5996
|
+
* compatibility commitment — which is why this seam is kept minimal.
|
|
5997
|
+
*/
|
|
5998
|
+
function registerCherryToolsWithEngine(ctx, deps, options = {}) {
|
|
5278
5999
|
const tools = ctx.tools;
|
|
5279
6000
|
if (tools === void 0) {
|
|
5280
6001
|
console.warn("[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered");
|
|
5281
|
-
return
|
|
6002
|
+
return {
|
|
6003
|
+
dispose: () => {},
|
|
6004
|
+
engine: void 0
|
|
6005
|
+
};
|
|
5282
6006
|
}
|
|
5283
|
-
const
|
|
5284
|
-
|
|
5285
|
-
|
|
6007
|
+
const engine = new CherryToolEngine(deps, options);
|
|
6008
|
+
const disposers = engine.definitions().map((definition) => tools.register(definition));
|
|
6009
|
+
return {
|
|
6010
|
+
engine,
|
|
6011
|
+
dispose: () => {
|
|
6012
|
+
for (const dispose of disposers) dispose();
|
|
6013
|
+
}
|
|
5286
6014
|
};
|
|
5287
6015
|
}
|
|
5288
|
-
/**
|
|
6016
|
+
/**
|
|
6017
|
+
* One tool declaration, in the exact shape the host registry consumes.
|
|
6018
|
+
*
|
|
6019
|
+
* This helper adds no result WRAPPER and this ticket adds no result-side
|
|
6020
|
+
* truncation (issue #161): a tool result is the MODEL's signal, and the peer is
|
|
6021
|
+
* the user's own Noise-authenticated desktop, so shortening the peer's
|
|
6022
|
+
* explanation costs the caller the text it acts on. The two result-text clamps
|
|
6023
|
+
* that DO exist are per-field product choices, not a log rule — `aggregateError`
|
|
6024
|
+
* (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
|
|
6025
|
+
* 200-character diagnostics digest used by `projectMessageFailure`. The LOG
|
|
6026
|
+
* face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
|
|
6027
|
+
* this ticket's sweep does not change tool output.
|
|
6028
|
+
*/
|
|
5289
6029
|
function defineCherryTool(definition) {
|
|
5290
6030
|
return {
|
|
5291
6031
|
name: definition.name,
|
|
@@ -5378,6 +6118,12 @@ var CherryToolEngine = class {
|
|
|
5378
6118
|
*/
|
|
5379
6119
|
#contentGate;
|
|
5380
6120
|
/**
|
|
6121
|
+
* The `maxBytes` one `agent.content.read` slice asks for
|
|
6122
|
+
* ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
|
|
6123
|
+
* reach a walk's second slice, see `CherryToolEngineOptions`).
|
|
6124
|
+
*/
|
|
6125
|
+
#contentReadSliceBytes;
|
|
6126
|
+
/**
|
|
5381
6127
|
* The parts pages already walked, keyed `<deviceId>\0<messageId>`.
|
|
5382
6128
|
*
|
|
5383
6129
|
* WHAT IS HELD is the RESULT of `agent.parts.list` — the raw part records plus
|
|
@@ -5444,6 +6190,49 @@ var CherryToolEngine = class {
|
|
|
5444
6190
|
* not pinned by bookkeeping.
|
|
5445
6191
|
*/
|
|
5446
6192
|
#watchedConnections = /* @__PURE__ */ new WeakSet();
|
|
6193
|
+
/**
|
|
6194
|
+
* The EXACT command body emitted for each (device, caller id, method), so a
|
|
6195
|
+
* lost reply can be recovered by receipt (issues #115, #118).
|
|
6196
|
+
*
|
|
6197
|
+
* Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
|
|
6198
|
+
* value is a {@link RememberedCommand}: the METHOD it was minted for, the
|
|
6199
|
+
* caller-owned FINGERPRINT a retry must match, and the BODY the retry
|
|
6200
|
+
* replays verbatim. The fingerprint is what must MATCH — a same-id call whose
|
|
6201
|
+
* arguments differ is not a retry of this command at all, and is refused
|
|
6202
|
+
* locally rather than sent. The body is what must be REPLAYED, derived
|
|
6203
|
+
* fields included: that is the whole point, because those fields are the ones
|
|
6204
|
+
* a retry cannot re-derive the same way.
|
|
6205
|
+
*
|
|
6206
|
+
* Three tools remember: `agent.messages.send` (the baseline it quoted),
|
|
6207
|
+
* `agent.interactions.respond` (the staleness triple) and
|
|
6208
|
+
* `agent.executions.cancel` (the execution it was told to abort). Two of
|
|
6209
|
+
* them run PRE-CHECKS before composing a body — respond's
|
|
6210
|
+
* `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
|
|
6211
|
+
* and after a lost reply those pre-checks are usually the first thing the
|
|
6212
|
+
* retry meets, because the first attempt is what SETTLED the interaction and
|
|
6213
|
+
* ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
|
|
6214
|
+
* a remembered retry is an answer, not a fresh call.
|
|
6215
|
+
*
|
|
6216
|
+
* WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
|
|
6217
|
+
* one-shot identity — nobody can retry it, so an entry for it is pure leak.
|
|
6218
|
+
* The entry is written the moment the command is ISSUED, before the reply is
|
|
6219
|
+
* awaited: a reply lost on the wire must not cost the only copy of the bytes
|
|
6220
|
+
* the peer's ledger will accept.
|
|
6221
|
+
*
|
|
6222
|
+
* Which body an id holds: the LATEST attempt issued for it — the first
|
|
6223
|
+
* attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
|
|
6224
|
+
* same-id retry is therefore answered with the peer's conflict, deliberately:
|
|
6225
|
+
* replaying the ledger's rejected receipt would re-run the healed admission
|
|
6226
|
+
* and duplicate the run, and a conflict is the safer failure.
|
|
6227
|
+
*
|
|
6228
|
+
* Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
|
|
6229
|
+
* identity-mismatch) but KEPT across an ordinary reconnect — see
|
|
6230
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
|
|
6231
|
+
* {@link CherryToolEngine.#forgetDevice}.
|
|
6232
|
+
*/
|
|
6233
|
+
#commandBodies = /* @__PURE__ */ new Map();
|
|
6234
|
+
/** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
|
|
6235
|
+
#commandBodyEntriesPerDevice;
|
|
5447
6236
|
constructor(deps, options = {}) {
|
|
5448
6237
|
this.#deps = deps;
|
|
5449
6238
|
this.#partsSafetyBound = Math.max(1, Math.floor(options.partsSafetyBound ?? DEFAULT_PARTS_SAFETY_BOUND));
|
|
@@ -5452,7 +6241,9 @@ var CherryToolEngine = class {
|
|
|
5452
6241
|
this.#cacheEntriesPerDevice = Math.max(1, Math.floor(options.cacheEntriesPerDevice ?? CACHE_ENTRIES_PER_DEVICE));
|
|
5453
6242
|
this.#messageCacheEntryBytes = Math.max(1, Math.floor(options.messageCacheEntryBytes ?? MESSAGE_CACHE_ENTRY_BYTES));
|
|
5454
6243
|
this.#messageCacheBytesPerDevice = Math.max(1, Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE));
|
|
6244
|
+
this.#commandBodyEntriesPerDevice = Math.max(1, Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE));
|
|
5455
6245
|
this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY);
|
|
6246
|
+
this.#contentReadSliceBytes = Math.max(1, Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES));
|
|
5456
6247
|
}
|
|
5457
6248
|
definitions() {
|
|
5458
6249
|
return [
|
|
@@ -5702,7 +6493,7 @@ var CherryToolEngine = class {
|
|
|
5702
6493
|
result = await connection.rpc.request(method, request, deadline === void 0 ? void 0 : { deadline: deadline.instant });
|
|
5703
6494
|
} catch (error) {
|
|
5704
6495
|
if (deadline !== void 0 && deadlineExhaustion(error)) return this.#truncated(items, "deadline", cursor);
|
|
5705
|
-
throw error;
|
|
6496
|
+
throw withPeerRefusalHint(error);
|
|
5706
6497
|
}
|
|
5707
6498
|
const extracted = extract(result);
|
|
5708
6499
|
if (extracted.notAnArray !== void 0) throw new Error(`${method}: peer answered a page whose \`items\` is not an array (\`items\` is ${extracted.notAnArray}) for ${JSON.stringify(params)}; refusing a silently short result — every page of a paged list must carry an items array`);
|
|
@@ -6015,7 +6806,7 @@ var CherryToolEngine = class {
|
|
|
6015
6806
|
#readTool() {
|
|
6016
6807
|
return defineCherryTool({
|
|
6017
6808
|
name: "cherry_read",
|
|
6018
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory.
|
|
6809
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. An attachment is reported as METADATA, never as its bytes: a `kind: \"file\"` part, or a legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as `name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or kept verbatim when that record carries a key this projection does not report. The bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A failed message (`status: \"error\"`) may carry `failure: {reasonCode, retryable, message}` when the peer sent one — its classification, whether it judged a retry worthwhile, and a bounded summary; the peer's `source` and `context.responseBody` are never projected. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
6019
6810
|
parameters: {
|
|
6020
6811
|
type: "object",
|
|
6021
6812
|
properties: {
|
|
@@ -6113,16 +6904,18 @@ var CherryToolEngine = class {
|
|
|
6113
6904
|
const session = isRecord(sessionResult["session"]) ? sessionResult["session"] : {};
|
|
6114
6905
|
const historyRevision = session["historyRevision"];
|
|
6115
6906
|
if (typeof historyRevision !== "string") throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`);
|
|
6907
|
+
const messageList = messageListRequest(connection);
|
|
6116
6908
|
const page = await this.#aggregate(connection, "agent.messages.list", {
|
|
6117
6909
|
sessionId,
|
|
6118
|
-
historyRevision
|
|
6910
|
+
historyRevision,
|
|
6911
|
+
...messageList.params
|
|
6119
6912
|
}, (result) => ({
|
|
6120
6913
|
...arrayOf(result["items"]),
|
|
6121
6914
|
nextCursor: result["nextCursor"]
|
|
6122
6915
|
}), {
|
|
6123
6916
|
limit,
|
|
6124
6917
|
safetyBound: MAX_AGGREGATE_LIMIT,
|
|
6125
|
-
maxPerPage:
|
|
6918
|
+
maxPerPage: messageList.maxPerPage,
|
|
6126
6919
|
resume: resume === void 0 ? void 0 : resume.kind === "cursor" ? {
|
|
6127
6920
|
kind: "cursor",
|
|
6128
6921
|
cursor: resume.cursor
|
|
@@ -6276,38 +7069,115 @@ var CherryToolEngine = class {
|
|
|
6276
7069
|
releaseSlot?.();
|
|
6277
7070
|
}
|
|
6278
7071
|
} else text = "";
|
|
7072
|
+
const legacy = this.#legacyAttachment(kind, raw, text);
|
|
6279
7073
|
return {
|
|
6280
7074
|
partId,
|
|
6281
7075
|
kind,
|
|
6282
7076
|
revision,
|
|
6283
|
-
content: text,
|
|
6284
|
-
...this.#attachmentFace(kind, raw, content),
|
|
7077
|
+
content: legacy === void 0 ? text : legacy.remainder,
|
|
7078
|
+
...legacy === void 0 ? this.#attachmentFace(kind, raw, content) : this.#attachmentFace(kind, raw, content, legacy.record),
|
|
6285
7079
|
...typeof raw["state"] === "string" ? { state: raw["state"] } : {},
|
|
6286
7080
|
...typeof raw["toolName"] === "string" ? { toolName: raw["toolName"] } : {},
|
|
6287
7081
|
...typeof raw["toolCallId"] === "string" ? { toolCallId: raw["toolCallId"] } : {}
|
|
6288
7082
|
};
|
|
6289
7083
|
}
|
|
6290
7084
|
/**
|
|
7085
|
+
* The ONE place a legacy `data` part is recognised as an attachment: the
|
|
7086
|
+
* structural discriminator, the JSON parse, and the lossless CONTENT rule.
|
|
7087
|
+
*
|
|
7088
|
+
* The legal legacy face is `{kind: 'data', name: 'file', content: {text:
|
|
7089
|
+
* '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
|
|
7090
|
+
* `content.text`, observed on the wire (2.1.7 desktop, image upload,
|
|
7091
|
+
* 2026-10-22). An OBJECT inside `content` is not producible by a conforming
|
|
7092
|
+
* peer at all: the pinned protocol 0.7.0 types `content` as a strict
|
|
7093
|
+
* `{text}` | `{ref}` union, so the old documentation described a shape no
|
|
7094
|
+
* peer can emit — and the real one was never lifted, which is how an image
|
|
7095
|
+
* upload reached a model as a JSON blob.
|
|
7096
|
+
*
|
|
7097
|
+
* WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
|
|
7098
|
+
* desktop labels every `data` part with its data KIND in `name` — an
|
|
7099
|
+
* attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
|
|
7100
|
+
* error is `'data-error'` — and the protocol types that field as an
|
|
7101
|
+
* `opaqueId`. This lift therefore REQUIRES the label to be the literal
|
|
7102
|
+
* `'file'` rather than ASSUMING every peer sets one: a `data` part that
|
|
7103
|
+
* carries no name reads as no name, is not lifted, and passes through
|
|
7104
|
+
* untouched. Several of those payloads are JSON too, so "the text parses"
|
|
7105
|
+
* proves nothing; `'file'` is the one label that means "this is an
|
|
7106
|
+
* attachment".
|
|
7107
|
+
*
|
|
7108
|
+
* NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
|
|
7109
|
+
* non-record, or carries neither `mediaType` nor `filename` as a string is
|
|
7110
|
+
* not an attachment, and the part passes through COMPLETELY unchanged
|
|
7111
|
+
* (`remainder` is the raw text, no field is lifted) — this runs on every
|
|
7112
|
+
* `data` part of every read, so a throw here would take down the whole
|
|
7113
|
+
* snapshot.
|
|
7114
|
+
*
|
|
7115
|
+
* THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
|
|
7116
|
+
* this face projects (`mediaType`, `filename`) — the metadata is fully
|
|
7117
|
+
* represented, and a model sees structure rather than a blob. A record
|
|
7118
|
+
* carrying ANY other key keeps its raw text, so a field we do not project is
|
|
7119
|
+
* never silently dropped (lossless by default). That promise is about KEYS: a
|
|
7120
|
+
* key we DO project whose value is not a string (say `mediaType: 123`) is
|
|
7121
|
+
* counted as represented — the key survives — and reports no field, because
|
|
7122
|
+
* this face lifts strings only. Emptied text therefore means "every key was
|
|
7123
|
+
* one of ours", not "every value produced a field".
|
|
7124
|
+
*/
|
|
7125
|
+
#legacyAttachment(kind, raw, text) {
|
|
7126
|
+
if (kind !== "data" || optionalString(raw["name"]) !== "file") return void 0;
|
|
7127
|
+
let parsed;
|
|
7128
|
+
try {
|
|
7129
|
+
parsed = JSON.parse(text);
|
|
7130
|
+
} catch {
|
|
7131
|
+
return;
|
|
7132
|
+
}
|
|
7133
|
+
if (!isRecord(parsed)) return void 0;
|
|
7134
|
+
if (optionalString(parsed["filename"]) === void 0 && optionalString(parsed["mediaType"]) === void 0) return;
|
|
7135
|
+
const everythingLifted = Object.keys(parsed).every((key) => key === "mediaType" || key === "filename");
|
|
7136
|
+
return {
|
|
7137
|
+
record: parsed,
|
|
7138
|
+
remainder: everythingLifted ? "" : text
|
|
7139
|
+
};
|
|
7140
|
+
}
|
|
7141
|
+
/**
|
|
6291
7142
|
* The descriptive metadata of an attachment part — and deliberately NOT a
|
|
6292
7143
|
* way to reach its bytes.
|
|
6293
7144
|
*
|
|
6294
|
-
* Two
|
|
6295
|
-
*
|
|
7145
|
+
* Two legal faces are covered, because both are inside the verified range and
|
|
7146
|
+
* they disagree about where the metadata lives:
|
|
6296
7147
|
*
|
|
6297
7148
|
* - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
|
|
6298
|
-
* - **
|
|
7149
|
+
* - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
|
|
7150
|
+
* the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
|
|
7151
|
+
* parses it and passes the record in here.
|
|
7152
|
+
*
|
|
7153
|
+
* A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
|
|
7154
|
+
* inside `content` — is schema-ILLEGAL under the pinned protocol (see the
|
|
7155
|
+
* docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
|
|
7156
|
+
* It is not the 2.1.4 projection: that story was wrong, and a misbehaving
|
|
7157
|
+
* peer's parts are not re-validated client-side, so the guarded arm costs
|
|
7158
|
+
* nothing and covers exactly the peer it was written for.
|
|
6299
7159
|
*
|
|
6300
7160
|
* Only string fields are lifted: a null `filename` (the peer's explicit "no
|
|
6301
7161
|
* name") stays absent rather than becoming the string "null". Anything a
|
|
6302
7162
|
* caller needs beyond this — the bytes — stays behind `agent.content.read`,
|
|
6303
7163
|
* which this plugin does not call for a file part.
|
|
6304
|
-
|
|
6305
|
-
|
|
7164
|
+
*
|
|
7165
|
+
* WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
|
|
7166
|
+
* attachment's record can arrive externalized as `{ref}`, and that ref is the
|
|
7167
|
+
* peer's serialization ENVELOPE, not the attachment's description: its
|
|
7168
|
+
* `mediaType` is the literal `'text/plain'` the envelope is labelled with and
|
|
7169
|
+
* its `byteLength` is the JSON metadata's own byte count. Letting either
|
|
7170
|
+
* outrank the record would report `text/plain` as an image's media type and a
|
|
7171
|
+
* size that is not the file's — so the record wins the media type, and this
|
|
7172
|
+
* face reports NO `byteLength` at all (a legacy `data` part carries none). The
|
|
7173
|
+
* `file` arm is untouched: there the ref IS the file's own description.
|
|
7174
|
+
*/
|
|
7175
|
+
#attachmentFace(kind, raw, content, metadata) {
|
|
6306
7176
|
if (kind !== "file" && kind !== "data") return {};
|
|
6307
7177
|
const ref = isRecord(raw["ref"]) ? raw["ref"] : isRecord(content["ref"]) ? content["ref"] : {};
|
|
6308
|
-
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(content["filename"]);
|
|
6309
|
-
const mediaType = optionalString(ref["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
6310
|
-
const byteLength = optionalString(ref["byteLength"]);
|
|
7178
|
+
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(metadata?.["filename"]) ?? optionalString(content["filename"]);
|
|
7179
|
+
const mediaType = kind === "data" && metadata !== void 0 ? optionalString(metadata["mediaType"]) ?? optionalString(content["mediaType"]) : optionalString(ref["mediaType"]) ?? optionalString(metadata?.["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
7180
|
+
const byteLength = kind === "file" ? optionalString(ref["byteLength"]) : void 0;
|
|
6311
7181
|
return {
|
|
6312
7182
|
...name === void 0 ? {} : { name },
|
|
6313
7183
|
...mediaType === void 0 ? {} : { mediaType },
|
|
@@ -6395,7 +7265,7 @@ var CherryToolEngine = class {
|
|
|
6395
7265
|
const declaredBytes = Number(ref["byteLength"]);
|
|
6396
7266
|
const refusal = this.#externalizedRefusal(ref);
|
|
6397
7267
|
if (refusal !== void 0) throw refusal;
|
|
6398
|
-
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes /
|
|
7268
|
+
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes)) : DEFAULT_CONTENT_SLICE_BOUND;
|
|
6399
7269
|
let offset = "0";
|
|
6400
7270
|
let sha;
|
|
6401
7271
|
let complete = false;
|
|
@@ -6408,7 +7278,7 @@ var CherryToolEngine = class {
|
|
|
6408
7278
|
contentId,
|
|
6409
7279
|
revision,
|
|
6410
7280
|
offset,
|
|
6411
|
-
maxBytes:
|
|
7281
|
+
maxBytes: this.#contentReadSliceBytes
|
|
6412
7282
|
}, deadline === void 0 ? void 0 : { deadline: deadline.instant });
|
|
6413
7283
|
const data = typeof slice["dataBase64"] === "string" ? slice["dataBase64"] : "";
|
|
6414
7284
|
const cap = this.#externalizedByteCap;
|
|
@@ -6492,7 +7362,7 @@ var CherryToolEngine = class {
|
|
|
6492
7362
|
try {
|
|
6493
7363
|
await subscriptions.ack(sessionId, result.nextCursor);
|
|
6494
7364
|
} catch (error) {
|
|
6495
|
-
console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed
|
|
7365
|
+
console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` + failureText(error));
|
|
6496
7366
|
this.#reEnsureInBackground(record.deviceId, sessionId);
|
|
6497
7367
|
}
|
|
6498
7368
|
return {
|
|
@@ -6701,11 +7571,29 @@ var CherryToolEngine = class {
|
|
|
6701
7571
|
*/
|
|
6702
7572
|
async #admitSend(connection, subscriptions, deviceId, sessionId, request) {
|
|
6703
7573
|
const { args, text } = request;
|
|
7574
|
+
const callerId = optionalString(args["commandId"]);
|
|
6704
7575
|
const attempt = async (commandId, revisionOverride) => {
|
|
6705
|
-
const
|
|
7576
|
+
const remembered = callerId === void 0 || commandId !== callerId ? void 0 : this.#recallCommandBody(deviceId, callerId);
|
|
7577
|
+
if (remembered !== void 0 && remembered.method !== SEND_METHOD) throw commandBodyDiffers();
|
|
7578
|
+
const rememberedBody = remembered?.body;
|
|
7579
|
+
if (remembered !== void 0 && (rememberedBody?.["sessionId"] !== sessionId || rememberedBody["text"] !== text)) throw commandBodyDiffers();
|
|
7580
|
+
const expectedIdleRevision = optionalString(rememberedBody?.["expectedIdleRevision"]) ?? revisionOverride ?? await this.#idleRevision(connection, subscriptions, deviceId, sessionId);
|
|
6706
7581
|
const baselineSeq = this.#newestSeq(subscriptions, sessionId);
|
|
7582
|
+
if (callerId !== void 0) this.#rememberCommandBody(deviceId, callerId, {
|
|
7583
|
+
method: SEND_METHOD,
|
|
7584
|
+
fingerprint: {
|
|
7585
|
+
sessionId,
|
|
7586
|
+
text
|
|
7587
|
+
},
|
|
7588
|
+
body: {
|
|
7589
|
+
commandId,
|
|
7590
|
+
sessionId,
|
|
7591
|
+
text,
|
|
7592
|
+
expectedIdleRevision
|
|
7593
|
+
}
|
|
7594
|
+
});
|
|
6707
7595
|
return {
|
|
6708
|
-
receipt: await this.#command(connection,
|
|
7596
|
+
receipt: await this.#command(connection, SEND_METHOD, {
|
|
6709
7597
|
commandId,
|
|
6710
7598
|
sessionId,
|
|
6711
7599
|
text,
|
|
@@ -6956,6 +7844,19 @@ var CherryToolEngine = class {
|
|
|
6956
7844
|
message: "`response` must be {kind:\"answer\", answers:{<question text>: <answer>}}"
|
|
6957
7845
|
};
|
|
6958
7846
|
}
|
|
7847
|
+
const callerId = optionalString(args["commandId"]);
|
|
7848
|
+
const answer = response === void 0 ? { decision } : { response };
|
|
7849
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7850
|
+
if (remembered !== void 0 && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers();
|
|
7851
|
+
if (remembered !== void 0) {
|
|
7852
|
+
const fingerprint = {
|
|
7853
|
+
sessionId,
|
|
7854
|
+
interactionId,
|
|
7855
|
+
...answer
|
|
7856
|
+
};
|
|
7857
|
+
if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers();
|
|
7858
|
+
return this.#command(connection, RESPOND_METHOD, remembered.body);
|
|
7859
|
+
}
|
|
6959
7860
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
6960
7861
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
6961
7862
|
const snapshot = await this.#interactionSnapshot(connection, subscriptions, record.deviceId, sessionId, interactionId);
|
|
@@ -6963,15 +7864,25 @@ var CherryToolEngine = class {
|
|
|
6963
7864
|
error: "unknown-interaction",
|
|
6964
7865
|
message: `no interaction ${interactionId} is known for session ${sessionId}`
|
|
6965
7866
|
};
|
|
6966
|
-
|
|
7867
|
+
const body = {
|
|
6967
7868
|
commandId: this.#commandId(args),
|
|
6968
7869
|
sessionId,
|
|
6969
7870
|
interactionId,
|
|
6970
7871
|
expectedRevision: snapshot.revision,
|
|
6971
7872
|
expectedExecutionId: snapshot.executionId,
|
|
6972
7873
|
inputDigest: snapshot.inputDigest,
|
|
6973
|
-
...
|
|
7874
|
+
...answer
|
|
7875
|
+
};
|
|
7876
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7877
|
+
method: RESPOND_METHOD,
|
|
7878
|
+
fingerprint: {
|
|
7879
|
+
sessionId,
|
|
7880
|
+
interactionId,
|
|
7881
|
+
...answer
|
|
7882
|
+
},
|
|
7883
|
+
body
|
|
6974
7884
|
});
|
|
7885
|
+
return this.#command(connection, RESPOND_METHOD, body);
|
|
6975
7886
|
}
|
|
6976
7887
|
});
|
|
6977
7888
|
}
|
|
@@ -7011,6 +7922,17 @@ var CherryToolEngine = class {
|
|
|
7011
7922
|
if (resolved.link === void 0) return resolved.verdict;
|
|
7012
7923
|
const { connection, record } = resolved.link;
|
|
7013
7924
|
const sessionId = requiredString(args, "sessionId");
|
|
7925
|
+
const callerId = optionalString(args["commandId"]);
|
|
7926
|
+
const supplied = optionalString(args["expectedExecutionId"]);
|
|
7927
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7928
|
+
if (remembered !== void 0 && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers();
|
|
7929
|
+
if (remembered !== void 0) {
|
|
7930
|
+
if (!sameCommandValue(remembered.fingerprint, {
|
|
7931
|
+
sessionId,
|
|
7932
|
+
expectedExecutionId: supplied
|
|
7933
|
+
})) throw commandBodyDiffers();
|
|
7934
|
+
return this.#command(connection, CANCEL_METHOD, remembered.body);
|
|
7935
|
+
}
|
|
7014
7936
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
7015
7937
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
7016
7938
|
const expectedExecutionId = await this.#activeExecutionId(connection, subscriptions, record.deviceId, sessionId);
|
|
@@ -7018,18 +7940,26 @@ var CherryToolEngine = class {
|
|
|
7018
7940
|
error: "no-active-execution",
|
|
7019
7941
|
message: `session ${sessionId} has no in-flight execution to cancel`
|
|
7020
7942
|
};
|
|
7021
|
-
const supplied = optionalString(args["expectedExecutionId"]);
|
|
7022
7943
|
if (supplied !== void 0 && supplied !== expectedExecutionId) return {
|
|
7023
7944
|
error: "execution-mismatch",
|
|
7024
7945
|
expectedExecutionId,
|
|
7025
7946
|
supplied,
|
|
7026
7947
|
message: `the cached active execution is ${expectedExecutionId}, not ${supplied}; re-read the session before cancelling`
|
|
7027
7948
|
};
|
|
7028
|
-
|
|
7949
|
+
const body = {
|
|
7029
7950
|
commandId: this.#commandId(args),
|
|
7030
7951
|
sessionId,
|
|
7031
7952
|
expectedExecutionId
|
|
7953
|
+
};
|
|
7954
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7955
|
+
method: CANCEL_METHOD,
|
|
7956
|
+
fingerprint: {
|
|
7957
|
+
sessionId,
|
|
7958
|
+
expectedExecutionId: supplied
|
|
7959
|
+
},
|
|
7960
|
+
body
|
|
7032
7961
|
});
|
|
7962
|
+
return this.#command(connection, CANCEL_METHOD, body);
|
|
7033
7963
|
}
|
|
7034
7964
|
});
|
|
7035
7965
|
}
|
|
@@ -7120,7 +8050,7 @@ var CherryToolEngine = class {
|
|
|
7120
8050
|
const selected = this.#selectRecords(selector);
|
|
7121
8051
|
if (!Array.isArray(selected)) return selected;
|
|
7122
8052
|
const record = selected[0];
|
|
7123
|
-
this.#forgetDevice(record.deviceId);
|
|
8053
|
+
this.#forgetDevice(record.deviceId, { dropCommandBodies: true });
|
|
7124
8054
|
await this.#deps.evict(record.deviceId);
|
|
7125
8055
|
try {
|
|
7126
8056
|
this.#deps.store.remove(record.deviceId);
|
|
@@ -7388,7 +8318,8 @@ var CherryToolEngine = class {
|
|
|
7388
8318
|
* the stored credential no longer describes a valid pairing. A transient
|
|
7389
8319
|
* network drop ALSO lands here and clears the caches unnecessarily; that
|
|
7390
8320
|
* is accepted, because the cost is one round trip per session on reuse and
|
|
7391
|
-
* the alternative is keeping snapshots across an identity change.
|
|
8321
|
+
* the alternative is keeping snapshots across an identity change. (The
|
|
8322
|
+
* remembered command BODIES are the one exception: see `#forgetDevice`.)
|
|
7392
8323
|
* - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
|
|
7393
8324
|
*
|
|
7394
8325
|
* {@link CherryToolEngine.#resolve} installs the watch on every tool call that
|
|
@@ -7411,7 +8342,7 @@ var CherryToolEngine = class {
|
|
|
7411
8342
|
this.#watchedConnections.add(connection);
|
|
7412
8343
|
watch(deviceId, (state) => {
|
|
7413
8344
|
if (state !== "offline" && state !== "identity-mismatch") return;
|
|
7414
|
-
this.#forgetDevice(deviceId);
|
|
8345
|
+
this.#forgetDevice(deviceId, { dropCommandBodies: state === "identity-mismatch" });
|
|
7415
8346
|
});
|
|
7416
8347
|
}
|
|
7417
8348
|
/**
|
|
@@ -7423,13 +8354,106 @@ var CherryToolEngine = class {
|
|
|
7423
8354
|
* leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
|
|
7424
8355
|
* the previous pairing's snapshots and a long-lived process does not
|
|
7425
8356
|
* accumulate entries for devices that no longer exist.
|
|
8357
|
+
*
|
|
8358
|
+
* The remembered command bodies obey a NARROWER seam than the read caches:
|
|
8359
|
+
* `dropCommandBodies` is passed by the callers that invalidate the PAIRING
|
|
8360
|
+
* (unpair, identity-mismatch), never by an ordinary `offline`. What a body
|
|
8361
|
+
* replays against — the peer's command ledger — survives a reconnect, and the
|
|
8362
|
+
* body is the only local copy of the exact bytes that ledger will accept, so
|
|
8363
|
+
* a link drop that emptied it would cost the recovery itself, where the read
|
|
8364
|
+
* caches merely pay one re-walk.
|
|
7426
8365
|
*/
|
|
7427
|
-
#forgetDevice(deviceId) {
|
|
8366
|
+
#forgetDevice(deviceId, options = {}) {
|
|
7428
8367
|
const prefix = `${deviceId}\u0000`;
|
|
7429
8368
|
for (const key of [...this.#revisionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key);
|
|
7430
8369
|
for (const key of [...this.#executionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#executionCache, key);
|
|
7431
8370
|
for (const key of [...this.#interactionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#interactionCache, key);
|
|
7432
8371
|
for (const key of [...this.#messageCache.keys()]) if (key.startsWith(prefix)) this.#dropCachedMessage(key);
|
|
8372
|
+
if (options.dropCommandBodies === true) {
|
|
8373
|
+
for (const key of [...this.#commandBodies.keys()]) if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key);
|
|
8374
|
+
}
|
|
8375
|
+
}
|
|
8376
|
+
/**
|
|
8377
|
+
* The command remembered for one caller-supplied `commandId`, keyed
|
|
8378
|
+
* `<deviceId>\0<commandId>` — or `undefined` when this device has never
|
|
8379
|
+
* emitted one (a first attempt, or an entry the FIFO evicted).
|
|
8380
|
+
*
|
|
8381
|
+
* The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
|
|
8382
|
+
* a miss:
|
|
8383
|
+
*
|
|
8384
|
+
* - no entry → a fresh call;
|
|
8385
|
+
* - an entry under the caller's own method → match the fingerprint, then
|
|
8386
|
+
* replay or refuse;
|
|
8387
|
+
* - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
|
|
8388
|
+
* minted for `agent.messages.send` is not a respond retry, and collapsing
|
|
8389
|
+
* that case into "no entry" would send a respond under a send's id and
|
|
8390
|
+
* let the PEER answer the conflict one round trip later.
|
|
8391
|
+
*/
|
|
8392
|
+
#recallCommandBody(deviceId, commandId) {
|
|
8393
|
+
return this.#commandBodies.get(this.#cacheKey(deviceId, commandId));
|
|
8394
|
+
}
|
|
8395
|
+
/**
|
|
8396
|
+
* TEST SEAM — the remembered bodies, as one `commandId` per device.
|
|
8397
|
+
*
|
|
8398
|
+
* The memory itself is PRIVATE, and stays that way for callers. What a test
|
|
8399
|
+
* needs to assert is its bookkeeping — that the bound evicts the oldest entry
|
|
8400
|
+
* and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
|
|
8401
|
+
* and neither can be observed through a real retry: a retry of an evicted id
|
|
8402
|
+
* is indistinguishable from a retry of an absent one, because both are refused
|
|
8403
|
+
* locally by the same rule. Keys are rendered WITHOUT the device component so
|
|
8404
|
+
* a test reads ids, not cache internals.
|
|
8405
|
+
*/
|
|
8406
|
+
rememberedCommandIds(deviceId) {
|
|
8407
|
+
const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`;
|
|
8408
|
+
return [...this.#commandBodies.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
8409
|
+
}
|
|
8410
|
+
/**
|
|
8411
|
+
* TEST SEAM — fill the memory the way a long-lived process would: one entry
|
|
8412
|
+
* per id, WITHOUT spending a wire call on each. The bound and the FIFO order
|
|
8413
|
+
* are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
|
|
8414
|
+
* is what makes this a test of the real policy rather than of a mock of it.
|
|
8415
|
+
*/
|
|
8416
|
+
rememberCommandBodyForTest(deviceId, commandId) {
|
|
8417
|
+
this.#rememberCommandBody(deviceId, commandId, {
|
|
8418
|
+
method: "agent.messages.send",
|
|
8419
|
+
fingerprint: {
|
|
8420
|
+
sessionId: "session-for-test",
|
|
8421
|
+
text: "text-for-test"
|
|
8422
|
+
},
|
|
8423
|
+
body: {
|
|
8424
|
+
commandId,
|
|
8425
|
+
sessionId: "session-for-test",
|
|
8426
|
+
text: "text-for-test",
|
|
8427
|
+
expectedIdleRevision: "0"
|
|
8428
|
+
}
|
|
8429
|
+
});
|
|
8430
|
+
}
|
|
8431
|
+
/**
|
|
8432
|
+
* Remembers the exact body one caller-supplied `commandId` was ISSUED with,
|
|
8433
|
+
* evicting this device's oldest entry when it is at the bound.
|
|
8434
|
+
*
|
|
8435
|
+
* FIFO, per device: `Map` insertion order IS the policy, the same deliberate
|
|
8436
|
+
* choice the session caches document, and a `set` on an EXISTING key keeps its
|
|
8437
|
+
* position — a refresh is a rewrite, not a use, so the policy stays
|
|
8438
|
+
* insert-order rather than becoming LRU. One line per eviction, naming the
|
|
8439
|
+
* device and the bound only.
|
|
8440
|
+
*/
|
|
8441
|
+
#rememberCommandBody(deviceId, commandId, remembered) {
|
|
8442
|
+
const key = this.#cacheKey(deviceId, commandId);
|
|
8443
|
+
if (!this.#commandBodies.has(key)) {
|
|
8444
|
+
let own = 0;
|
|
8445
|
+
let oldest;
|
|
8446
|
+
for (const candidate of this.#commandBodies.keys()) {
|
|
8447
|
+
if (!candidate.startsWith(`${deviceId}\u0000`)) continue;
|
|
8448
|
+
own += 1;
|
|
8449
|
+
if (oldest === void 0) oldest = candidate;
|
|
8450
|
+
}
|
|
8451
|
+
if (own >= this.#commandBodyEntriesPerDevice && oldest !== void 0) {
|
|
8452
|
+
this.#commandBodies.delete(oldest);
|
|
8453
|
+
console.warn(`[cherry-remote] remembered command bodies for device ${deviceId} reached their ${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of that commandId now re-derives its body, and the peer decides whether it replays)`);
|
|
8454
|
+
}
|
|
8455
|
+
}
|
|
8456
|
+
this.#commandBodies.set(key, remembered);
|
|
7433
8457
|
}
|
|
7434
8458
|
/**
|
|
7435
8459
|
* The session's current idle revision, from the SUBSCRIPTION CACHE.
|
|
@@ -8139,13 +9163,23 @@ var CherryToolEngine = class {
|
|
|
8139
9163
|
* identity object: the verdict is already known and true, and only the
|
|
8140
9164
|
* content behind it failed.
|
|
8141
9165
|
*
|
|
8142
|
-
* `aggregateError` is
|
|
8143
|
-
*
|
|
8144
|
-
*
|
|
9166
|
+
* `aggregateError` is a TOOL RESULT, not a log line — see
|
|
9167
|
+
* {@link defineCherryTool} for the product decision (issue #161): a result is
|
|
9168
|
+
* the MODEL's signal, so it is not bounded by the logging rule, and the
|
|
9169
|
+
* 200-character clip here is this field's own choice.
|
|
9170
|
+
*
|
|
9171
|
+
* The LOG line IS the logging face, and it renders the failure as ONE bounded
|
|
9172
|
+
* string through {@link failureText} (issue #161). An assembly failure is
|
|
9173
|
+
* usually a peer RPC refusal — its `error.message` is the peer's own text,
|
|
9174
|
+
* capped at 512 characters by the failure schema — and the old line
|
|
9175
|
+
* interpolated it whole: one 512-char refusal made a 654-character line for
|
|
9176
|
+
* the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
|
|
9177
|
+
* RESULT keeps the raw message's first 200 characters; a bound on the log
|
|
9178
|
+
* does not change what the model receives.
|
|
8145
9179
|
*/
|
|
8146
9180
|
#assemblyFailed(identity, error) {
|
|
8147
9181
|
const message = error instanceof Error ? error.message : String(error);
|
|
8148
|
-
console.warn(`[cherry-remote] the ${String(identity["status"])} answer for session ${String(identity["sessionId"])} could not assemble its content (${
|
|
9182
|
+
console.warn(`[cherry-remote] the ${String(identity["status"])} answer for session ${String(identity["sessionId"])} could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`);
|
|
8149
9183
|
return {
|
|
8150
9184
|
...identity,
|
|
8151
9185
|
aggregateError: message.slice(0, 200),
|
|
@@ -8224,15 +9258,18 @@ var CherryToolEngine = class {
|
|
|
8224
9258
|
async #readTerminalMessages(connection, sessionId, session, deadline) {
|
|
8225
9259
|
const historyRevision = optionalString(session["historyRevision"]);
|
|
8226
9260
|
if (historyRevision === void 0) return { messages: [] };
|
|
9261
|
+
const messageList = messageListRequest(connection);
|
|
8227
9262
|
const page = await this.#aggregate(connection, "agent.messages.list", {
|
|
8228
9263
|
sessionId,
|
|
8229
|
-
historyRevision
|
|
9264
|
+
historyRevision,
|
|
9265
|
+
...messageList.params
|
|
8230
9266
|
}, (result) => ({
|
|
8231
9267
|
...arrayOf(result["items"]),
|
|
8232
9268
|
nextCursor: result["nextCursor"]
|
|
8233
9269
|
}), {
|
|
8234
9270
|
limit: TERMINAL_AGGREGATE_MESSAGES,
|
|
8235
|
-
deadline
|
|
9271
|
+
deadline,
|
|
9272
|
+
maxPerPage: messageList.maxPerPage
|
|
8236
9273
|
});
|
|
8237
9274
|
const messages = [];
|
|
8238
9275
|
let projectedBytes = 0;
|
|
@@ -8290,11 +9327,11 @@ var CherryToolEngine = class {
|
|
|
8290
9327
|
try {
|
|
8291
9328
|
layer = this.#deps.subs(deviceId);
|
|
8292
9329
|
} catch (error) {
|
|
8293
|
-
console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}
|
|
9330
|
+
console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`);
|
|
8294
9331
|
return;
|
|
8295
9332
|
}
|
|
8296
9333
|
layer.ensure(sessionId).catch((error) => {
|
|
8297
|
-
console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed
|
|
9334
|
+
console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
|
|
8298
9335
|
});
|
|
8299
9336
|
}
|
|
8300
9337
|
/**
|
|
@@ -9826,9 +10863,20 @@ function normalizeHost(raw) {
|
|
|
9826
10863
|
return raw.slice(1, -1);
|
|
9827
10864
|
}
|
|
9828
10865
|
/**
|
|
9829
|
-
* Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract
|
|
9830
|
-
*
|
|
9831
|
-
* semantics
|
|
10866
|
+
* Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
|
|
10867
|
+
*
|
|
10868
|
+
* The map semantics this face owes (the record's per-host maps are DERIVED,
|
|
10869
|
+
* never patched directly — there is no `ports` field in this body):
|
|
10870
|
+
*
|
|
10871
|
+
* - neither `hosts` nor `port` patched → the maps are unchanged;
|
|
10872
|
+
* - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
|
|
10873
|
+
* host gets `ws` and the record-level port;
|
|
10874
|
+
* - a CHANGED `port` (with or without `hosts`) → EVERY final host is
|
|
10875
|
+
* re-pointed to it. The face has one port field, so leaving per-host
|
|
10876
|
+
* entries would make the edit inert for every listed host that already had
|
|
10877
|
+
* one. Sending the CURRENT value is a no-op for the maps — the settings page
|
|
10878
|
+
* sends both fields on every save, and a mixed-port record must not be
|
|
10879
|
+
* flattened by a save that did not change the port.
|
|
9832
10880
|
*/
|
|
9833
10881
|
function parsePatch(body) {
|
|
9834
10882
|
const patch = {};
|
|
@@ -9852,6 +10900,7 @@ function parsePatch(body) {
|
|
|
9852
10900
|
if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
|
|
9853
10901
|
return normalizeHost(entry);
|
|
9854
10902
|
});
|
|
10903
|
+
if (new Set(patch.hosts).size !== patch.hosts.length) throw new PatchError("hosts must not contain duplicates");
|
|
9855
10904
|
}
|
|
9856
10905
|
const port = body["port"];
|
|
9857
10906
|
if (port !== void 0) {
|
|
@@ -10250,7 +11299,9 @@ function listDevices(res, route) {
|
|
|
10250
11299
|
state: projectState(device, route.deps.connect),
|
|
10251
11300
|
enabled: device.enabled,
|
|
10252
11301
|
hosts: [...device.hosts],
|
|
10253
|
-
port: device.port
|
|
11302
|
+
port: device.port,
|
|
11303
|
+
security: { ...device.security },
|
|
11304
|
+
ports: { ...device.ports }
|
|
10254
11305
|
})));
|
|
10255
11306
|
}
|
|
10256
11307
|
/**
|
|
@@ -10420,11 +11471,13 @@ function deviceById(deps, deviceId) {
|
|
|
10420
11471
|
const found = deps.store.list().find((device) => device.deviceId === deviceId);
|
|
10421
11472
|
if (found !== void 0) return found;
|
|
10422
11473
|
return {
|
|
10423
|
-
schemaVersion:
|
|
11474
|
+
schemaVersion: 4,
|
|
10424
11475
|
deviceId,
|
|
10425
11476
|
displayName: "",
|
|
10426
11477
|
hosts: [],
|
|
10427
11478
|
port: 0,
|
|
11479
|
+
security: {},
|
|
11480
|
+
ports: {},
|
|
10428
11481
|
desktopIdentity: "",
|
|
10429
11482
|
grants: [],
|
|
10430
11483
|
enabled: false
|
|
@@ -10474,12 +11527,19 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
10474
11527
|
const previousEnabled = existing.enabled;
|
|
10475
11528
|
const previousHosts = existing.hosts;
|
|
10476
11529
|
const previousPort = existing.port;
|
|
11530
|
+
const previousSecurity = existing.security;
|
|
11531
|
+
const previousPorts = existing.ports;
|
|
11532
|
+
const nextPorts = patch.port !== void 0 && patch.port !== previousPort ? portsFor(patch.hosts ?? previousHosts, {}, patch.port) : patch.hosts === void 0 ? previousPorts : portsFor(patch.hosts, previousPorts, previousPort);
|
|
10477
11533
|
const next = {
|
|
10478
11534
|
...existing,
|
|
10479
11535
|
...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
|
|
10480
11536
|
...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
|
|
10481
|
-
...patch.hosts === void 0 ? {} : {
|
|
10482
|
-
|
|
11537
|
+
...patch.hosts === void 0 ? {} : {
|
|
11538
|
+
hosts: patch.hosts,
|
|
11539
|
+
security: securityFor(patch.hosts, previousSecurity)
|
|
11540
|
+
},
|
|
11541
|
+
...patch.port === void 0 ? {} : { port: patch.port },
|
|
11542
|
+
ports: nextPorts
|
|
10483
11543
|
};
|
|
10484
11544
|
try {
|
|
10485
11545
|
route.deps.store.upsert(next);
|
|
@@ -10488,7 +11548,7 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
10488
11548
|
writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
|
|
10489
11549
|
return;
|
|
10490
11550
|
}
|
|
10491
|
-
const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort;
|
|
11551
|
+
const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort || !sameSecurity(next.security, previousSecurity);
|
|
10492
11552
|
const enabledChanged = patch.enabled !== void 0 && patch.enabled !== previousEnabled;
|
|
10493
11553
|
if (addressChanged) evict(route, deviceId);
|
|
10494
11554
|
if (enabledChanged) hibernate(route, deviceId);
|
|
@@ -10508,18 +11568,49 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
10508
11568
|
state,
|
|
10509
11569
|
enabled: next.enabled,
|
|
10510
11570
|
hosts: [...next.hosts],
|
|
10511
|
-
port: next.port
|
|
11571
|
+
port: next.port,
|
|
11572
|
+
security: { ...next.security },
|
|
11573
|
+
ports: { ...next.ports }
|
|
10512
11574
|
});
|
|
10513
11575
|
}
|
|
10514
11576
|
function warnConnect(deviceId) {
|
|
10515
11577
|
return (error) => {
|
|
10516
|
-
console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed
|
|
11578
|
+
console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`);
|
|
10517
11579
|
};
|
|
10518
11580
|
}
|
|
10519
11581
|
/** Order-sensitive host comparison: `hosts` is an ordered candidate list. */
|
|
10520
11582
|
function sameHosts(a, b) {
|
|
10521
11583
|
return a.length === b.length && a.every((host, index) => host === b[index]);
|
|
10522
11584
|
}
|
|
11585
|
+
/**
|
|
11586
|
+
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
11587
|
+
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
11588
|
+
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
11589
|
+
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
11590
|
+
* demote a TLS-proxied desktop to a plain dial.
|
|
11591
|
+
*/
|
|
11592
|
+
function securityFor(hosts, previous) {
|
|
11593
|
+
return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : "ws"]));
|
|
11594
|
+
}
|
|
11595
|
+
/**
|
|
11596
|
+
* The port map for a host list (issue #135): each named host keeps the port it
|
|
11597
|
+
* was dialed with, a host the list just gained defaults to `fallback` (the
|
|
11598
|
+
* record-level port), and hosts the list dropped leave no entry behind.
|
|
11599
|
+
*
|
|
11600
|
+
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
11601
|
+
* `port` patch): there is no remembered entry to keep, by design — one port
|
|
11602
|
+
* field on this face must move the whole list. Same `Object.hasOwn` rule as
|
|
11603
|
+
* `securityFor`: an inherited function is not a remembered port.
|
|
11604
|
+
*/
|
|
11605
|
+
function portsFor(hosts, previous, fallback) {
|
|
11606
|
+
return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]));
|
|
11607
|
+
}
|
|
11608
|
+
/** Order-insensitive comparison for two scheme maps. */
|
|
11609
|
+
function sameSecurity(a, b) {
|
|
11610
|
+
const keys = Object.keys(a);
|
|
11611
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
11612
|
+
return keys.every((key) => a[key] === b[key]);
|
|
11613
|
+
}
|
|
10523
11614
|
/** Drops the pooled connection, closing any live socket first. */
|
|
10524
11615
|
function evict(route, deviceId) {
|
|
10525
11616
|
try {
|
|
@@ -10634,11 +11725,12 @@ function openEvents(req, res, route) {
|
|
|
10634
11725
|
//#region src/index.ts
|
|
10635
11726
|
const name = "cherry-remote";
|
|
10636
11727
|
const inject = ["tools"];
|
|
10637
|
-
/** Applies the documented fallbacks
|
|
11728
|
+
/** Applies the documented fallbacks and validates the plugin config's keys. */
|
|
10638
11729
|
function resolveBridgeConfig(config = {}) {
|
|
10639
11730
|
return {
|
|
10640
11731
|
rateBudgetPerSecond: positive("rateBudgetPerSecond", config.rateBudgetPerSecond, 32),
|
|
10641
|
-
perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
|
|
11732
|
+
perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
|
|
11733
|
+
wsTls: dialOptions(config.wsTls)
|
|
10642
11734
|
};
|
|
10643
11735
|
}
|
|
10644
11736
|
/**
|
|
@@ -10663,6 +11755,76 @@ function positive(key, value, fallback) {
|
|
|
10663
11755
|
}
|
|
10664
11756
|
return value;
|
|
10665
11757
|
}
|
|
11758
|
+
/** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
|
|
11759
|
+
const WS_TLS_KEYS = /* @__PURE__ */ new Set(["rejectUnauthorized", "ca"]);
|
|
11760
|
+
/** The shape a non-object config value arrived as, for a value-free warning. */
|
|
11761
|
+
function shapeOf(value) {
|
|
11762
|
+
if (value === null) return "null";
|
|
11763
|
+
if (Array.isArray(value)) return "array";
|
|
11764
|
+
return typeof value;
|
|
11765
|
+
}
|
|
11766
|
+
/**
|
|
11767
|
+
* One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
|
|
11768
|
+
*
|
|
11769
|
+
* A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
|
|
11770
|
+
* would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
|
|
11771
|
+
* would load bytes the operator never inspected. The header check is a SHAPE
|
|
11772
|
+
* check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
|
|
11773
|
+
* and an unparseable body fails the handshake loudly, which is the honest
|
|
11774
|
+
* verdict. Buffers keep the non-empty test with no header requirement: DER and
|
|
11775
|
+
* programmatically built PEM bytes are legitimate `ca` values.
|
|
11776
|
+
*/
|
|
11777
|
+
function certificateEntry(value) {
|
|
11778
|
+
if (typeof value === "string") return value.trimStart().startsWith("-----BEGIN");
|
|
11779
|
+
if (Buffer.isBuffer(value)) return value.length > 0;
|
|
11780
|
+
return false;
|
|
11781
|
+
}
|
|
11782
|
+
/**
|
|
11783
|
+
* A configured TLS trust bag, or `undefined` when absent/unusable.
|
|
11784
|
+
*
|
|
11785
|
+
* ABSENT is the documented default (Node's trust store) and says nothing —
|
|
11786
|
+
* exactly like {@link positive}. A key that is PRESENT but unusable is a
|
|
11787
|
+
* deployment mistake that silently changes which peers are reachable, so it is
|
|
11788
|
+
* warned about by name rather than swallowed — but NEVER by value: a `ca` is
|
|
11789
|
+
* PEM CONTENT, and a certificate must not reach a log.
|
|
11790
|
+
*
|
|
11791
|
+
* FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
|
|
11792
|
+
* bag rather than forwarding the parts that happen to look right. Trust must be
|
|
11793
|
+
* applied AS WRITTEN: a partially applied bag would reach some peers and not
|
|
11794
|
+
* others with nothing telling the operator which half took effect, and their
|
|
11795
|
+
* `ca` intention would be silently replaced by Node's default store. The
|
|
11796
|
+
* fallback is "no override" — the same state as absent.
|
|
11797
|
+
*/
|
|
11798
|
+
function dialOptions(value) {
|
|
11799
|
+
if (value === void 0) return void 0;
|
|
11800
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
11801
|
+
console.warn(`[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options (got ${shapeOf(value)}); ignoring it`);
|
|
11802
|
+
return;
|
|
11803
|
+
}
|
|
11804
|
+
const record = value;
|
|
11805
|
+
const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key));
|
|
11806
|
+
if (unknownKeys.length > 0) {
|
|
11807
|
+
console.warn(`[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(", ")}; only flat rejectUnauthorized / ca are accepted — ignoring the whole object`);
|
|
11808
|
+
return;
|
|
11809
|
+
}
|
|
11810
|
+
const rejectUnauthorized = record["rejectUnauthorized"];
|
|
11811
|
+
if (rejectUnauthorized !== void 0 && typeof rejectUnauthorized !== "boolean") {
|
|
11812
|
+
console.warn("[cherry-remote] config \"wsTls.rejectUnauthorized\" must be a boolean; ignoring the whole object");
|
|
11813
|
+
return;
|
|
11814
|
+
}
|
|
11815
|
+
const ca = record["ca"];
|
|
11816
|
+
if (ca !== void 0) {
|
|
11817
|
+
if (!(Array.isArray(ca) ? ca.length > 0 && ca.every((entry) => certificateEntry(entry)) : certificateEntry(ca))) {
|
|
11818
|
+
console.warn("[cherry-remote] config \"wsTls.ca\" must be PEM CONTENT (the certificate text itself, not a file path) — a \"-----BEGIN\" string, a non-empty Buffer, or a non-empty array of those; ignoring the whole object");
|
|
11819
|
+
return;
|
|
11820
|
+
}
|
|
11821
|
+
}
|
|
11822
|
+
if (rejectUnauthorized === void 0 && ca === void 0) return void 0;
|
|
11823
|
+
return {
|
|
11824
|
+
...rejectUnauthorized === void 0 ? {} : { rejectUnauthorized },
|
|
11825
|
+
...ca === void 0 ? {} : { ca }
|
|
11826
|
+
};
|
|
11827
|
+
}
|
|
10666
11828
|
/** The data directory convention every DSH plugin follows. */
|
|
10667
11829
|
function resolveDataDir(config = {}, env = process.env) {
|
|
10668
11830
|
if (config.dataDir !== void 0 && config.dataDir !== "") return config.dataDir;
|
|
@@ -10704,6 +11866,7 @@ const state = {
|
|
|
10704
11866
|
identityError: void 0,
|
|
10705
11867
|
rateBudgetPerSecond: 32,
|
|
10706
11868
|
perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
|
|
11869
|
+
wsTls: void 0,
|
|
10707
11870
|
connections: /* @__PURE__ */ new Map(),
|
|
10708
11871
|
subscriptions: /* @__PURE__ */ new Map(),
|
|
10709
11872
|
deviceStateListeners: /* @__PURE__ */ new Set(),
|
|
@@ -10769,7 +11932,8 @@ function connectionFor(deviceId) {
|
|
|
10769
11932
|
const connection = new DeviceConnection({
|
|
10770
11933
|
identity: requireIdentity(),
|
|
10771
11934
|
record,
|
|
10772
|
-
rateBudgetPerSecond: state.rateBudgetPerSecond
|
|
11935
|
+
rateBudgetPerSecond: state.rateBudgetPerSecond,
|
|
11936
|
+
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
|
|
10773
11937
|
});
|
|
10774
11938
|
state.connectionsBuilt += 1;
|
|
10775
11939
|
connection.onState((deviceState) => {
|
|
@@ -10790,7 +11954,7 @@ function connectionFor(deviceId) {
|
|
|
10790
11954
|
try {
|
|
10791
11955
|
subsFor(deviceId).handleNotification(method, params);
|
|
10792
11956
|
} catch (error) {
|
|
10793
|
-
console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed
|
|
11957
|
+
console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`);
|
|
10794
11958
|
}
|
|
10795
11959
|
});
|
|
10796
11960
|
state.connections.set(deviceId, connection);
|
|
@@ -10881,6 +12045,30 @@ function subsFor(deviceId) {
|
|
|
10881
12045
|
return created;
|
|
10882
12046
|
}
|
|
10883
12047
|
/**
|
|
12048
|
+
* Bounds the link-recovery line's session-id list (issue #155).
|
|
12049
|
+
*
|
|
12050
|
+
* The number of ENTRIES is bounded only by the peer-advertised hello
|
|
12051
|
+
* `subscriptions` — a number the peer chooses, not one this assembly can size —
|
|
12052
|
+
* and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
|
|
12053
|
+
* `sessions.join(', ')` therefore emitted `entries × 258` characters into one
|
|
12054
|
+
* info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
|
|
12055
|
+
* ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
|
|
12056
|
+
* text is peer-controlled (the ids are echoed back from what the peer accepted
|
|
12057
|
+
* as subscriptions), so it needs a LOCAL bound like every other such log.
|
|
12058
|
+
*
|
|
12059
|
+
* `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
|
|
12060
|
+
* 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
|
|
12061
|
+
* own subscription cap fits WHOLE — the bound bites only on a peer that
|
|
12062
|
+
* advertises more room, or on a malformed over-long id.
|
|
12063
|
+
* `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
|
|
12064
|
+
* is never clipped; only a malformed one is. A list that stops early names the
|
|
12065
|
+
* count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
|
|
12066
|
+
* silently. The bound is on the LOG only — the loop still re-ensures every
|
|
12067
|
+
* session.
|
|
12068
|
+
*/
|
|
12069
|
+
const RECOVERY_SESSIONS_MAX = 320;
|
|
12070
|
+
const RECOVERY_SESSION_ID_MAX = 256;
|
|
12071
|
+
/**
|
|
10884
12072
|
* The reconnect recovery line (ruling 8): every session this device had
|
|
10885
12073
|
* subscribed is re-ensured at its LAST cursor, so the peer replays only what
|
|
10886
12074
|
* this harness has not yet consumed.
|
|
@@ -10894,14 +12082,17 @@ async function recoverSubscriptions(deviceId) {
|
|
|
10894
12082
|
const subscriptions = state.subscriptions.get(deviceId);
|
|
10895
12083
|
if (subscriptions === void 0) return;
|
|
10896
12084
|
const sessions = subscriptions.activeSessions();
|
|
10897
|
-
if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${sessions
|
|
12085
|
+
if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(sessions, {
|
|
12086
|
+
max: RECOVERY_SESSIONS_MAX,
|
|
12087
|
+
itemMax: RECOVERY_SESSION_ID_MAX
|
|
12088
|
+
})}`);
|
|
10898
12089
|
for (const sessionId of sessions) try {
|
|
10899
12090
|
const result = subscriptions.read(sessionId);
|
|
10900
12091
|
const cursor = "events" in result ? result.nextCursor : void 0;
|
|
10901
12092
|
await subscriptions.ensure(sessionId, cursor);
|
|
10902
12093
|
console.info(`[cherry-remote] subscription recovered for ${deviceId} session ${sessionId}` + (cursor === void 0 ? "" : ` at seq ${cursor.seq}`));
|
|
10903
12094
|
} catch (error) {
|
|
10904
|
-
console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed
|
|
12095
|
+
console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
|
|
10905
12096
|
}
|
|
10906
12097
|
}
|
|
10907
12098
|
/** One dep object, shared by both faces, so there is exactly ONE pool. */
|
|
@@ -10929,6 +12120,7 @@ function assemblyDeps() {
|
|
|
10929
12120
|
payload: request.payload,
|
|
10930
12121
|
store: state.store,
|
|
10931
12122
|
...request.name === void 0 ? {} : { name: request.name },
|
|
12123
|
+
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
|
|
10932
12124
|
onVerificationCode: request.onVerificationCode,
|
|
10933
12125
|
onStatus: (stage) => publishPairingProgress({ stage })
|
|
10934
12126
|
});
|
|
@@ -10997,6 +12189,7 @@ function apply(ctx, config = {}) {
|
|
|
10997
12189
|
const bridgeConfig = resolveBridgeConfig(config);
|
|
10998
12190
|
state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond;
|
|
10999
12191
|
state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes;
|
|
12192
|
+
state.wsTls = bridgeConfig.wsTls;
|
|
11000
12193
|
state.store = new DeviceStore(join(dataDir, "devices.json"));
|
|
11001
12194
|
state.recentErrors.length = 0;
|
|
11002
12195
|
state.connectionsBuilt = 0;
|