dsh-cherry-bridge 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5,7 +5,60 @@ 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, remoteConnectPath, remoteFailureSchema } from "@cherrystudio/remote-protocol";
8
+ import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
9
+ import { peerIdFromString } from "@libp2p/peer-id";
10
+ /**
11
+ * Adds the candidate addresses a peer ADVERTISED that this record does not
12
+ * already dial, returning how many hosts were added (issue #143).
13
+ *
14
+ * The rule is ADD-ONLY, deliberately:
15
+ *
16
+ * - a host already in `hosts` is left exactly as it is, INCLUDING its port and
17
+ * its scheme. The peer's snapshot describes where it listens NOW, while a
18
+ * record entry may be an operator's choice (a pinned port, a TLS proxy) that
19
+ * every reconnect must keep honouring; re-pointing it would silently rewrite
20
+ * the operator's address book on a schedule. "Already dials" is judged
21
+ * CASE-INSENSITIVELY: a hostname is case-insensitive, the peer's schema
22
+ * lowercases what it advertises, and a PATCHed record stores what the
23
+ * operator typed — without this, `MyNAS.example` and `mynas.example` would
24
+ * both hold a dial slot. (An IPv6 literal in a different EXPANSION is NOT
25
+ * reconciled here; the PATCH path owns the archive's spelling, and it does
26
+ * not canonicalize either.)
27
+ * - nothing is removed HERE: a host the peer still advertises is re-learned
28
+ * after an operator PATCH drops it, because the peer is the source of truth
29
+ * for where it listens. The operator's answer to that is `forgetEndpoints`
30
+ * (issue #185), whose mark this function honours — addition stays add-only,
31
+ * and forgetting is a separate, explicit decision;
32
+ * - host, port and scheme are appended TOGETHER, which is what keeps the v4
33
+ * one-entry-per-host invariant true. That invariant is enforced on the READ
34
+ * path by `assertValidSecurity`/`assertValidPorts`, and a semantic failure
35
+ * there is NOT a quarantine (that class covers unreadable bytes): the throw
36
+ * escapes `parseArchive` out of `list()`, so a split entry would break every
37
+ * tool until an operator repaired the file;
38
+ * - past {@link MAX_CANDIDATE_HOSTS} nothing is added, and the loop stops
39
+ * rather than scanning the rest.
40
+ *
41
+ * `endpoints` must already have passed the peer's own `advertisedEndpointSchema`
42
+ * (which bounds the port to 1..65535, the scheme to `ws`/`wss`, and rejects a
43
+ * loopback destination), so every value written here is archive-valid without a
44
+ * second validation pass.
45
+ */
46
+ function mergeAdvertisedEndpoints(record, endpoints) {
47
+ const dialed = new Set(record.hosts.map((host) => host.toLowerCase()));
48
+ const forgotten = new Set((record.forgottenHosts ?? []).map((host) => host.toLowerCase()));
49
+ let added = 0;
50
+ for (const endpoint of endpoints) {
51
+ const key = endpoint.host.toLowerCase();
52
+ if (dialed.has(key) || forgotten.has(key)) continue;
53
+ if (record.hosts.length >= 32) break;
54
+ record.hosts.push(endpoint.host);
55
+ record.ports[endpoint.host] = endpoint.port;
56
+ record.security[endpoint.host] = endpoint.security;
57
+ dialed.add(key);
58
+ added += 1;
59
+ }
60
+ return added;
61
+ }
9
62
  const FILE_MODE = 384;
10
63
  /**
11
64
  * Version-to-version migrations, keyed by the version they upgrade from.
@@ -13,15 +66,70 @@ const FILE_MODE = 384;
13
66
  * `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
14
67
  * real schema evolution starts at version 2 after release. A v1 archive is the
15
68
  * current field set minus `schemaVersion`.
16
- */
17
- const MIGRATIONS = { 1: (record) => ({
18
- ...record,
19
- schemaVersion: 2
20
- }) };
69
+ *
70
+ * `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
71
+ * one `port` and no scheme at all, so every host is backfilled `ws` — the
72
+ * plain dial v2 always used. An archive that already carries SOME entries
73
+ * (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
74
+ * record remembers is never overwritten by the default, because that would
75
+ * downgrade a TLS-proxied desktop to a dial that cannot connect.
76
+ *
77
+ * `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
78
+ * shared `port` (the winner candidate's), so the step backfills `ports` from
79
+ * `port` for every host — the only one v3 ever dialed. A half-migrated file
80
+ * keeps the entries it already carries, for the same reason the scheme step
81
+ * does: a remembered port is the address the pairing actually used, and
82
+ * overwriting it would point the host at the winner's port again.
83
+ *
84
+ * **A legacy file is read loosely, this build's own output strictly —
85
+ * deliberately.** Each step fills its map for the CURRENT host list and
86
+ * nothing else: an entry naming a host the older file no longer lists is
87
+ * silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
88
+ * degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
89
+ * port VALUE is not repaired here at all — the migrated entry then continues
90
+ * down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
91
+ * archive. That split is the point: a v2 or v3 file is a supported legacy shape
92
+ * and is normalised into one, while a v4 file is this build's own output and
93
+ * any deviation from it is a hand edit worth failing loudly on.
94
+ */
95
+ const MIGRATIONS = {
96
+ 1: (record) => ({
97
+ ...record,
98
+ schemaVersion: 2
99
+ }),
100
+ 2: (record) => ({
101
+ ...record,
102
+ schemaVersion: 3,
103
+ security: backfilledSecurity(record)
104
+ }),
105
+ 3: (record) => ({
106
+ ...record,
107
+ schemaVersion: 4,
108
+ ports: backfilledPorts(record)
109
+ })
110
+ };
111
+ /** Every host of a v2 record, defaulting anything it did not already say. */
112
+ function backfilledSecurity(disk) {
113
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
114
+ const existing = disk.security ?? {};
115
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : "ws"]));
116
+ }
117
+ /** Every host of a v3 record, defaulting anything it did not already say. */
118
+ function backfilledPorts(disk) {
119
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
120
+ const existing = disk.ports ?? {};
121
+ const fallback = disk.port;
122
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : fallback]));
123
+ }
21
124
  /**
22
- * Every field of the *current* set except `schemaVersion` — v1 is defined as
23
- * the current field set minus the version key, so these are exactly the keys a
24
- * complete v1 archive must carry.
125
+ * The field set of a v1 entry: what the store wrote before it carried a
126
+ * version at all.
127
+ *
128
+ * FROZEN. It must not track the current record — a v1 archive predates every
129
+ * later field, so demanding one here (as `'security'` was, briefly) fails the
130
+ * completeness gate on an archive that is perfectly intact and quarantines it
131
+ * instead of migrating it. Later fields are the migration chain's business
132
+ * (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
25
133
  */
26
134
  const V1_KEYS = [
27
135
  "deviceId",
@@ -32,7 +140,18 @@ const V1_KEYS = [
32
140
  "grants",
33
141
  "enabled"
34
142
  ];
35
- const REQUIRED_KEYS = [...V1_KEYS, "schemaVersion"];
143
+ /**
144
+ * Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
145
+ * this is not a historical snapshot: it grows with the record, and it is what
146
+ * `upsert` and the post-migration exit demand (which is why a v4 entry without
147
+ * `security`/`ports` never reaches its own validator).
148
+ */
149
+ const REQUIRED_KEYS = [
150
+ ...V1_KEYS,
151
+ "security",
152
+ "ports",
153
+ "schemaVersion"
154
+ ];
36
155
  /**
37
156
  * A version-less entry reads as the legacy (v1) shape only when every v1 field
38
157
  * is present. A missing `schemaVersion` alone means v1; a missing
@@ -44,14 +163,116 @@ function hasCompleteFieldSet(disk) {
44
163
  }
45
164
  /** Asserts the required `DeviceRecord` shape. The read path's exit. */
46
165
  function assertValidRecord(disk) {
47
- if (disk.schemaVersion !== 2) throw new Error(`device archive entry has unsupported schemaVersion ${String(disk.schemaVersion)}; expected 2`);
166
+ if (disk.schemaVersion !== 4) throw new Error(`device archive entry has unsupported schemaVersion ${String(disk.schemaVersion)}; expected 4`);
48
167
  const missing = REQUIRED_KEYS.filter((key) => disk[key] === void 0);
49
168
  if (missing.length > 0) throw new Error(`device archive entry is missing required field(s): ${missing.join(", ")}`);
50
169
  if (!Array.isArray(disk.hosts)) throw new Error("device archive entry field hosts must be an array");
170
+ 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");
171
+ assertValidSecurity(disk.hosts, disk.security);
172
+ assertValidPorts(disk.hosts, disk.ports);
173
+ assertValidForgotten(disk.hosts, disk.forgottenHosts);
51
174
  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
175
  return disk;
53
176
  }
54
177
  /**
178
+ * The `security` map must be EXACTLY one entry per host, each a known scheme.
179
+ *
180
+ * Keyed by host, so a record can carry an entry the dial will never consult
181
+ * (a host it no longer lists) and a host the dial will fall back on silently.
182
+ * Both are hand-edited-archive states, and both are rejected rather than
183
+ * repaired in memory: the archive a human edits is the archive the store
184
+ * judges, and a silent repair would hide the very mistake. The message names
185
+ * the offending HOSTS (they are addresses an operator chose, not secrets).
186
+ */
187
+ function assertValidSecurity(hosts, security) {
188
+ if (security === void 0) return;
189
+ if (security === null || typeof security !== "object" || Array.isArray(security)) throw new Error("device archive entry field security must be an object keyed by host");
190
+ const map = security;
191
+ const wanted = new Set(hosts);
192
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
193
+ if (unknownHost !== void 0) throw new Error(`device archive entry field security names host "${unknownHost}", which is not in hosts`);
194
+ for (const host of hosts) {
195
+ const scheme = map[host];
196
+ if (scheme !== "ws" && scheme !== "wss") throw new Error(`device archive entry field security["${host}"] must be "ws" or "wss"`);
197
+ }
198
+ }
199
+ /**
200
+ * The `ports` map must be EXACTLY one entry per host, each an integer port.
201
+ *
202
+ * The same reasoning as {@link assertValidSecurity}: an entry for a host the
203
+ * record no longer lists would never be consulted, and a missing one would
204
+ * leave that host on the record-level (winner's) port — the exact failure the
205
+ * map exists to prevent. Both are rejected rather than repaired. The
206
+ * message names the offending HOST, never the value (a port is not a secret, but
207
+ * echoing a junk value back from a hand-edited file adds nothing actionable).
208
+ */
209
+ function assertValidPorts(hosts, ports) {
210
+ if (ports === void 0) return;
211
+ if (ports === null || typeof ports !== "object" || Array.isArray(ports)) throw new Error("device archive entry field ports must be an object keyed by host");
212
+ const map = ports;
213
+ const wanted = new Set(hosts);
214
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
215
+ if (unknownHost !== void 0) throw new Error(`device archive entry field ports names host "${unknownHost}", which is not in hosts`);
216
+ for (const host of hosts) {
217
+ const port = map[host];
218
+ 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`);
219
+ }
220
+ }
221
+ /**
222
+ * `forgottenHosts`, when present, must be a list of non-empty strings with no
223
+ * case-variant duplicates and no overlap with `hosts`.
224
+ *
225
+ * Each rule refuses a state whose MEANING is ambiguous: two spellings of one
226
+ * host make "is this address suppressed?" depend on which entry the comparison
227
+ * hit first, and a host that is both dialed and forgotten makes the dial list
228
+ * and the suppression list disagree about the same address. The message names
229
+ * the offending host — these are addresses an operator chose, not secrets.
230
+ */
231
+ function assertValidForgotten(hosts, forgotten) {
232
+ if (forgotten === void 0) return;
233
+ if (!Array.isArray(forgotten)) throw new Error("device archive entry field forgottenHosts must be an array");
234
+ const dialed = new Set(hosts.map((host) => host.toLowerCase()));
235
+ const seen = /* @__PURE__ */ new Set();
236
+ for (const entry of forgotten) {
237
+ if (typeof entry !== "string" || entry.length === 0) throw new Error("device archive entry field forgottenHosts must list non-empty strings");
238
+ const key = entry.toLowerCase();
239
+ if (seen.has(key)) throw new Error(`device archive entry lists host "${entry}" in forgottenHosts more than once`);
240
+ if (dialed.has(key)) throw new Error(`device archive entry names host "${entry}" in both hosts and forgottenHosts`);
241
+ seen.add(key);
242
+ }
243
+ }
244
+ /**
245
+ * Repairs ONE cross-build state on the READ path: a host listed in both
246
+ * `hosts` and `forgottenHosts` (issue #185).
247
+ *
248
+ * A build that predates the field PRESERVES it but cannot honour it, so a
249
+ * downgrade followed by a still-advertising desktop appends the host to
250
+ * `hosts` while the mark stays. Upgrading again then yields a record that says
251
+ * both "dial it" and "never dial it". Refusing it would brick every device tool
252
+ * until an operator hand-edits a 0600 file, so the READ path keeps the DIAL
253
+ * list — what the running build actually did — and drops the mark, with a
254
+ * warning naming the device and host (addresses are not secrets).
255
+ *
256
+ * The WRITE paths stay strict: `upsert`, `forgetEndpoints` and
257
+ * `rememberEndpoints` never produce the overlap, and `assertValidRecord`
258
+ * refuses it. This runs ONLY on the read path, before that assertion.
259
+ */
260
+ function reconcileForgottenMarks(disk) {
261
+ const forgotten = disk.forgottenHosts;
262
+ if (!Array.isArray(forgotten) || !Array.isArray(disk.hosts)) return disk;
263
+ const dialed = new Set(disk.hosts.map((host) => host.toLowerCase()));
264
+ const kept = forgotten.filter((host) => !(typeof host === "string" && dialed.has(host.toLowerCase())));
265
+ if (kept.length === forgotten.length) return disk;
266
+ for (const host of forgotten) {
267
+ if (typeof host !== "string" || !dialed.has(host.toLowerCase())) continue;
268
+ console.warn(`device archive entry ${String(disk.deviceId)} lists host "${host}" as both dialed and forgotten; keeping it dialed and dropping the mark (a plugin downgrade can produce this)`);
269
+ }
270
+ return {
271
+ ...disk,
272
+ forgottenHosts: kept
273
+ };
274
+ }
275
+ /**
55
276
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
56
277
  * -> migration -> assert the required `DeviceRecord` type.
57
278
  *
@@ -74,18 +295,18 @@ function parseRecord(raw, archiveVersion) {
74
295
  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
296
  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
297
  let migrated = disk;
77
- for (let step = archiveVersion; step < 2; step += 1) {
298
+ for (let step = archiveVersion; step < 4; step += 1) {
78
299
  const migrate = MIGRATIONS[step];
79
300
  if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
80
301
  migrated = migrate(migrated);
81
302
  }
82
- return assertValidRecord(migrated);
303
+ return assertValidRecord(reconcileForgottenMarks(migrated));
83
304
  }
84
305
  function parseArchive(parsed) {
85
306
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
86
307
  const archive = parsed;
87
308
  if (typeof archive.schemaVersion !== "number") throw new Error("device archive is missing a numeric schemaVersion");
88
- if (archive.schemaVersion > 2) throw new Error(`device archive declares schemaVersion ${archive.schemaVersion}, newer than supported 2; refusing to read it`);
309
+ if (archive.schemaVersion > 4) throw new Error(`device archive declares schemaVersion ${archive.schemaVersion}, newer than supported 4; refusing to read it`);
89
310
  if (!Array.isArray(archive.devices)) throw new Error("device archive is missing a devices array");
90
311
  const archiveVersion = archive.schemaVersion;
91
312
  return {
@@ -95,7 +316,7 @@ function parseArchive(parsed) {
95
316
  }
96
317
  function serializeArchive(devices) {
97
318
  return `${JSON.stringify({
98
- schemaVersion: 2,
319
+ schemaVersion: 4,
99
320
  devices
100
321
  }, null, 2)}\n`;
101
322
  }
@@ -197,6 +418,26 @@ var QuarantinedArchiveError = class QuarantinedArchiveError extends Error {
197
418
  }
198
419
  };
199
420
  /**
421
+ * Refusal raised by {@link DeviceStore.forgetEndpoints} when the operation
422
+ * would leave a record with NO dial candidate.
423
+ *
424
+ * A record with nothing to dial is worse than the stale address the operator
425
+ * wanted gone: every tool would answer `offline` until a RE-PAIR or a PATCH
426
+ * with a non-empty `hosts` list writes a fresh one, and the archive would look
427
+ * healthy the whole time. The check is ATOMIC — the whole call refuses, so a
428
+ * name for the last host cannot remove a different host on its way to failing.
429
+ * Nothing is written when this is thrown.
430
+ */
431
+ var LastCandidateHostError = class extends Error {
432
+ deviceId;
433
+ constructor(deviceId) {
434
+ super(`device ${deviceId}: refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address.`);
435
+ this.name = "LastCandidateHostError";
436
+ this.deviceId = deviceId;
437
+ Object.setPrototypeOf(this, new.target.prototype);
438
+ }
439
+ };
440
+ /**
200
441
  * Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
201
442
  * copy must not mask the original problem) and returns the path it landed at.
202
443
  */
@@ -363,6 +604,137 @@ var DeviceStore = class {
363
604
  const devices = this.#load().filter((record) => record.deviceId !== deviceId);
364
605
  this.#persist(devices);
365
606
  }
607
+ /**
608
+ * Merges freshly learned addresses into one record and writes ONLY when that
609
+ * record gained a host (issue #143), returning the number added.
610
+ *
611
+ * It exists so the enrichment is an ARCHIVE operation rather than an
612
+ * `upsert` the assembly assembles by hand, and so a merge that adds nothing
613
+ * never rewrites (and fsyncs) the archive: `connection.endpoints` is asked on
614
+ * every connect, and a stable desktop would otherwise pay a full atomic write
615
+ * per reconnect. The merge runs on a CLONE and is committed as a new list,
616
+ * mirroring `upsert`'s discipline — the store never adopts a caller's object
617
+ * graph, and a write refused at {@link #persist}'s own gate cannot have
618
+ * half-edited the record the archive still holds.
619
+ *
620
+ * `desktopIdentity` is the pin the answering connection belongs to: the
621
+ * record must still name that desktop. A RE-PAIR under the same `deviceId`
622
+ * (the desktop regenerated its identity) would otherwise take an address
623
+ * learned from the previous pairing — a stale candidate whose every dial then
624
+ * fails the Noise pin, permanently, because learning never removes.
625
+ *
626
+ * An unknown `deviceId` is a no-op — the record may have been removed (or
627
+ * re-paired) between the RPC and this write.
628
+ */
629
+ learnEndpoints(deviceId, endpoints, desktopIdentity) {
630
+ const devices = this.#load();
631
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
632
+ if (index < 0) return 0;
633
+ if (devices[index].desktopIdentity !== desktopIdentity) return 0;
634
+ const merged = structuredClone(devices[index]);
635
+ const added = mergeAdvertisedEndpoints(merged, endpoints);
636
+ if (added === 0) return 0;
637
+ const next = [...devices];
638
+ next[index] = merged;
639
+ this.#persist(next);
640
+ return added;
641
+ }
642
+ /**
643
+ * FORGETS addresses (issue #185): drops each named host from the dial list —
644
+ * host, scheme and port as one unit — and records it so the next
645
+ * advertisement cannot put it back. Returns the hosts that left the dial list
646
+ * (`removed`) and the marks this call added (`marked`).
647
+ *
648
+ * This is the operation learning cannot provide. `connection.endpoints` is
649
+ * asked on every handshake, so an address the desktop still advertises
650
+ * returns after any removal the operator makes by PATCH; forgetting is the
651
+ * explicit decision that outranks it.
652
+ *
653
+ * - Matching is CASE-INSENSITIVE (a PATCHed record keeps the operator's
654
+ * spelling, the peer's schema lowercases what it advertises), and the mark
655
+ * records the ARCHIVE's spelling when the host was dialed, the caller's
656
+ * otherwise.
657
+ * - Naming an address the record does not dial is legal and still marks it:
658
+ * suppression is the point, and a pre-emptive forget must survive until
659
+ * the peer advertises the address.
660
+ * - Removing the LAST candidate throws {@link LastCandidateHostError} and
661
+ * writes NOTHING — no host, no scheme, no port, no mark.
662
+ * - A call that changes no byte (the host was already forgotten) does not
663
+ * rewrite (or fsync) the archive.
664
+ *
665
+ * The write goes through `#persist` on a CLONE with the result re-validated,
666
+ * so a caller-supplied junk host can never land a record the next read would
667
+ * refuse. An unknown `deviceId` is a no-op, like {@link learnEndpoints}.
668
+ */
669
+ forgetEndpoints(deviceId, hosts) {
670
+ const devices = this.#load();
671
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
672
+ if (index < 0) return {
673
+ removed: [],
674
+ marked: []
675
+ };
676
+ const merged = structuredClone(devices[index]);
677
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
678
+ const removed = merged.hosts.filter((host) => wanted.has(host.toLowerCase()));
679
+ if (removed.length > 0 && removed.length === merged.hosts.length) throw new LastCandidateHostError(deviceId);
680
+ for (const host of removed) {
681
+ delete merged.security[host];
682
+ delete merged.ports[host];
683
+ }
684
+ merged.hosts = merged.hosts.filter((host) => !wanted.has(host.toLowerCase()));
685
+ const forgotten = merged.forgottenHosts ?? [];
686
+ const known = new Set(forgotten.map((host) => host.toLowerCase()));
687
+ const marked = [];
688
+ for (const host of hosts) {
689
+ const key = host.toLowerCase();
690
+ if (known.has(key)) continue;
691
+ const spelling = removed.find((entry) => entry.toLowerCase() === key) ?? host;
692
+ forgotten.push(spelling);
693
+ known.add(key);
694
+ marked.push(spelling);
695
+ }
696
+ if (removed.length === 0 && marked.length === 0) return {
697
+ removed: [],
698
+ marked: []
699
+ };
700
+ merged.forgottenHosts = forgotten;
701
+ assertValidRecord(merged);
702
+ const next = [...devices];
703
+ next[index] = merged;
704
+ this.#persist(next);
705
+ return {
706
+ removed,
707
+ marked
708
+ };
709
+ }
710
+ /**
711
+ * Lifts a forget mark (issue #185), returning the marks that were removed.
712
+ *
713
+ * It does NOT dial the host again: it only lets the next handshake's
714
+ * advertisement add it back through the ordinary learning path, with the
715
+ * port and scheme the peer reports at that moment. An operator who wants the
716
+ * address back immediately PATCHes it into `hosts`, which clears the mark on
717
+ * the same write.
718
+ *
719
+ * No-op (and no write) when the device is unknown, nothing is forgotten, or
720
+ * none of the named hosts is marked.
721
+ */
722
+ rememberEndpoints(deviceId, hosts) {
723
+ const devices = this.#load();
724
+ const index = devices.findIndex((record) => record.deviceId === deviceId);
725
+ if (index < 0) return { restored: [] };
726
+ const current = devices[index].forgottenHosts ?? [];
727
+ if (current.length === 0) return { restored: [] };
728
+ const wanted = new Set(hosts.map((host) => host.toLowerCase()));
729
+ const restored = current.filter((host) => wanted.has(host.toLowerCase()));
730
+ if (restored.length === 0) return { restored: [] };
731
+ const merged = structuredClone(devices[index]);
732
+ merged.forgottenHosts = (merged.forgottenHosts ?? []).filter((host) => !wanted.has(host.toLowerCase()));
733
+ const next = [...devices];
734
+ next[index] = merged;
735
+ this.#persist(next);
736
+ return { restored };
737
+ }
366
738
  #load() {
367
739
  if (this.#devices !== void 0) return this.#devices;
368
740
  let archive;
@@ -376,7 +748,7 @@ var DeviceStore = class {
376
748
  }
377
749
  const devices = archive === void 0 ? [] : archive.devices;
378
750
  this.#devices = devices;
379
- if (archive !== void 0 && archive.schemaVersion < 2) this.#persist(devices);
751
+ if (archive !== void 0 && archive.schemaVersion < 4) this.#persist(devices);
380
752
  return devices;
381
753
  }
382
754
  #persist(devices) {
@@ -471,6 +843,124 @@ async function generateIdentity(dataDir, path) {
471
843
  return parseIdentity(key);
472
844
  }
473
845
  //#endregion
846
+ //#region src/text.ts
847
+ /**
848
+ * Bounded rendering for peer- and payload-controlled text (issue #155).
849
+ *
850
+ * The ONE helper this audit STANDARDISED ON — not the first attempt at the
851
+ * idea: `src/diagnostics.ts`'s `truncateErrorMessage` (the diagnostics error
852
+ * digest) and `src/rpc/jsonRpcClient.ts`'s `bounded()` (the frame-drop
853
+ * warning's classification fields) predate it and stay where they are, for
854
+ * their own callers. #155 is where the rendering rule was stated once and
855
+ * applied to the SIX sites the audit found — four files: pairing's unknown-key
856
+ * warning and stage verdict, the link-recovery line, the two connection error
857
+ * lines, and the JSON-RPC notification-handler line. Each was unbounded for a
858
+ * different "why not" (a 10 KiB payload cap, a hello-advertised field, the
859
+ * 64 KiB record budget), which is exactly how the class of defect recurs: each
860
+ * site answered the question for itself.
861
+ *
862
+ * The rule these helpers establish is stated once, here: bound the text where
863
+ * it is RENDERED (never where it is carried — an error message is a value
864
+ * until a log line or a verdict makes it text), name the amount removed with
865
+ * the `… (+N chars)` marker, and never drop a list ENTRY silently (the
866
+ * `(+K more)` count is explicit).
867
+ *
868
+ * Issue #161 swept the SAME SHAPE at the sites #155 did not name — a catch
869
+ * handing an error OBJECT to `console.warn`'s second argument — on the paths
870
+ * PEER traffic reaches. One renderer states the answer there, {@link
871
+ * failureText}: the message alone, bounded. A purely LOCAL catch keeps the
872
+ * object print, deliberately: its stack is the useful part and no peer text
873
+ * can reach it (fs quarantine, the host registry, our own listener / settle
874
+ * fan-out, a REST route's synchronous construction). The two earlier forms it
875
+ * did NOT absorb — `truncateErrorMessage` (the diagnostics digest) and
876
+ * `bounded()` (the frame-drop path) — are left alone: each emits a different
877
+ * marker (`…[truncated]`, a bare `…`) that callers and docs depend on, so
878
+ * folding would change output rather than converge on the rule.
879
+ */
880
+ /**
881
+ * Clips `text` into `max` characters, the `… (+N chars)` marker included in
882
+ * the count. `kept` is a fixed point — the number of removed characters sets
883
+ * the marker's own digits — and it is the LARGEST prefix that fits, so a
884
+ * caller's ceiling is used without ever being exceeded.
885
+ *
886
+ * The final guard never ends the kept prefix between a UTF-16 surrogate pair: a
887
+ * clip there would emit a lone surrogate, which is not valid unicode text (the
888
+ * peer's `unicodeText` params face refuses it) and would travel in a
889
+ * user-visible verdict. Dropping one char pays for a marker that can grow by
890
+ * one digit at a 9→10 boundary, and the marker is re-derived from the new
891
+ * dropped count. Pinned by `tests/unit/text.test.ts` — the branch used to be a
892
+ * declared coverage gap while the clip point was private to pairing.
893
+ */
894
+ function clipText(text, max) {
895
+ if (text.length <= max) return text;
896
+ let kept = max;
897
+ for (;;) {
898
+ const next = max - `… (+${text.length - kept} chars)`.length;
899
+ if (next === kept) break;
900
+ if (next < 0) return "…".slice(0, max);
901
+ kept = next;
902
+ }
903
+ const before = text.charCodeAt(kept - 1);
904
+ const after = text.charCodeAt(kept);
905
+ if (kept > 0 && before >= 55296 && before <= 56319 && after >= 56320 && after <= 57343) kept -= 1;
906
+ return `${text.slice(0, kept)}… (+${text.length - kept} chars)`;
907
+ }
908
+ /**
909
+ * Renders `items` as a comma-joined list under `limits.max`, each item clipped
910
+ * to `limits.itemMax` FIRST. When anything is left out the list says so:
911
+ * ` (+K more)` where K is the number of items NOT named.
912
+ *
913
+ * Caller contract: `itemMax <= max`, so the first item always fits and a list
914
+ * carrying a single over-long entry never renders an empty one. The marker
915
+ * rides ON TOP of `limits.max` — it is a count, not an item, so the named text
916
+ * never has to absorb its length. `[]` renders as `''`.
917
+ *
918
+ * Pairing's progress line (`stageHosts`) deliberately does NOT use this helper:
919
+ * it always shows the FIRST host in full even above its own budget, because
920
+ * that host is the run's identity. That is not an oversight, and it is not a
921
+ * reason to weaken the contract here.
922
+ */
923
+ function boundedJoin(items, limits) {
924
+ const rendered = items.map((item) => clipText(item, limits.itemMax));
925
+ const first = rendered[0];
926
+ if (first === void 0) return "";
927
+ let text = first;
928
+ let shown = 1;
929
+ for (; shown < rendered.length; shown += 1) {
930
+ if (text.length + 2 + rendered[shown].length > limits.max) break;
931
+ text += `, ${rendered[shown]}`;
932
+ }
933
+ const omitted = items.length - shown;
934
+ return omitted > 0 ? `${text} (+${omitted} more)` : text;
935
+ }
936
+ /**
937
+ * Renders ONE caught failure for a log line: the message alone, clipped to
938
+ * {@link LOG_TEXT_MAX}.
939
+ *
940
+ * This is the ONE form a caught failure takes in a PEER-TRIGGERED catch (issue
941
+ * #161): a catch reached from peer traffic or a peer-triggered callback. The
942
+ * old shape — `console.warn(prefix, error)` — handed the console the OBJECT.
943
+ * On Node the console prints the error's name, message and stack (own
944
+ * properties only when inspected), so the peer-controlled message and the
945
+ * stack were both unbounded; for a defensive catch the stack is the noise.
946
+ *
947
+ * A caught failure from a purely LOCAL path (an fs copy, the host registry, our
948
+ * own observers) keeps the object print: its stack is the useful part and no
949
+ * peer text can reach it.
950
+ *
951
+ * TOTAL by construction: it runs INSIDE catch blocks, so a captured value whose
952
+ * `toString` (or an Error subclass whose `message` getter) throws must not
953
+ * escape the catch. The extraction is guarded and falls back to the fixed token
954
+ * `<unrenderable failure>`.
955
+ */
956
+ function failureText(error) {
957
+ try {
958
+ return clipText(error instanceof Error ? error.message : String(error), 240);
959
+ } catch {
960
+ return "<unrenderable failure>";
961
+ }
962
+ }
963
+ //#endregion
474
964
  //#region src/rpc/jsonRpcClient.ts
475
965
  /** Default cap on business requests in flight on a single connection. */
476
966
  const DEFAULT_MAX_IN_FLIGHT = 8;
@@ -896,7 +1386,7 @@ var JsonRpcClient = class {
896
1386
  for (const handler of [...this.handlers]) try {
897
1387
  handler(method, params);
898
1388
  } catch (error) {
899
- console.warn(`[cherry-remote] notification handler for "${method}" threw`, error);
1389
+ console.warn(`[cherry-remote] notification handler for "${bounded(method)}" threw: ${failureText(error)}`);
900
1390
  }
901
1391
  return;
902
1392
  }
@@ -1887,12 +2377,15 @@ function sanitizeDevice(record, state, limits) {
1887
2377
  hosts: [...record.hosts],
1888
2378
  port: record.port,
1889
2379
  desktopIdentity: digestIdentity(record.desktopIdentity),
1890
- ...limits === void 0 ? {} : { limits: {
1891
- heartbeatMs: limits.heartbeatMs,
1892
- subscriptions: limits.subscriptions,
1893
- pageItems: limits.pageItems,
1894
- inFlightRequests: limits.inFlightRequests
1895
- } }
2380
+ ...limits === void 0 ? {} : {
2381
+ limits: {
2382
+ heartbeatMs: limits.heartbeatMs,
2383
+ subscriptions: limits.subscriptions,
2384
+ pageItems: limits.pageItems,
2385
+ inFlightRequests: limits.inFlightRequests
2386
+ },
2387
+ ...limits.messageSummaries === void 0 ? {} : { messageSummaries: limits.messageSummaries }
2388
+ }
1896
2389
  };
1897
2390
  }
1898
2391
  /** Narrows an unknown bag to the report's own shapes, dropping everything else. */
@@ -1907,10 +2400,20 @@ function projectDevice(raw) {
1907
2400
  hosts: Array.isArray(value["hosts"]) ? value["hosts"].map((host) => String(host)) : [],
1908
2401
  port: typeof value["port"] === "number" ? value["port"] : 0,
1909
2402
  desktopIdentity: desktopIdentity === "unknown" || /^[0-9a-f]{12}$/.test(desktopIdentity) ? desktopIdentity : digestIdentity(desktopIdentity),
1910
- ...projectLimits(value["limits"])
2403
+ ...projectLimits(value["limits"]),
2404
+ ...projectMessageSummaries(value["messageSummaries"])
1911
2405
  };
1912
2406
  }
1913
2407
  /**
2408
+ * Re-projects the ONE capability field, dropping anything that is not a finite
2409
+ * number: a version this report cannot read is reported as absent (not
2410
+ * advertised), which is the safe side — the field only ever amplifies what the
2411
+ * peer's hello said.
2412
+ */
2413
+ function projectMessageSummaries(raw) {
2414
+ return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
2415
+ }
2416
+ /**
1914
2417
  * Re-projects the four numeric limit keys, dropping any foreign key that a
1915
2418
  * caller's `limits` object might carry. A whitelist inside the whitelist: the
1916
2419
  * shape is rebuilt field by field rather than spread, so a future addition to
@@ -2305,21 +2808,6 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2305
2808
  */
2306
2809
  const MAX_RESUME_TOKEN_CHARS = 4096;
2307
2810
  //#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
2811
  //#region src/connection.ts
2324
2812
  /**
2325
2813
  * The transport call the dial hands a socket to (see
@@ -2417,6 +2905,33 @@ const DEFAULT_BACKOFF_MAX_MS = 6e4;
2417
2905
  const DEFAULT_REFRESH_GRACE_MS = 3e4;
2418
2906
  const DEFAULT_CONNECT_TIMEOUT_MS$1 = 1e4;
2419
2907
  const DEFAULT_PROTOCOL_VERSIONS = [1];
2908
+ /**
2909
+ * How long the optional `connection.endpoints` enrichment (issue #143) may hold
2910
+ * up ONE handshake before the connection gives up on it.
2911
+ *
2912
+ * The call runs INSIDE the handshake, so without a bound of its own it would
2913
+ * inherit the rpc layer's 60 s default: a peer that advertises
2914
+ * `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
2915
+ * would keep the device in `connecting` for a minute, and every reconnect would
2916
+ * pay it again. THIS layer bounds it because the enrichment is worth
2917
+ * milliseconds, not a retry.
2918
+ *
2919
+ * WHY 2 s. The peer's handler is a local computation
2920
+ * (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
2921
+ * round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
2922
+ * slack.
2923
+ *
2924
+ * The UPPER bound is NOT derived from a claim about the whole handshake: a dial
2925
+ * already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
2926
+ * ride the rpc layer's 60 s default, so `connected` can arrive long after a
2927
+ * tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
2928
+ * derived is narrower and true: THIS wait alone must stay below that window, so
2929
+ * a peer that advertises the capability and then hangs on this one call cannot
2930
+ * by itself push the state flip past the window a tool already waited out. A
2931
+ * timeout costs one reconnect's worth of enrichment and says nothing about the
2932
+ * device.
2933
+ */
2934
+ const ENDPOINTS_LEARN_TIMEOUT_MS = 2e3;
2420
2935
  /** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
2421
2936
  const SILENCE_FACTOR = 2;
2422
2937
  /** The deliberate local-shutdown face of a disposed client (see the replay path). */
@@ -2505,7 +3020,8 @@ var SecondWindowRateLimiter = class {
2505
3020
  async acquire() {
2506
3021
  let release;
2507
3022
  const ahead = this.#queue;
2508
- this.#queue = ahead.then(() => new Promise((resolve) => release = resolve));
3023
+ const next = () => new Promise((resolve) => release = resolve);
3024
+ this.#queue = ahead.then(next, next);
2509
3025
  await ahead;
2510
3026
  try {
2511
3027
  for (;;) {
@@ -2690,6 +3206,50 @@ function businessWindowFor(reportedInFlightRequests) {
2690
3206
  return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
2691
3207
  }
2692
3208
  /**
3209
+ * The destinations a `connection.endpoints` answer may contribute, in the order
3210
+ * the peer listed them (issue #143). Empty when the answer must be discarded.
3211
+ *
3212
+ * TWO answers are refused WHOLE:
3213
+ *
3214
+ * - **the identity pin.** The answer must name the very desktop this record is
3215
+ * pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
3216
+ * static key, so this is not the adversarial case it protects against — it
3217
+ * is the defence against a CONFUSED answer (a proxy, a desktop whose identity
3218
+ * was regenerated, a future protocol that answers for more than one host):
3219
+ * the list decides where this client dials, so a list that cannot be
3220
+ * attributed to our own desktop may not change it;
3221
+ * - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
3222
+ * number is not the pin either, so both fall out of the same comparison.
3223
+ *
3224
+ * One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
3225
+ * destination schema, rejects a LOOPBACK destination (a shared address that
3226
+ * resolves to our own machine would send the next reconnect to the wrong host)
3227
+ * and normalizes the host (case, IPv6), so the archive never stores a spelling
3228
+ * the dial cannot use. One unusable row must not discard its neighbours.
3229
+ *
3230
+ * NOT total on the wire shape, deliberately: a non-object answer or a non-array
3231
+ * `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
3232
+ * turns that into the silent no-op the contract promises. Defending each shape
3233
+ * twice would look safer and be worse: the redundancy masks which guard a test
3234
+ * actually pins (issue #114's lesson), and the whole ANSWER is untrusted
3235
+ * anyway, so the boundary belongs at the caller, where the handshake can see
3236
+ * it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
3237
+ * "no addresses right now" answer (the desktop's `addresses` can be empty).
3238
+ *
3239
+ * No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
3240
+ * the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
3241
+ */
3242
+ function advertisedEndpoints(result, desktopIdentity) {
3243
+ const answer = result;
3244
+ if (answer.desktopIdentity !== desktopIdentity) return [];
3245
+ const accepted = [];
3246
+ for (const raw of answer.endpoints ?? []) {
3247
+ const parsed = advertisedEndpointSchema.safeParse(raw);
3248
+ if (parsed.success) accepted.push(parsed.data);
3249
+ }
3250
+ return accepted;
3251
+ }
3252
+ /**
2693
3253
  * The desktop's content-read allowance: 512/s, and the SAME in both generations
2694
3254
  * (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
2695
3255
  * 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
@@ -2770,6 +3330,7 @@ var DeviceConnection = class {
2770
3330
  #protocolVersions;
2771
3331
  #helloProtocolVersions;
2772
3332
  #transportRetryWaitMs;
3333
+ #onAdvertisedEndpoints;
2773
3334
  #stateListeners = /* @__PURE__ */ new Set();
2774
3335
  /**
2775
3336
  * Callers parked in {@link waitForSettled}. Separate from the state listeners
@@ -2818,8 +3379,10 @@ var DeviceConnection = class {
2818
3379
  #pendingPings = /* @__PURE__ */ new Set();
2819
3380
  #token;
2820
3381
  #tokenExpiresAt;
3382
+ #wsOptions;
2821
3383
  constructor(opts) {
2822
3384
  this.record = opts.record;
3385
+ this.#wsOptions = opts.wsOptions;
2823
3386
  this.#identity = opts.identity;
2824
3387
  this.#limiter = new TieredRateLimiter(opts.rateBudgetPerSecond ?? 32, contentBudgetFor(opts.rateBudgetPerSecond));
2825
3388
  this.#backoffInitialMs = opts.backoffInitialMs ?? DEFAULT_BACKOFF_INITIAL_MS;
@@ -2830,6 +3393,7 @@ var DeviceConnection = class {
2830
3393
  this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS;
2831
3394
  this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions;
2832
3395
  this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? 5e3;
3396
+ this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints;
2833
3397
  }
2834
3398
  /**
2835
3399
  * The business RPC surface: rate-smoothed and gated on `connected`. Typed
@@ -2997,7 +3561,7 @@ var DeviceConnection = class {
2997
3561
  if (error instanceof ProtocolVersionError) {
2998
3562
  this.#running = false;
2999
3563
  this.#setState("offline");
3000
- console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${error.message}); upgrade Cherry Studio / the plugin`);
3564
+ console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${clipText(error.message, 240)}); upgrade Cherry Studio / the plugin`);
3001
3565
  this.#cycle.reject(/* @__PURE__ */ new Error(`device ${this.record.deviceId}: protocol version incompatible`));
3002
3566
  return;
3003
3567
  }
@@ -3015,7 +3579,8 @@ var DeviceConnection = class {
3015
3579
  if (!this.#running) return;
3016
3580
  const delay = this.#backoffDelay();
3017
3581
  const hint = isAllHostsUnreachable(error) ? " — all candidate addresses were unreachable; if the desktop moved, update them via the device card 地址维护 (address maintenance)" : "";
3018
- console.warn(`[cherry-remote] device ${this.record.deviceId} offline (${error instanceof Error ? error.message : String(error)}); reconnecting in ${delay}ms${hint}`);
3582
+ const detail = error instanceof Error ? error.message : String(error);
3583
+ console.warn(`[cherry-remote] device ${this.record.deviceId} offline (${clipText(detail, 240)}); reconnecting in ${delay}ms${hint}`);
3019
3584
  this.#setTimer("backoff", setTimeout(() => this.#tryEstablish(), delay));
3020
3585
  }
3021
3586
  #backoffDelay() {
@@ -3029,6 +3594,12 @@ var DeviceConnection = class {
3029
3594
  * network failures fall through to the next candidate — the §6
3030
3595
  * 「候选地址全部不可达」 verdict only holds when EVERY host failed.
3031
3596
  *
3597
+ * Each candidate dials with ITS OWN port from `record.ports`; the record-level
3598
+ * `port` (the winner's at pairing, the operator's after a PATCH) is the
3599
+ * fallback for an in-memory record assembled without the map (issue #135).
3600
+ * Before that, one port was shared by every host, so a reconnect to a
3601
+ * non-winner host dialed the right scheme at the wrong port.
3602
+ *
3032
3603
  * "Per-host" covers the CHANNEL-LEVEL handshake failures as well as the dial:
3033
3604
  * a socket that opens and then dies mid-handshake (the peer accepted the TCP
3034
3605
  * connection and went away, a proxy reset it, the Noise exchange was cut) is a
@@ -3046,7 +3617,8 @@ var DeviceConnection = class {
3046
3617
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
3047
3618
  let link;
3048
3619
  try {
3049
- link = await this.#dial(host, this.record.port, epoch);
3620
+ const port = this.record.ports?.[host] ?? this.record.port;
3621
+ link = await this.#dial(host, port, epoch);
3050
3622
  } catch (error) {
3051
3623
  if (error instanceof IdentityMismatchError) throw error;
3052
3624
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -3067,8 +3639,11 @@ var DeviceConnection = class {
3067
3639
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
3068
3640
  }
3069
3641
  async #dial(host, port, epoch) {
3070
- const url = `ws://${urlHost(host)}:${port}${remoteConnectPath}`;
3071
- const ws = new WebSocket(url);
3642
+ const ws = new WebSocket(directEndpointUrl({
3643
+ host,
3644
+ port,
3645
+ security: this.record.security?.[host] ?? "ws"
3646
+ }), this.#wsOptions);
3072
3647
  const controller = new AbortController();
3073
3648
  const opened = new Promise((resolve) => ws.once("open", () => resolve()));
3074
3649
  const failure = new Promise((_, reject) => {
@@ -3133,7 +3708,8 @@ var DeviceConnection = class {
3133
3708
  heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
3134
3709
  subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
3135
3710
  pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
3136
- inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests
3711
+ inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
3712
+ ...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
3137
3713
  };
3138
3714
  link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
3139
3715
  let auth;
@@ -3144,6 +3720,7 @@ var DeviceConnection = class {
3144
3720
  if (reason === "GRANT_REVOKED" || reason === "UNAUTHENTICATED") throw new AuthRejectedError(reason);
3145
3721
  throw error instanceof Error ? error : new Error(String(error));
3146
3722
  }
3723
+ await this.#learnEndpoints(link, hello.connectionEndpointsVersion);
3147
3724
  if (epoch !== this.#epoch) {
3148
3725
  client.dispose();
3149
3726
  link.channel.close().catch(noop$1);
@@ -3167,6 +3744,51 @@ var DeviceConnection = class {
3167
3744
  console.info(`[cherry-remote] device ${this.record.deviceId} connected (${digestIdentity(link.channel.remoteIdentity)})`);
3168
3745
  }
3169
3746
  /**
3747
+ * Best-effort enrichment of the dial list from the peer's advertised
3748
+ * endpoints (issue #143).
3749
+ *
3750
+ * WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
3751
+ * address that appeared after the QR was minted. It does NOT heal a total
3752
+ * address change (the list is captured on the old path) and can never carry
3753
+ * TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
3754
+ * comes from the QR or the operator.
3755
+ *
3756
+ * EVERY failure here is SILENT and non-fatal, because this is an enrichment,
3757
+ * not a verdict: a peer that does not advertise the capability (`connection`
3758
+ * through 2.1.3 has neither the hello bit nor this method), one advertising a
3759
+ * version this client cannot speak (strictly `1` — a higher number is not
3760
+ * evidence that it speaks 1, the same rule the paged-summaries bit took), a
3761
+ * refused call (the desktop throws when its gateway is not running), a wedged
3762
+ * one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
3763
+ * answer, or a store that cannot write. None of them is a CONNECTION failure:
3764
+ * the link comes up either way, and only the archive is left without the new
3765
+ * addresses — this instance's own record may already carry them, because the
3766
+ * merge runs before the callback (see the paragraph below).
3767
+ *
3768
+ * The MERGE happens in two places on purpose: here (this instance's record,
3769
+ * so its next reconnect dials the new address) and through the callback (the
3770
+ * assembly's archive, so a restart keeps it). They are the same pure rule
3771
+ * against two copies of the record, and each is idempotent.
3772
+ *
3773
+ * ONE `catch` covers everything after the gate, and that is the contract, not
3774
+ * laziness: a refused call, a wedged one (bounded by
3775
+ * {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
3776
+ * parser throws rather than defending every field twice), a throwing assembly
3777
+ * hook (an unwritable archive), or a bug in the merge must all land here —
3778
+ * none of them may fail the handshake. The observable promise is therefore
3779
+ * structural: this method cannot reject.
3780
+ */
3781
+ async #learnEndpoints(link, capability) {
3782
+ if (capability !== 1) return;
3783
+ try {
3784
+ const endpoints = advertisedEndpoints(await this.#houseCall(link, "connection.endpoints", { domain: "agent" }, ENDPOINTS_LEARN_TIMEOUT_MS), this.record.desktopIdentity);
3785
+ if (endpoints.length === 0) return;
3786
+ const added = mergeAdvertisedEndpoints(this.record, endpoints);
3787
+ if (added > 0) console.info(`[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`);
3788
+ this.#onAdvertisedEndpoints?.(endpoints);
3789
+ } catch {}
3790
+ }
3791
+ /**
3170
3792
  * authenticate with the stored token; a stale token (TOKEN_EXPIRED /
3171
3793
  * UNAUTHENTICATED) is retried exactly once without it before the pairing
3172
3794
  * is declared invalid.
@@ -3498,24 +4120,56 @@ var PairingError = class extends Error {
3498
4120
  Object.setPrototypeOf(this, new.target.prototype);
3499
4121
  }
3500
4122
  };
3501
- const PAYLOAD_TYPE = "cherry-studio-pair";
3502
- const PAYLOAD_VERSION = 2;
3503
4123
  /** Overall payload ceiling: a QR code never approaches this. */
3504
4124
  const MAX_PAYLOAD_BYTES = 10240;
3505
- /** `opaqueId` cap for invitationId/invitationSecret/desktopIdentity. */
3506
- const OPAQUE_ID_MAX = 256;
4125
+ /**
4126
+ * Bounds the unknown-key WARNING (issue #155). The keys in that warning are
4127
+ * payload-CONTROLLED NAMES, and `MAX_PAYLOAD_BYTES` is no bound at all on the
4128
+ * line: a 10 KiB payload carrying 32 unknown keys of ~256 characters used to
4129
+ * join into ~8.2 KB of attacker-chosen text (the measured fixture is 8,192
4130
+ * characters of keys and an 8,312-character line) on the operator's log.
4131
+ *
4132
+ * `UNKNOWN_FIELDS_MAX` is the joined list's budget — the same 240-character
4133
+ * working budget as `LOG_TEXT_MAX`, so "one bounded peer detail" means one
4134
+ * number for one detail-sized face (pairing's `FAILURE_SEGMENT_MAX` is the
4135
+ * same NUMBER bounding a different quantity: a whole `<label>: <detail>`).
4136
+ * `UNKNOWN_FIELD_MAX` clips ONE
4137
+ * key: 64 characters is longer than any field name the protocol defines, so a
4138
+ * clipped key is always a malformed or hostile payload, never a real typo the
4139
+ * operator needs whole. A list that stops early names the count (`(+K more)`);
4140
+ * entries are never dropped silently.
4141
+ */
4142
+ const UNKNOWN_FIELDS_MAX = 240;
4143
+ const UNKNOWN_FIELD_MAX = 64;
3507
4144
  /** The claim's deviceName cap; a hostname may reach 253, so it is truncated. */
3508
4145
  const DEVICE_NAME_MAX = 64;
3509
4146
  /** Bounds a single candidate host string (IPv6 literals are ~45). */
3510
4147
  const HOST_MAX$1 = 256;
3511
4148
  /** Matches the protocol's unicodeText: no lone UTF-16 surrogates. */
3512
4149
  const LONE_SURROGATE_RE$1 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
3513
- /** Fields the implementation consumes; everything else in the payload is ignored. */
3514
- const KNOWN_FIELDS = /* @__PURE__ */ new Set([
4150
+ /**
4151
+ * The keys each QR ARM consumes (selected by `v`). A payload field outside
4152
+ * the MATCHED arm's set is ignored with a warning — the desktop names hosts
4153
+ * the design does not use. One union set would silently swallow a v2 payload
4154
+ * carrying a spurious `endpoints` (or v3 carrying `ips`/`port`); the arm
4155
+ * split keeps the pre-schema warning behavior.
4156
+ */
4157
+ const V2_KEYS = /* @__PURE__ */ new Set([
3515
4158
  "v",
3516
4159
  "t",
3517
- "port",
4160
+ "name",
3518
4161
  "ips",
4162
+ "port",
4163
+ "invitationId",
4164
+ "invitationSecret",
4165
+ "desktopIdentity",
4166
+ "protocolVersions"
4167
+ ]);
4168
+ const V3_KEYS = /* @__PURE__ */ new Set([
4169
+ "v",
4170
+ "t",
4171
+ "name",
4172
+ "endpoints",
3519
4173
  "invitationId",
3520
4174
  "invitationSecret",
3521
4175
  "desktopIdentity",
@@ -3537,12 +4191,12 @@ function invalid(message) {
3537
4191
  function isUnicodeText(value) {
3538
4192
  return !LONE_SURROGATE_RE$1.test(value);
3539
4193
  }
3540
- function checkString(value, field, max) {
3541
- if (typeof value !== "string") invalid(`payload field "${field}" must be a string`);
3542
- if (value.length === 0) invalid(`payload field "${field}" must not be empty`);
3543
- if (value.length > max) invalid(`payload field "${field}" exceeds ${max} characters`);
3544
- if (!isUnicodeText(value)) invalid(`payload field "${field}" is not valid unicode text`);
3545
- return value;
4194
+ /** Rejections quote issue PATHS (deduplicated, sorted). The "never inputs"
4195
+ * promise is the CALL SITE's property — only `issue.path` is read here; an
4196
+ * editor reaching for `issue.message` or `String(error)` must re-check what
4197
+ * zod embeds there before keeping that promise. */
4198
+ function schemaIssuePaths(error) {
4199
+ return [...new Set(error.issues.map((issue) => issue.path.join(".") || "payload"))].sort().join(", ");
3546
4200
  }
3547
4201
  /**
3548
4202
  * Normalizes one candidate host (ruling 2). The QR may carry IPv6 literals in
@@ -3566,9 +4220,9 @@ function normalizeHost$1(raw) {
3566
4220
  return inner;
3567
4221
  }
3568
4222
  /**
3569
- * Parses and validates the pairing payload. Unknown fields are ignored (with a
3570
- * warning) — the payload carries host naming the design does not consume — but
3571
- * the fields on the manifest are judged against their protocol caps.
4223
+ * Parses and validates the pairing payload against the published
4224
+ * `pairingQrSchema` (v2 or v3). Unknown fields are ignored (with a warning);
4225
+ * every field judgement is the schema's, capped by the local size rule.
3572
4226
  */
3573
4227
  function parsePayload(payload) {
3574
4228
  if (Buffer.byteLength(payload, "utf8") > MAX_PAYLOAD_BYTES) invalid(`payload exceeds the ${MAX_PAYLOAD_BYTES}-byte limit`);
@@ -3580,38 +4234,100 @@ function parsePayload(payload) {
3580
4234
  }
3581
4235
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) invalid("payload must be a JSON object");
3582
4236
  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
4237
  const ips = raw["ips"];
3590
- if (!Array.isArray(ips) || ips.length === 0) invalid("payload field \"ips\" must be a non-empty array");
3591
- const hosts = ips.map((entry, index) => normalizeHost$1(checkString(entry, `ips[${index}]`, HOST_MAX$1)));
3592
- const invitationId = checkString(raw["invitationId"], "invitationId", OPAQUE_ID_MAX);
3593
- const invitationSecret = checkString(raw["invitationSecret"], "invitationSecret", OPAQUE_ID_MAX);
3594
- const desktopIdentity = checkString(raw["desktopIdentity"], "desktopIdentity", OPAQUE_ID_MAX);
3595
- const versions = raw["protocolVersions"];
3596
- if (!Array.isArray(versions) || versions.length === 0) invalid("payload field \"protocolVersions\" must be a non-empty array");
4238
+ const input = Array.isArray(ips) ? {
4239
+ ...raw,
4240
+ ips: ips.map((entry) => typeof entry === "string" ? normalizeHost$1(entry) : entry)
4241
+ } : raw;
4242
+ const result = pairingQrSchema.safeParse(input);
4243
+ if (!result.success) invalid(`payload rejected by the pairing QR schema (v2 or v3): ${schemaIssuePaths(result.error)}`);
4244
+ const qr = result.data;
4245
+ const ignored = Object.keys(raw).filter((key) => !(qr.v === 2 ? V2_KEYS : V3_KEYS).has(key));
4246
+ if (ignored.length > 0) console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${boundedJoin(ignored, {
4247
+ max: UNKNOWN_FIELDS_MAX,
4248
+ itemMax: UNKNOWN_FIELD_MAX
4249
+ })}`);
4250
+ const candidates = qr.v === 2 ? qr.ips.map((host) => ({
4251
+ host,
4252
+ port: qr.port,
4253
+ security: "ws"
4254
+ })) : qr.endpoints.map((endpoint) => ({
4255
+ host: endpoint.host,
4256
+ port: endpoint.port,
4257
+ security: endpoint.security
4258
+ }));
4259
+ const seenHosts = /* @__PURE__ */ new Set();
4260
+ for (const candidate of candidates) {
4261
+ if (!Number.isInteger(candidate.port) || candidate.port < 1 || candidate.port > 65535) invalid(`payload endpoint "${candidate.host}": port must be an integer in 1..65535`);
4262
+ if (seenHosts.has(candidate.host)) invalid(`payload endpoints must not repeat host "${candidate.host}"`);
4263
+ seenHosts.add(candidate.host);
4264
+ }
4265
+ for (const field of [
4266
+ "invitationId",
4267
+ "invitationSecret",
4268
+ "desktopIdentity"
4269
+ ]) if (!isUnicodeText(qr[field])) invalid(`payload field "${field}" is not valid unicode text`);
4270
+ try {
4271
+ peerIdFromString(qr.desktopIdentity);
4272
+ } catch {
4273
+ invalid("payload field \"desktopIdentity\" is not a peer id");
4274
+ }
3597
4275
  return {
3598
- hosts,
3599
- port,
3600
- invitationId,
3601
- invitationSecret,
3602
- desktopIdentity,
3603
- protocolVersions: versions.map((version) => {
3604
- if (typeof version !== "number" || !Number.isInteger(version)) invalid("payload field \"protocolVersions\" must contain integers");
3605
- return version;
3606
- })
4276
+ candidates,
4277
+ invitationId: qr.invitationId,
4278
+ invitationSecret: qr.invitationSecret,
4279
+ desktopIdentity: qr.desktopIdentity,
4280
+ protocolVersions: qr.protocolVersions
3607
4281
  };
3608
4282
  }
3609
4283
  const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS = [1];
3610
4284
  const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
4285
+ /**
4286
+ * ONE aggregate budget for the candidate DIAL walk (issue #141). A v3 QR admits
4287
+ * up to 32 endpoints, so 32 serial `connectTimeoutMs` dials could spend 320 s
4288
+ * on an invitation that lives 120 s. It bounds the SUM, never one attempt.
4289
+ */
4290
+ const DEFAULT_DIAL_BUDGET_MS = 3e4;
3611
4291
  const DEFAULT_POLL_INTERVAL_MS = 1e3;
3612
4292
  /** The protocol invitationMs: 120s of invitation, and our poll race guard. */
3613
4293
  const DEFAULT_POLL_TIMEOUT_MS = 12e4;
3614
4294
  const VERIFICATION_CODE_RE = /^[0-9]{6}$/;
4295
+ /** Bounds the close frame's reason text quoted into a candidate failure. */
4296
+ const CLOSE_REASON_MAX = 200;
4297
+ /**
4298
+ * Bounds ONE candidate failure segment of the joined `unreachable` verdict
4299
+ * (issue #148). Unbounded, a v3 QR's 32 endpoints × a 253-char host — repeated
4300
+ * again inside the dial error — reached ~17-18 KB in one tool result that
4301
+ * `cherry_pair` passes through whole. The bound is per SEGMENT, never a
4302
+ * "first N + and M more" cut: issue #141's contract is that every candidate,
4303
+ * attempted or not, is NAMED.
4304
+ *
4305
+ * `FAILURE_LABEL_MAX` clips an over-long `host:port` in the HOST and keeps the
4306
+ * PORT (`<host prefix>…:<port>`) — a prefix without its port names no address.
4307
+ * `FAILURE_SEGMENT_MAX` bounds the whole `<label>: <detail>`: the detail is
4308
+ * clipped to the room the label leaves, and the `… (+N chars)` marker's own
4309
+ * length is charged to the bound. 32 candidates then join to at most
4310
+ * 32 × 240 + 31 × 2 = 7,742 characters.
4311
+ */
4312
+ const FAILURE_LABEL_MAX = 128;
4313
+ const FAILURE_SEGMENT_MAX = 240;
4314
+ /**
4315
+ * Bounds the progress line's HOST TEXT (issue #152). `report()` runs once per
4316
+ * stage transition and used to join every candidate host: a v3 QR admits 32
4317
+ * endpoints and a host name may be 253 characters, so one stage wrote ~8 KB.
4318
+ *
4319
+ * The FIRST host is always shown — a line with no host at all would lose the
4320
+ * run's identity — then further hosts are appended while the host NAMES fit
4321
+ * this budget; a truncated list carries an explicit `(+K more)` marker that
4322
+ * rides ON TOP of the budget (the marker is a COUNT, not a host name).
4323
+ *
4324
+ * Invariant: the host names consume ≤ `STAGE_HOSTS_CHARS`, with ONE exception
4325
+ * — a FIRST host that alone exceeds it, still shown because the identity rule
4326
+ * outranks the bound. A LATER over-long host can never enter the bounded
4327
+ * section (`used` already holds the first host, so it fails the fit test) and
4328
+ * is omitted into the marker.
4329
+ */
4330
+ const STAGE_HOSTS_CHARS = 160;
3615
4331
  const noop = () => {};
3616
4332
  /** A callable silent libp2p logger (same shape as the connection layer's). */
3617
4333
  function makeSilentLogger() {
@@ -3626,8 +4342,75 @@ const silentComponentLogger = { forComponent: () => makeSilentLogger() };
3626
4342
  function sleep$1(ms) {
3627
4343
  return new Promise((resolve) => setTimeout(resolve, ms));
3628
4344
  }
4345
+ /**
4346
+ * Tags a PRE-OPEN `ws` error as a candidate-dial failure (issue #142). The
4347
+ * `websocket:` prefix is the KIND: the operator's fix is this address or its
4348
+ * TLS, not the network — the contrast with `timeout:` is the whole point of the
4349
+ * prefix, and it is what the joined `unreachable` verdict shows. The `ws` error
4350
+ * carries the discriminator a bare message drops — an OS `code`
4351
+ * (`ECONNREFUSED`, `ENOTFOUND`, a TLS code) or a numeric ws code — so it is
4352
+ * appended, and only when it is actually a string or a number (never
4353
+ * `undefined`/`null`).
4354
+ */
4355
+ function webSocketFailure(error) {
4356
+ const code = error.code;
4357
+ const suffix = typeof code === "string" || typeof code === "number" ? ` (code ${code})` : "";
4358
+ return /* @__PURE__ */ new Error(`websocket: ${error.message}${suffix}`);
4359
+ }
4360
+ /**
4361
+ * Tags a POST-OPEN channel failure as a candidate-dial failure (issue #147).
4362
+ * `channel:` is the THIRD kind, deliberately not `websocket:`: the upgrade
4363
+ * completed, so the address works and only the channel's establishment failed
4364
+ * (Noise handshake, remote identity proof, protocol offer). Blaming the
4365
+ * address would send the operator to inspect something that demonstrably
4366
+ * answered. No code suffix is appended: a Noise-stage error can carry its own
4367
+ * string code (`ERR_INVALID_CRYPTO_EXCHANGE`), but that is an internal label
4368
+ * rather than the ws/OS discriminator the other kinds keep, so it is
4369
+ * deliberately dropped.
4370
+ */
4371
+ function channelFailure(error) {
4372
+ return /* @__PURE__ */ new Error(`channel: ${error.message}`);
4373
+ }
4374
+ /**
4375
+ * The ONE text form of a candidate failure (issue #148). All three
4376
+ * `failures.push` sites render through it, so the segment bound cannot hold on
4377
+ * one arm (budget-exhausted, dial failure, claim fall-through) and fail on
4378
+ * another. A short label and a short detail are returned BYTE FOR BYTE; the
4379
+ * enforcement is never a rewrite of ordinary text.
4380
+ *
4381
+ * The detail goes through the SHARED {@link clipText} (issue #155), the same
4382
+ * `… (+N chars)` form every other peer-controlled log detail uses; the clip
4383
+ * point is no longer private to pairing.
4384
+ */
4385
+ function failureSegment(label, detail) {
4386
+ const boundedLabel = clipFailureLabel(label);
4387
+ const room = FAILURE_SEGMENT_MAX - boundedLabel.length - 2;
4388
+ if (room <= 0) return boundedLabel.slice(0, FAILURE_SEGMENT_MAX);
4389
+ return `${boundedLabel}: ${clipText(detail, room)}`;
4390
+ }
4391
+ /**
4392
+ * Clips a `host:port` label in the HOST, keeping the port (see
4393
+ * {@link FAILURE_LABEL_MAX}).
4394
+ *
4395
+ * Identification here is PREFIX + PORT, not uniqueness: two schema-distinct
4396
+ * 253-char hosts that agree on their first ~121 characters AND on the port clip
4397
+ * to identical labels (ports may repeat — only hosts are deduped). Each still
4398
+ * contributes its own segment, so #141's per-candidate count is intact.
4399
+ */
4400
+ function clipFailureLabel(label) {
4401
+ if (label.length <= FAILURE_LABEL_MAX) return label;
4402
+ const colon = label.lastIndexOf(":");
4403
+ if (colon < 0) return `${label.slice(0, 127)}…`;
4404
+ const suffix = `…${label.slice(colon)}`;
4405
+ const hostRoom = FAILURE_LABEL_MAX - suffix.length;
4406
+ return hostRoom > 0 ? `${label.slice(0, hostRoom)}${suffix}` : suffix.slice(0, FAILURE_LABEL_MAX);
4407
+ }
3629
4408
  /** Dials one candidate address and completes the Noise handshake.
3630
4409
  *
4410
+ * The URL is the published `directEndpointUrl` — it brackets IPv6 literals,
4411
+ * appends the fixed `/v1/remote/connect` path, and selects `ws:` or `wss:`
4412
+ * from the candidate's own `security` (v3 TLS-proxy endpoints dial TLS).
4413
+ *
3631
4414
  * Both halves start concurrently (`Promise.all`): the peer's accept half is
3632
4415
  * already running on its side, so a sequential dial would deadlock the
3633
4416
  * prelude.
@@ -3638,28 +4421,54 @@ function sleep$1(ms) {
3638
4421
  * rather than assumed: the payload's `desktopIdentity` is the peer the Noise
3639
4422
  * XX handshake must prove, so a swapped-in impostor fails the handshake
3640
4423
  * instead of being pinned as the desktop.
3641
- */
3642
- async function dial(host, port, identity, desktopIdentity, protocolVersions, timeoutMs) {
3643
- const ws = new WebSocket(`ws://${urlHost(host)}:${port}${remoteConnectPath}`);
4424
+ *
4425
+ * A failure carries one of THREE kinds into the joined `unreachable` verdict,
4426
+ * because each names the operator's next check: `timeout:` (the per-attempt
4427
+ * timer won its race — check the network), `websocket:` (the socket failed
4428
+ * BEFORE the upgrade — check this address or its TLS), and `channel:` (the
4429
+ * socket came up and the channel failed AFTER it — check the peer's identity
4430
+ * or the protocol offer). A stalled handshake stays `timeout:`: the dial
4431
+ * promise's own timer settles first, so the abort it triggers cannot win.
4432
+ */
4433
+ async function dial(candidate, identity, desktopIdentity, protocolVersions, timeoutMs, wsOptions) {
4434
+ const { host, port } = candidate;
4435
+ const ws = new WebSocket(directEndpointUrl(candidate), wsOptions);
3644
4436
  const controller = new AbortController();
3645
4437
  let timer;
3646
4438
  const timedOut = new Promise((_, reject) => {
3647
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`connect to ${host}:${port} timed out`)), timeoutMs);
4439
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout: connect to ${host}:${port} timed out`)), timeoutMs);
3648
4440
  });
3649
4441
  try {
3650
4442
  const connecting = (async () => {
3651
4443
  await new Promise((resolve, reject) => {
3652
- ws.once("open", () => resolve());
3653
- ws.once("error", (error) => reject(error));
3654
- });
3655
- const stream = new RemoteSocketStream(toRemoteSocket(ws), silentComponentLogger.forComponent("pairing"), "outbound");
3656
- return connectSecureChannel(stream, {
3657
- identity,
3658
- logger: silentComponentLogger,
3659
- protocolVersions: [...protocolVersions],
3660
- signal: controller.signal,
3661
- remoteIdentity: desktopIdentity
4444
+ const settle = (outcome) => {
4445
+ ws.off("open", onOpen);
4446
+ ws.off("error", onError);
4447
+ ws.off("close", onClose);
4448
+ outcome();
4449
+ };
4450
+ const onOpen = () => settle(resolve);
4451
+ const onError = (error) => settle(() => reject(webSocketFailure(error)));
4452
+ const onClose = (code, reason) => {
4453
+ const text = reason.toString("utf8").slice(0, CLOSE_REASON_MAX);
4454
+ settle(() => reject(/* @__PURE__ */ new Error(`websocket: closed before open (code ${code})${text === "" ? "" : ` ${text}`}`)));
4455
+ };
4456
+ ws.once("open", onOpen);
4457
+ ws.once("error", onError);
4458
+ ws.on("close", onClose);
3662
4459
  });
4460
+ try {
4461
+ const stream = new RemoteSocketStream(toRemoteSocket(ws), silentComponentLogger.forComponent("pairing"), "outbound");
4462
+ return await connectSecureChannel(stream, {
4463
+ identity,
4464
+ logger: silentComponentLogger,
4465
+ protocolVersions: [...protocolVersions],
4466
+ signal: controller.signal,
4467
+ remoteIdentity: desktopIdentity
4468
+ });
4469
+ } catch (error) {
4470
+ throw channelFailure(error instanceof Error ? error : new Error(String(error)));
4471
+ }
3663
4472
  })();
3664
4473
  const channel = await Promise.race([connecting, timedOut.finally(() => controller.abort(/* @__PURE__ */ new Error("pairing connect timed out")))]);
3665
4474
  return {
@@ -3692,6 +4501,27 @@ function rpcReason(error) {
3692
4501
  }
3693
4502
  }
3694
4503
  /**
4504
+ * Renders the progress line's host list under {@link STAGE_HOSTS_CHARS} (the
4505
+ * invariant lives on that constant). A truncated list names the first host,
4506
+ * then says how many candidates it is NOT naming — the count is explicit so a
4507
+ * reader can tell a short host list from a bounded one.
4508
+ */
4509
+ function stageHosts(hosts) {
4510
+ const [first, ...rest] = hosts;
4511
+ if (first === void 0) return "";
4512
+ let text = first;
4513
+ let used = first.length;
4514
+ let shown = 1;
4515
+ for (const host of rest) {
4516
+ if (used + host.length + 2 > STAGE_HOSTS_CHARS) break;
4517
+ text += `, ${host}`;
4518
+ used += host.length + 2;
4519
+ shown += 1;
4520
+ }
4521
+ const omitted = hosts.length - shown;
4522
+ return omitted > 0 ? `${text} (+${omitted} more)` : text;
4523
+ }
4524
+ /**
3695
4525
  * Pairs this DSH profile with a Cherry Studio desktop from its pairing payload,
3696
4526
  * returning the PERSISTED record (ruling 6).
3697
4527
  *
@@ -3712,35 +4542,45 @@ async function pairWithPayload(opts) {
3712
4542
  const payload = parsePayload(opts.payload);
3713
4543
  const onStatus = opts.onStatus ?? noop;
3714
4544
  const report = (stage) => {
3715
- console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${payload.hosts.join(", ")})`);
4545
+ console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${stageHosts(payload.candidates.map((candidate) => candidate.host))})`);
3716
4546
  onStatus(stage);
3717
4547
  };
3718
4548
  report("validate");
3719
4549
  const supported = opts.supportedProtocolVersions ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS;
3720
4550
  const offered = payload.protocolVersions.filter((version) => supported.includes(version));
3721
4551
  const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
4552
+ const dialBudgetMs = typeof opts.dialBudgetMs === "number" && Number.isFinite(opts.dialBudgetMs) ? opts.dialBudgetMs : DEFAULT_DIAL_BUDGET_MS;
4553
+ const dialDeadline = performance.now() + dialBudgetMs;
3722
4554
  report("connect");
3723
4555
  const failures = [];
3724
- for (const host of payload.hosts) {
4556
+ let approved;
4557
+ for (const candidate of payload.candidates) {
4558
+ const label = `${candidate.host}:${candidate.port}`;
4559
+ const remaining = dialDeadline - performance.now();
4560
+ if (remaining <= 0) {
4561
+ failures.push(failureSegment(label, `not attempted (the ${dialBudgetMs} ms dial budget was exhausted before this candidate)`));
4562
+ continue;
4563
+ }
3725
4564
  let temp;
3726
4565
  try {
3727
- temp = await dial(host, payload.port, opts.identity, payload.desktopIdentity, offered, connectTimeoutMs);
4566
+ temp = await dial(candidate, opts.identity, payload.desktopIdentity, offered, Math.min(connectTimeoutMs, remaining), opts.wsOptions);
3728
4567
  } catch (error) {
3729
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
4568
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
3730
4569
  continue;
3731
4570
  }
3732
4571
  try {
3733
- const record = await claimAndAwait(temp.client, payload, offered, opts, report);
4572
+ approved = await claimAndAwait(temp.client, payload, offered, opts, report, candidate);
3734
4573
  destroy(temp);
3735
- opts.store.upsert(record);
3736
- return record;
4574
+ break;
3737
4575
  } catch (error) {
3738
4576
  destroy(temp);
3739
4577
  if (error instanceof PairingError) throw error;
3740
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
4578
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
3741
4579
  }
3742
4580
  }
3743
- throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
4581
+ if (approved === void 0) throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
4582
+ opts.store.upsert(approved);
4583
+ return approved;
3744
4584
  }
3745
4585
  /**
3746
4586
  * The RPC half of one candidate: confirm the protocol, claim, present the code,
@@ -3748,7 +4588,7 @@ async function pairWithPayload(opts) {
3748
4588
  * deviceId, grants and token (ruling 3) — the claim receipt carries the code
3749
4589
  * and nothing else.
3750
4590
  */
3751
- async function claimAndAwait(client, payload, protocolVersions, opts, onStatus) {
4591
+ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus, candidate) {
3752
4592
  const deviceName = (opts.name ?? hostname()).slice(0, DEVICE_NAME_MAX);
3753
4593
  try {
3754
4594
  await client.request("connection.hello", { protocolVersions: [...protocolVersions] });
@@ -3782,7 +4622,7 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
3782
4622
  }
3783
4623
  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
4624
  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);
4625
+ if (result.status === "approved") return recordFrom(payload, result, deviceName, candidate);
3786
4626
  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
4627
  await sleep$1(interval);
3788
4628
  }
@@ -3793,18 +4633,34 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
3793
4633
  * `displayName` is the name the claim actually carried — the record must not
3794
4634
  * disagree with what the desktop shows for this device, and re-deriving it
3795
4635
  * here would lose the 64-char truncation.
3796
- */
3797
- function recordFrom(payload, approved, deviceName) {
4636
+ *
4637
+ * The address projection: every candidate host is kept (the v2 record always
4638
+ * did, and reconnect re-walks them), and `port` is the one the pairing actually
4639
+ * completed on — the WINNER's port, and the record-level fallback. A mixed-port
4640
+ * v3 payload is now persisted FAITHFULLY, per host, through `ports` (issue
4641
+ * #135): the 2.1.8 generation side emits one endpoint per custom-address
4642
+ * invitation (`ApiGatewayService.getRemoteEndpoint`:
4643
+ * `advertisedEndpoint ? [advertisedEndpoint] : …`), but a QR that does carry a
4644
+ * mixed list must still reconnect each host on its own port. `security` IS
4645
+ * persisted (issue #126), one entry per host, taken from the very candidates the
4646
+ * walk dialed — so a schemaVersion-4 record remembers the scheme the pairing
4647
+ * actually used, and the steady-state dial (src/connection.ts) rebuilds the
4648
+ * same URL instead of a plain `ws://`. Before that, #124 paired a TLS endpoint
4649
+ * and made it unreachable on every reconnect.
4650
+ */
4651
+ function recordFrom(payload, approved, deviceName, winner) {
3798
4652
  const grants = (approved.authorization?.grants ?? []).map((grant) => grant.domain).filter((domain) => domain === "agent");
3799
4653
  if (grants.length === 0) throw new PairingError("rejected", "the desktop approved no agent capability for this device");
3800
4654
  const tokenExpiresAt = Date.parse(approved.expiresAt);
3801
4655
  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
4656
  return {
3803
- schemaVersion: 2,
4657
+ schemaVersion: 4,
3804
4658
  deviceId: approved.deviceId,
3805
4659
  displayName: deviceName,
3806
- hosts: [...payload.hosts],
3807
- port: payload.port,
4660
+ hosts: payload.candidates.map((candidate) => candidate.host),
4661
+ port: winner.port,
4662
+ security: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.security])),
4663
+ ports: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.port])),
3808
4664
  desktopIdentity: payload.desktopIdentity,
3809
4665
  grants,
3810
4666
  accessToken: approved.accessToken,
@@ -3822,7 +4678,7 @@ function asPairingError(error, stage) {
3822
4678
  if (error instanceof PairingError) return error;
3823
4679
  const reason = rpcReason(error);
3824
4680
  if (reason !== void 0) return new PairingError("rejected", `the desktop refused the pairing ${stage} (${reason})`);
3825
- return new PairingError("unreachable", `pairing ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
4681
+ return new PairingError("unreachable", failureSegment(`pairing ${stage} failed`, error instanceof Error ? error.message : String(error)));
3826
4682
  }
3827
4683
  //#endregion
3828
4684
  //#region src/subscriptions.ts
@@ -4345,7 +5201,7 @@ var SessionSubscriptions = class {
4345
5201
  try {
4346
5202
  await this.#rpc.request("agent.subscriptions.close", { subscriptionId });
4347
5203
  } catch (error) {
4348
- console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed`, error);
5204
+ console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed: ${failureText(error)}`);
4349
5205
  }
4350
5206
  }
4351
5207
  /**
@@ -4680,6 +5536,42 @@ const TERMINAL_EXECUTION_STATUSES = [
4680
5536
  ];
4681
5537
  /** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
4682
5538
  const PAGE_ITEMS_FALLBACK = 100;
5539
+ /**
5540
+ * The page this bridge asks for when the peer's hello advertised
5541
+ * `agentMessageSummariesVersion: 1` (issue #105).
5542
+ *
5543
+ * WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
5544
+ * exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
5545
+ * schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
5546
+ * record cannot hold and the transport kills the connection (`defaults.ts`).
5547
+ * What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
5548
+ * revision, role, status and optional usage/model/failure, and NO `partIds`.
5549
+ *
5550
+ * WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
5551
+ * the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
5552
+ * prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
5553
+ * at a halved limit, so its reply records stay under `recordBytes` = 65,536
5554
+ * whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
5555
+ * shape our walk already tolerates) instead of a killed link. The single-item
5556
+ * `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
5557
+ * exception arm, and a schema-valid summary row cannot reach it: measured
5558
+ * against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
5559
+ * 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
5560
+ * ≈37 KB — under the 62,279 budget, so the refusal exists for full
5561
+ * `partIds`-bearing rows, not summaries.
5562
+ *
5563
+ * 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
5564
+ * all: `#aggregate` still intersects this with the peer's NEGOTIATED
5565
+ * `pageItems`, so a peer advertising less keeps its size. Residual exposure —
5566
+ * a hypothetical peer advertising the capability WITHOUT a page budget —
5567
+ * contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
5568
+ * the same "pragmatic, not provable" class `defaults.ts` already scopes.
5569
+ *
5570
+ * The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
5571
+ * in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
5572
+ * fattest item there is.
5573
+ */
5574
+ const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK;
4683
5575
  /** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
4684
5576
  const MAX_AGGREGATE_LIMIT = 1e3;
4685
5577
  /**
@@ -4768,6 +5660,33 @@ const DEFAULT_PARTS_SAFETY_BOUND = 1e4;
4768
5660
  * returning a partial (or empty) result as though it were complete.
4769
5661
  */
4770
5662
  const MAX_AGGREGATE_PAGES = 1e4;
5663
+ /**
5664
+ * The `agent.messages.list` request for ONE message walk, and the page size to
5665
+ * ask for — the ONE place the paged-summaries dialect is decided (issue #105).
5666
+ *
5667
+ * THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
5668
+ * `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
5669
+ * through 2.1.7 ships — is a STRICT object with no such key, so sending it
5670
+ * there is a protocol violation, not a wasted field. Both message-walk faces
5671
+ * (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
5672
+ * the same reason the resume token has one helper: two call sites spelling the
5673
+ * dialect would eventually disagree about which peer may hear it.
5674
+ *
5675
+ * With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
5676
+ * summary rows are metadata, so the byte-safe clamp no longer bounds anything
5677
+ * they can carry. Without it, the clamp stays exactly as it was: the
5678
+ * count-paged peer still returns `partIds`-bearing rows.
5679
+ */
5680
+ function messageListRequest(connection) {
5681
+ if (connection.limits.messageSummaries === 1) return {
5682
+ params: { partsMode: "paged" },
5683
+ maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS
5684
+ };
5685
+ return {
5686
+ params: {},
5687
+ maxPerPage: 16
5688
+ };
5689
+ }
4771
5690
  /** Token prefix for a peer page position; the one `#aggregate` can send back. */
4772
5691
  const RESUME_CURSOR_PREFIX = "cursor:";
4773
5692
  /** Token prefix for "strictly older than this message id". */
@@ -5106,6 +6025,44 @@ function failureReason(error) {
5106
6025
  if (isRecord(data) && typeof data["reason"] === "string") return data["reason"];
5107
6026
  }
5108
6027
  /**
6028
+ * The phrase this hint leaves behind, so applying it twice reads once.
6029
+ */
6030
+ const PEER_REFUSAL_FINAL_MARK = "retrying the same request cannot help";
6031
+ /**
6032
+ * The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
6033
+ * carrying `details.retryable === false`, which is the desktop's per-item byte
6034
+ * budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
6035
+ * `itemBytes` / `budgetBytes`), issue #105.
6036
+ *
6037
+ * WHY THE WARNING HAS TO BE ADDED: the official client marks every
6038
+ * `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
6039
+ * exponential backoff, so a byte-budget refusal never converges — and the
6040
+ * condition really is permanent: the same item over the same budget will be
6041
+ * over it again. A caller (or a model reading the error) that retries learns
6042
+ * nothing but spends the peer's rate window.
6043
+ *
6044
+ * The sentence is deliberately narrower than the reason: only the
6045
+ * `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
6046
+ * the upload window, the subscription cap, a legacy refusal with no `details` —
6047
+ * keeps its own text, because some of those ARE worth a later retry.
6048
+ *
6049
+ * Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
6050
+ * structured `data` and the {@link JsonRpcRequestError} identity stay the
6051
+ * peer's, so branching callers see exactly what it sent.
6052
+ */
6053
+ function withPeerRefusalHint(error) {
6054
+ if (!(error instanceof JsonRpcRequestError)) return error;
6055
+ if (failureReason(error) !== "RESOURCE_EXHAUSTED") return error;
6056
+ const data = error.data;
6057
+ if (!isRecord(data) || !isRecord(data["details"]) || data["details"]["retryable"] !== false) return error;
6058
+ if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error;
6059
+ const kind = optionalString(data["details"]["kind"]);
6060
+ const cause = kind === "ITEM_TOO_LARGE" ? "an ITEM_TOO_LARGE page item over its byte budget" : "the same item";
6061
+ 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";
6062
+ error.message = `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); retrying the same request cannot help — ${tail}`;
6063
+ return error;
6064
+ }
6065
+ /**
5109
6066
  * One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
5110
6067
  * digest the ref advertises (or there was no digest to check against).
5111
6068
  *
@@ -5313,6 +6270,54 @@ function optionalString(value) {
5313
6270
  return typeof value === "string" && value !== "" ? value : void 0;
5314
6271
  }
5315
6272
  /**
6273
+ * The `hosts` argument of `cherry_devices`' write actions (issue #185): a
6274
+ * non-empty list of non-empty host names, with no case-variant duplicate.
6275
+ *
6276
+ * Each rule is a state the store would have to interpret: an empty name can
6277
+ * never match a dialed host, and two spellings of one host would make "is this
6278
+ * address forgotten?" depend on which entry a comparison hit first. Values are
6279
+ * TRIMMED here (the model-facing surface is where a stray space is caught);
6280
+ * the archive then stores the spelling it already had for a dialed host.
6281
+ */
6282
+ function hostListArgument(value) {
6283
+ if (!Array.isArray(value) || value.length === 0) return {
6284
+ error: "invalid-hosts",
6285
+ message: "`hosts` must be a non-empty array of host names"
6286
+ };
6287
+ const hosts = [];
6288
+ const seen = /* @__PURE__ */ new Set();
6289
+ for (const entry of value) {
6290
+ if (typeof entry !== "string" || entry.trim() === "") return {
6291
+ error: "invalid-hosts",
6292
+ message: "`hosts` entries must be non-empty strings"
6293
+ };
6294
+ let host = entry.trim();
6295
+ if (host.startsWith("[") && host.endsWith("]")) {
6296
+ try {
6297
+ new URL(`http://${host}/`);
6298
+ } catch {
6299
+ return {
6300
+ error: "invalid-hosts",
6301
+ message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`
6302
+ };
6303
+ }
6304
+ host = host.slice(1, -1);
6305
+ }
6306
+ if (host.length > 256) return {
6307
+ error: "invalid-hosts",
6308
+ message: "`hosts` entry exceeds 256 characters"
6309
+ };
6310
+ const key = host.toLowerCase();
6311
+ if (seen.has(key)) return {
6312
+ error: "invalid-hosts",
6313
+ message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`
6314
+ };
6315
+ seen.add(key);
6316
+ hosts.push(host);
6317
+ }
6318
+ return hosts;
6319
+ }
6320
+ /**
5316
6321
  * The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
5317
6322
  * in the exact shape Cherry Studio answers it: a `rejected` receipt whose
5318
6323
  * `error` is `{reason: 'CONFLICT', message: 'Session is not idle at the
@@ -5434,7 +6439,19 @@ function registerCherryToolsWithEngine(ctx, deps, options = {}) {
5434
6439
  }
5435
6440
  };
5436
6441
  }
5437
- /** One tool declaration, in the exact shape the host registry consumes. */
6442
+ /**
6443
+ * One tool declaration, in the exact shape the host registry consumes.
6444
+ *
6445
+ * This helper adds no result WRAPPER and this ticket adds no result-side
6446
+ * truncation (issue #161): a tool result is the MODEL's signal, and the peer is
6447
+ * the user's own Noise-authenticated desktop, so shortening the peer's
6448
+ * explanation costs the caller the text it acts on. The two result-text clamps
6449
+ * that DO exist are per-field product choices, not a log rule — `aggregateError`
6450
+ * (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
6451
+ * 200-character diagnostics digest used by `projectMessageFailure`. The LOG
6452
+ * face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
6453
+ * this ticket's sweep does not change tool output.
6454
+ */
5438
6455
  function defineCherryTool(definition) {
5439
6456
  return {
5440
6457
  name: definition.name,
@@ -5902,7 +6919,7 @@ var CherryToolEngine = class {
5902
6919
  result = await connection.rpc.request(method, request, deadline === void 0 ? void 0 : { deadline: deadline.instant });
5903
6920
  } catch (error) {
5904
6921
  if (deadline !== void 0 && deadlineExhaustion(error)) return this.#truncated(items, "deadline", cursor);
5905
- throw error;
6922
+ throw withPeerRefusalHint(error);
5906
6923
  }
5907
6924
  const extracted = extract(result);
5908
6925
  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`);
@@ -5981,26 +6998,86 @@ var CherryToolEngine = class {
5981
6998
  #devicesTool() {
5982
6999
  return defineCherryTool({
5983
7000
  name: "cherry_devices",
5984
- description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId).",
7001
+ description: "List every paired Cherry Studio desktop with its connection state (connected/connecting/offline/disabled/identity-mismatch). An optional `device` narrows the list to one device (display name prefix or deviceId). `action: \"forget\"` removes the named `hosts` from that device's dial candidates AND stops the desktop's advertisements from adding them back; `action: \"remember\"` lifts that mark, so the next handshake may learn them again. Both write actions require `device` and `hosts`.",
5985
7002
  parameters: {
5986
7003
  type: "object",
5987
- properties: { device: {
5988
- type: "string",
5989
- description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
5990
- } },
7004
+ properties: {
7005
+ device: {
7006
+ type: "string",
7007
+ description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
7008
+ },
7009
+ action: {
7010
+ type: "string",
7011
+ enum: [
7012
+ "list",
7013
+ "forget",
7014
+ "remember"
7015
+ ],
7016
+ description: "What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE device's forgotten addresses and require `device` and `hosts`."
7017
+ },
7018
+ hosts: {
7019
+ type: "array",
7020
+ items: { type: "string" },
7021
+ description: "For `forget`/`remember`: the host names to act on, matched case-insensitively. `forget` refuses to remove the last remaining address."
7022
+ }
7023
+ },
5991
7024
  additionalProperties: false
5992
7025
  },
5993
7026
  execute: async (args) => {
7027
+ const action = optionalString(args["action"]) ?? "list";
7028
+ if (action !== "list" && action !== "forget" && action !== "remember") return {
7029
+ error: "invalid-action",
7030
+ message: "`action` accepts exactly \"list\", \"forget\" or \"remember\""
7031
+ };
5994
7032
  const selector = optionalString(args["device"]);
7033
+ if (action === "list") {
7034
+ const quarantined = this.#quarantineVerdict();
7035
+ if (quarantined !== void 0) return quarantined;
7036
+ if (selector === void 0) {
7037
+ const records = this.#deps.store.list();
7038
+ return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
7039
+ }
7040
+ const selected = this.#selectRecords(selector);
7041
+ if (!Array.isArray(selected)) return selected;
7042
+ return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7043
+ }
7044
+ if (selector === void 0) return {
7045
+ error: "device-required",
7046
+ message: "`action` \"forget\"/\"remember\" needs `device`: a selector that names exactly one device"
7047
+ };
7048
+ const hosts = hostListArgument(args["hosts"]);
7049
+ if (!Array.isArray(hosts)) return hosts;
5995
7050
  const quarantined = this.#quarantineVerdict();
5996
7051
  if (quarantined !== void 0) return quarantined;
5997
- if (selector === void 0) {
5998
- const records = this.#deps.store.list();
5999
- return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
6000
- }
6001
7052
  const selected = this.#selectRecords(selector);
6002
7053
  if (!Array.isArray(selected)) return selected;
6003
- return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
7054
+ const record = selected[0];
7055
+ let removed = [];
7056
+ let marked = [];
7057
+ let restored = [];
7058
+ try {
7059
+ if (action === "forget") ({removed, marked} = this.#deps.store.forgetEndpoints(record.deviceId, hosts));
7060
+ else ({restored} = this.#deps.store.rememberEndpoints(record.deviceId, hosts));
7061
+ } catch (error) {
7062
+ if (error instanceof LastCandidateHostError) return {
7063
+ error: "last-address",
7064
+ device: record.deviceId,
7065
+ message: error.message
7066
+ };
7067
+ throw error;
7068
+ }
7069
+ if (removed.length > 0 || marked.length > 0 || restored.length > 0) await this.#deps.evict(record.deviceId);
7070
+ const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId);
7071
+ if (fresh === void 0) return this.#notFoundVerdict(selector);
7072
+ const device = await this.#deviceView(fresh);
7073
+ return action === "forget" ? {
7074
+ device,
7075
+ removed,
7076
+ marked
7077
+ } : {
7078
+ device,
7079
+ restored
7080
+ };
6004
7081
  }
6005
7082
  });
6006
7083
  }
@@ -6054,7 +7131,8 @@ var CherryToolEngine = class {
6054
7131
  state,
6055
7132
  enabled: record.enabled,
6056
7133
  hosts: record.hosts,
6057
- port: record.port
7134
+ port: record.port,
7135
+ forgottenHosts: record.forgottenHosts ?? []
6058
7136
  };
6059
7137
  }
6060
7138
  #agentsTool() {
@@ -6313,16 +7391,18 @@ var CherryToolEngine = class {
6313
7391
  const session = isRecord(sessionResult["session"]) ? sessionResult["session"] : {};
6314
7392
  const historyRevision = session["historyRevision"];
6315
7393
  if (typeof historyRevision !== "string") throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`);
7394
+ const messageList = messageListRequest(connection);
6316
7395
  const page = await this.#aggregate(connection, "agent.messages.list", {
6317
7396
  sessionId,
6318
- historyRevision
7397
+ historyRevision,
7398
+ ...messageList.params
6319
7399
  }, (result) => ({
6320
7400
  ...arrayOf(result["items"]),
6321
7401
  nextCursor: result["nextCursor"]
6322
7402
  }), {
6323
7403
  limit,
6324
7404
  safetyBound: MAX_AGGREGATE_LIMIT,
6325
- maxPerPage: 16,
7405
+ maxPerPage: messageList.maxPerPage,
6326
7406
  resume: resume === void 0 ? void 0 : resume.kind === "cursor" ? {
6327
7407
  kind: "cursor",
6328
7408
  cursor: resume.cursor
@@ -6496,7 +7576,7 @@ var CherryToolEngine = class {
6496
7576
  * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
6497
7577
  * `content.text`, observed on the wire (2.1.7 desktop, image upload,
6498
7578
  * 2026-10-22). An OBJECT inside `content` is not producible by a conforming
6499
- * peer at all: the pinned protocol 0.5.0 types `content` as a strict
7579
+ * peer at all: the pinned protocol 0.7.0 types `content` as a strict
6500
7580
  * `{text}` | `{ref}` union, so the old documentation described a shape no
6501
7581
  * peer can emit — and the real one was never lifted, which is how an image
6502
7582
  * upload reached a model as a JSON blob.
@@ -6769,7 +7849,7 @@ var CherryToolEngine = class {
6769
7849
  try {
6770
7850
  await subscriptions.ack(sessionId, result.nextCursor);
6771
7851
  } catch (error) {
6772
- console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed`, error);
7852
+ console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` + failureText(error));
6773
7853
  this.#reEnsureInBackground(record.deviceId, sessionId);
6774
7854
  }
6775
7855
  return {
@@ -8570,13 +9650,23 @@ var CherryToolEngine = class {
8570
9650
  * identity object: the verdict is already known and true, and only the
8571
9651
  * content behind it failed.
8572
9652
  *
8573
- * `aggregateError` is BOUNDED like every other peer-echoed string in a result
8574
- * (the full text goes to the log): the reason is actionable at 200 characters
8575
- * and the log is where the operator reads the rest.
9653
+ * `aggregateError` is a TOOL RESULT, not a log line — see
9654
+ * {@link defineCherryTool} for the product decision (issue #161): a result is
9655
+ * the MODEL's signal, so it is not bounded by the logging rule, and the
9656
+ * 200-character clip here is this field's own choice.
9657
+ *
9658
+ * The LOG line IS the logging face, and it renders the failure as ONE bounded
9659
+ * string through {@link failureText} (issue #161). An assembly failure is
9660
+ * usually a peer RPC refusal — its `error.message` is the peer's own text,
9661
+ * capped at 512 characters by the failure schema — and the old line
9662
+ * interpolated it whole: one 512-char refusal made a 654-character line for
9663
+ * the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
9664
+ * RESULT keeps the raw message's first 200 characters; a bound on the log
9665
+ * does not change what the model receives.
8576
9666
  */
8577
9667
  #assemblyFailed(identity, error) {
8578
9668
  const message = error instanceof Error ? error.message : String(error);
8579
- console.warn(`[cherry-remote] the ${String(identity["status"])} answer for session ${String(identity["sessionId"])} could not assemble its content (${message}); returning the verdict with the assembly failure reported`);
9669
+ 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`);
8580
9670
  return {
8581
9671
  ...identity,
8582
9672
  aggregateError: message.slice(0, 200),
@@ -8655,15 +9745,18 @@ var CherryToolEngine = class {
8655
9745
  async #readTerminalMessages(connection, sessionId, session, deadline) {
8656
9746
  const historyRevision = optionalString(session["historyRevision"]);
8657
9747
  if (historyRevision === void 0) return { messages: [] };
9748
+ const messageList = messageListRequest(connection);
8658
9749
  const page = await this.#aggregate(connection, "agent.messages.list", {
8659
9750
  sessionId,
8660
- historyRevision
9751
+ historyRevision,
9752
+ ...messageList.params
8661
9753
  }, (result) => ({
8662
9754
  ...arrayOf(result["items"]),
8663
9755
  nextCursor: result["nextCursor"]
8664
9756
  }), {
8665
9757
  limit: TERMINAL_AGGREGATE_MESSAGES,
8666
- deadline
9758
+ deadline,
9759
+ maxPerPage: messageList.maxPerPage
8667
9760
  });
8668
9761
  const messages = [];
8669
9762
  let projectedBytes = 0;
@@ -8721,11 +9814,11 @@ var CherryToolEngine = class {
8721
9814
  try {
8722
9815
  layer = this.#deps.subs(deviceId);
8723
9816
  } catch (error) {
8724
- console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}`, error);
9817
+ console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`);
8725
9818
  return;
8726
9819
  }
8727
9820
  layer.ensure(sessionId).catch((error) => {
8728
- console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed`, error);
9821
+ console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
8729
9822
  });
8730
9823
  }
8731
9824
  /**
@@ -10257,9 +11350,20 @@ function normalizeHost(raw) {
10257
11350
  return raw.slice(1, -1);
10258
11351
  }
10259
11352
  /**
10260
- * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract, and `port` is
10261
- * shared by every host (spec §3.2) — there is no per-host port and no merge
10262
- * semantics to get wrong.
11353
+ * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
11354
+ *
11355
+ * The map semantics this face owes (the record's per-host maps are DERIVED,
11356
+ * never patched directly — there is no `ports` field in this body):
11357
+ *
11358
+ * - neither `hosts` nor `port` patched → the maps are unchanged;
11359
+ * - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
11360
+ * host gets `ws` and the record-level port;
11361
+ * - a CHANGED `port` (with or without `hosts`) → EVERY final host is
11362
+ * re-pointed to it. The face has one port field, so leaving per-host
11363
+ * entries would make the edit inert for every listed host that already had
11364
+ * one. Sending the CURRENT value is a no-op for the maps — the settings page
11365
+ * sends both fields on every save, and a mixed-port record must not be
11366
+ * flattened by a save that did not change the port.
10263
11367
  */
10264
11368
  function parsePatch(body) {
10265
11369
  const patch = {};
@@ -10283,6 +11387,12 @@ function parsePatch(body) {
10283
11387
  if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
10284
11388
  return normalizeHost(entry);
10285
11389
  });
11390
+ const seen = /* @__PURE__ */ new Set();
11391
+ for (const host of patch.hosts) {
11392
+ const key = host.toLowerCase();
11393
+ if (seen.has(key)) throw new PatchError("hosts must not contain duplicates");
11394
+ seen.add(key);
11395
+ }
10286
11396
  }
10287
11397
  const port = body["port"];
10288
11398
  if (port !== void 0) {
@@ -10638,6 +11748,23 @@ async function handleRequest(req, res, route) {
10638
11748
  writeError(res, 405, "method-not-allowed", "DELETE or PATCH only");
10639
11749
  return;
10640
11750
  }
11751
+ if (first === "devices" && match.segments.length === 3 && second !== void 0) {
11752
+ const deviceId = decodeSegment(second);
11753
+ if (deviceId === void 0) {
11754
+ writeError(res, 400, "invalid-request", "device id is not valid URL encoding");
11755
+ return;
11756
+ }
11757
+ const action = match.segments[2];
11758
+ if (action !== "forget" && action !== "remember") {
11759
+ writeError(res, 404, "not-found", "unknown route");
11760
+ return;
11761
+ }
11762
+ if (method !== "POST") {
11763
+ writeError(res, 405, "method-not-allowed", "POST only");
11764
+ return;
11765
+ }
11766
+ return await changeEndpoints(req, res, route, deviceId, action);
11767
+ }
10641
11768
  if (first === "pair" && match.segments.length === 1) {
10642
11769
  if (method !== "POST") {
10643
11770
  writeError(res, 405, "method-not-allowed", "POST only");
@@ -10675,14 +11802,28 @@ async function handleRequest(req, res, route) {
10675
11802
  }
10676
11803
  }
10677
11804
  function listDevices(res, route) {
10678
- writeJson(res, 200, route.deps.store.list().map((device) => ({
11805
+ writeJson(res, 200, route.deps.store.list().map((device) => deviceSummary(route, device)));
11806
+ }
11807
+ /**
11808
+ * One device as the browser sees it: the public archive projection, never a
11809
+ * credential.
11810
+ *
11811
+ * ONE builder, because FOUR faces answer this shape — `GET /devices`, the
11812
+ * `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
11813
+ * per face is how a field ends up on one and not the others.
11814
+ */
11815
+ function deviceSummary(route, device) {
11816
+ return {
10679
11817
  deviceId: device.deviceId,
10680
11818
  displayName: device.displayName,
10681
11819
  state: projectState(device, route.deps.connect),
10682
11820
  enabled: device.enabled,
10683
11821
  hosts: [...device.hosts],
10684
- port: device.port
10685
- })));
11822
+ port: device.port,
11823
+ security: { ...device.security },
11824
+ ports: { ...device.ports },
11825
+ forgottenHosts: [...device.forgottenHosts ?? []]
11826
+ };
10686
11827
  }
10687
11828
  /**
10688
11829
  * The `GET /diagnostics` body (spec §5).
@@ -10851,11 +11992,13 @@ function deviceById(deps, deviceId) {
10851
11992
  const found = deps.store.list().find((device) => device.deviceId === deviceId);
10852
11993
  if (found !== void 0) return found;
10853
11994
  return {
10854
- schemaVersion: 2,
11995
+ schemaVersion: 4,
10855
11996
  deviceId,
10856
11997
  displayName: "",
10857
11998
  hosts: [],
10858
11999
  port: 0,
12000
+ security: {},
12001
+ ports: {},
10859
12002
  desktopIdentity: "",
10860
12003
  grants: [],
10861
12004
  enabled: false
@@ -10905,12 +12048,21 @@ async function patchDevice(req, res, route, deviceId) {
10905
12048
  const previousEnabled = existing.enabled;
10906
12049
  const previousHosts = existing.hosts;
10907
12050
  const previousPort = existing.port;
12051
+ const previousSecurity = existing.security;
12052
+ const previousPorts = existing.ports;
12053
+ 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);
12054
+ const nextForgotten = patch.hosts === void 0 || existing.forgottenHosts === void 0 ? void 0 : existing.forgottenHosts.filter((host) => !patch.hosts.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()));
10908
12055
  const next = {
10909
12056
  ...existing,
12057
+ ...nextForgotten === void 0 ? {} : { forgottenHosts: nextForgotten },
10910
12058
  ...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
10911
12059
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
10912
- ...patch.hosts === void 0 ? {} : { hosts: patch.hosts },
10913
- ...patch.port === void 0 ? {} : { port: patch.port }
12060
+ ...patch.hosts === void 0 ? {} : {
12061
+ hosts: patch.hosts,
12062
+ security: securityFor(patch.hosts, previousSecurity)
12063
+ },
12064
+ ...patch.port === void 0 ? {} : { port: patch.port },
12065
+ ports: nextPorts
10914
12066
  };
10915
12067
  try {
10916
12068
  route.deps.store.upsert(next);
@@ -10919,7 +12071,7 @@ async function patchDevice(req, res, route, deviceId) {
10919
12071
  writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
10920
12072
  return;
10921
12073
  }
10922
- const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort;
12074
+ const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort || !sameSecurity(next.security, previousSecurity);
10923
12075
  const enabledChanged = patch.enabled !== void 0 && patch.enabled !== previousEnabled;
10924
12076
  if (addressChanged) evict(route, deviceId);
10925
12077
  if (enabledChanged) hibernate(route, deviceId);
@@ -10928,29 +12080,167 @@ async function patchDevice(req, res, route, deviceId) {
10928
12080
  } catch (error) {
10929
12081
  console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error);
10930
12082
  }
10931
- const state = projectState(next, route.deps.connect);
12083
+ const summary = deviceSummary(route, next);
10932
12084
  if (patch.enabled !== void 0 && patch.enabled !== previousEnabled) route.hub.publish("device-state", {
10933
12085
  deviceId,
10934
- state
10935
- });
10936
- writeJson(res, 200, {
10937
- deviceId: next.deviceId,
10938
- displayName: next.displayName,
10939
- state,
10940
- enabled: next.enabled,
10941
- hosts: [...next.hosts],
10942
- port: next.port
12086
+ state: summary.state
10943
12087
  });
12088
+ writeJson(res, 200, summary);
10944
12089
  }
10945
12090
  function warnConnect(deviceId) {
10946
12091
  return (error) => {
10947
- console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed`, error);
12092
+ console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`);
10948
12093
  };
10949
12094
  }
10950
12095
  /** Order-sensitive host comparison: `hosts` is an ordered candidate list. */
10951
12096
  function sameHosts(a, b) {
10952
12097
  return a.length === b.length && a.every((host, index) => host === b[index]);
10953
12098
  }
12099
+ /**
12100
+ * `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
12101
+ * settings page's door onto #185's endpoint maintenance, which until now only
12102
+ * the `cherry_devices` tool could open.
12103
+ *
12104
+ * One action per request, and the action IS the request: there is no composite
12105
+ * "patch the record AND forget a host" body whose ordering would have to be
12106
+ * defined. Every judgement is the store's — the last-candidate refusal, the
12107
+ * case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
12108
+ * the two doors share ONE set of STORE rules; this route only maps the verdicts
12109
+ * onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
12110
+ * differ on purpose — see `parseEndpointHosts`.)
12111
+ *
12112
+ * EVICTION on a change, exactly like the tool's own arm and a PATCH's address
12113
+ * maintenance, and for a DIFFERENT reason on each action — which is why both
12114
+ * are stated here rather than waved through as "parity":
12115
+ *
12116
+ * - FORGET: a pooled connection CAPTURES its record at construction, so the
12117
+ * socket built before this write would keep dialing the host we just forgot.
12118
+ * (The mark itself is honoured by the store's learn merge, so a reconnect is
12119
+ * about the DIAL LIST, not about suppressing the address.)
12120
+ * - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
12121
+ * stored record. Without the eviction a desktop that stays connected would
12122
+ * not re-handshake for as long as the link holds, so the restored address
12123
+ * would come back only after some later flap — the button would look inert.
12124
+ *
12125
+ * The cost is real and inherited from the tool's arm: an eviction disposes the
12126
+ * subscription layer and closes a healthy socket, so a write can drop an
12127
+ * in-flight command's link. One operator click now reaches that churn; that is
12128
+ * the price of "the archive is the truth" for a live connection.
12129
+ */
12130
+ async function changeEndpoints(req, res, route, deviceId, action) {
12131
+ const existing = route.deps.store.list().find((device) => device.deviceId === deviceId);
12132
+ if (existing === void 0) {
12133
+ writeError(res, 404, "not-found", `no device ${deviceId}`);
12134
+ return;
12135
+ }
12136
+ let hosts;
12137
+ try {
12138
+ hosts = parseEndpointHosts(await readJsonBody(req));
12139
+ } catch (error) {
12140
+ writeError(res, 400, "invalid-request", error instanceof Error ? error.message : String(error));
12141
+ return;
12142
+ }
12143
+ try {
12144
+ if (action === "forget") {
12145
+ const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts);
12146
+ if (removed.length > 0 || marked.length > 0) evict(route, deviceId);
12147
+ } else {
12148
+ const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts);
12149
+ if (restored.length > 0) evict(route, deviceId);
12150
+ }
12151
+ } catch (error) {
12152
+ if (writeQuarantineRefusal(res, route, error)) return;
12153
+ if (error instanceof LastCandidateHostError) {
12154
+ writeError(res, 400, "last-address", error.message);
12155
+ return;
12156
+ }
12157
+ writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
12158
+ return;
12159
+ }
12160
+ writeJson(res, 200, deviceSummary(route, route.deps.store.list().find((device) => device.deviceId === deviceId) ?? existing));
12161
+ }
12162
+ /**
12163
+ * The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
12164
+ * normalized the same way the PATCH normalizes an address.
12165
+ *
12166
+ * The normalization is not cosmetic: an IPv6 literal is stored bare but a
12167
+ * browser copy-pastes it bracketed, and the store matches by host spelling —
12168
+ * so `[::1]` would be treated as an address the record does not dial and merely
12169
+ * marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
12170
+ * spelling (same `normalizeHost`), and deliberately NOT for its duplicate
12171
+ * refusal: a repeated host in an action body names one host twice, which the
12172
+ * store's own sets collapse, while the PATCH's duplicates would build an
12173
+ * ambiguous record (`hosts`, `security` and `ports` are keyed by host).
12174
+ */
12175
+ function parseEndpointHosts(body) {
12176
+ const hosts = body["hosts"];
12177
+ if (!Array.isArray(hosts) || hosts.length === 0) throw new PatchError("hosts must be a non-empty array");
12178
+ return hosts.map((entry) => {
12179
+ if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
12180
+ return normalizeHost(entry);
12181
+ });
12182
+ }
12183
+ /**
12184
+ * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
12185
+ * keeps the scheme it was dialed with, a host the list just gained defaults to
12186
+ * `ws`, and hosts the list dropped leave no entry behind. An edit is an
12187
+ * address edit, not a scheme edit — re-typing the same host must not silently
12188
+ * demote a TLS-proxied desktop to a plain dial.
12189
+ *
12190
+ * "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
12191
+ * learn merge, the forget/remember actions and the mark clearing already use:
12192
+ * `NAS.example` and `nas.example` are one address, so a PATCH that re-types
12193
+ * the case keeps the remembered scheme instead of resetting it to `ws`.
12194
+ * `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
12195
+ * address edit happened at all, and a new spelling IS a change to persist.
12196
+ */
12197
+ function securityFor(hosts, previous) {
12198
+ const remembered = indexByHost(previous);
12199
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? "ws"]));
12200
+ }
12201
+ /**
12202
+ * A case-insensitive index over a per-host map (issue #189).
12203
+ *
12204
+ * FIRST spelling wins on a collision. A hand-edited archive CAN hold two
12205
+ * spellings of one host (the store does not reject it, and this face does not
12206
+ * touch `previous`), so callers look their exact spelling up FIRST and consult
12207
+ * this index only as the fallback: a surviving spelling therefore keeps its
12208
+ * OWN entry, and the collision rule only decides what a THIRD spelling
12209
+ * inherits.
12210
+ */
12211
+ function indexByHost(previous) {
12212
+ const index = /* @__PURE__ */ new Map();
12213
+ for (const [host, value] of Object.entries(previous)) {
12214
+ const key = host.toLowerCase();
12215
+ if (!index.has(key)) index.set(key, value);
12216
+ }
12217
+ return index;
12218
+ }
12219
+ /** The exact spelling's entry, else the case-insensitive one, else nothing. */
12220
+ function exactOrRemembered(previous, remembered, host) {
12221
+ return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase());
12222
+ }
12223
+ /**
12224
+ * The port map for a host list (issue #135): each named host keeps the port it
12225
+ * was dialed with, a host the list just gained defaults to `fallback` (the
12226
+ * record-level port), and hosts the list dropped leave no entry behind.
12227
+ *
12228
+ * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
12229
+ * `port` patch): there is no remembered entry to keep, by design — one port
12230
+ * field on this face must move the whole list. Same `Object.hasOwn`/
12231
+ * case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
12232
+ * keeps its own port, and an inherited function is never a remembered port.
12233
+ */
12234
+ function portsFor(hosts, previous, fallback) {
12235
+ const remembered = indexByHost(previous);
12236
+ return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]));
12237
+ }
12238
+ /** Order-insensitive comparison for two scheme maps. */
12239
+ function sameSecurity(a, b) {
12240
+ const keys = Object.keys(a);
12241
+ if (keys.length !== Object.keys(b).length) return false;
12242
+ return keys.every((key) => a[key] === b[key]);
12243
+ }
10954
12244
  /** Drops the pooled connection, closing any live socket first. */
10955
12245
  function evict(route, deviceId) {
10956
12246
  try {
@@ -11065,11 +12355,12 @@ function openEvents(req, res, route) {
11065
12355
  //#region src/index.ts
11066
12356
  const name = "cherry-remote";
11067
12357
  const inject = ["tools"];
11068
- /** Applies the documented fallbacks to the plugin config's numeric keys. */
12358
+ /** Applies the documented fallbacks and validates the plugin config's keys. */
11069
12359
  function resolveBridgeConfig(config = {}) {
11070
12360
  return {
11071
12361
  rateBudgetPerSecond: positive("rateBudgetPerSecond", config.rateBudgetPerSecond, 32),
11072
- perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
12362
+ perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
12363
+ wsTls: dialOptions(config.wsTls)
11073
12364
  };
11074
12365
  }
11075
12366
  /**
@@ -11094,6 +12385,76 @@ function positive(key, value, fallback) {
11094
12385
  }
11095
12386
  return value;
11096
12387
  }
12388
+ /** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
12389
+ const WS_TLS_KEYS = /* @__PURE__ */ new Set(["rejectUnauthorized", "ca"]);
12390
+ /** The shape a non-object config value arrived as, for a value-free warning. */
12391
+ function shapeOf(value) {
12392
+ if (value === null) return "null";
12393
+ if (Array.isArray(value)) return "array";
12394
+ return typeof value;
12395
+ }
12396
+ /**
12397
+ * One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
12398
+ *
12399
+ * A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
12400
+ * would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
12401
+ * would load bytes the operator never inspected. The header check is a SHAPE
12402
+ * check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
12403
+ * and an unparseable body fails the handshake loudly, which is the honest
12404
+ * verdict. Buffers keep the non-empty test with no header requirement: DER and
12405
+ * programmatically built PEM bytes are legitimate `ca` values.
12406
+ */
12407
+ function certificateEntry(value) {
12408
+ if (typeof value === "string") return value.trimStart().startsWith("-----BEGIN");
12409
+ if (Buffer.isBuffer(value)) return value.length > 0;
12410
+ return false;
12411
+ }
12412
+ /**
12413
+ * A configured TLS trust bag, or `undefined` when absent/unusable.
12414
+ *
12415
+ * ABSENT is the documented default (Node's trust store) and says nothing —
12416
+ * exactly like {@link positive}. A key that is PRESENT but unusable is a
12417
+ * deployment mistake that silently changes which peers are reachable, so it is
12418
+ * warned about by name rather than swallowed — but NEVER by value: a `ca` is
12419
+ * PEM CONTENT, and a certificate must not reach a log.
12420
+ *
12421
+ * FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
12422
+ * bag rather than forwarding the parts that happen to look right. Trust must be
12423
+ * applied AS WRITTEN: a partially applied bag would reach some peers and not
12424
+ * others with nothing telling the operator which half took effect, and their
12425
+ * `ca` intention would be silently replaced by Node's default store. The
12426
+ * fallback is "no override" — the same state as absent.
12427
+ */
12428
+ function dialOptions(value) {
12429
+ if (value === void 0) return void 0;
12430
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
12431
+ console.warn(`[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options (got ${shapeOf(value)}); ignoring it`);
12432
+ return;
12433
+ }
12434
+ const record = value;
12435
+ const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key));
12436
+ if (unknownKeys.length > 0) {
12437
+ console.warn(`[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(", ")}; only flat rejectUnauthorized / ca are accepted — ignoring the whole object`);
12438
+ return;
12439
+ }
12440
+ const rejectUnauthorized = record["rejectUnauthorized"];
12441
+ if (rejectUnauthorized !== void 0 && typeof rejectUnauthorized !== "boolean") {
12442
+ console.warn("[cherry-remote] config \"wsTls.rejectUnauthorized\" must be a boolean; ignoring the whole object");
12443
+ return;
12444
+ }
12445
+ const ca = record["ca"];
12446
+ if (ca !== void 0) {
12447
+ if (!(Array.isArray(ca) ? ca.length > 0 && ca.every((entry) => certificateEntry(entry)) : certificateEntry(ca))) {
12448
+ 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");
12449
+ return;
12450
+ }
12451
+ }
12452
+ if (rejectUnauthorized === void 0 && ca === void 0) return void 0;
12453
+ return {
12454
+ ...rejectUnauthorized === void 0 ? {} : { rejectUnauthorized },
12455
+ ...ca === void 0 ? {} : { ca }
12456
+ };
12457
+ }
11097
12458
  /** The data directory convention every DSH plugin follows. */
11098
12459
  function resolveDataDir(config = {}, env = process.env) {
11099
12460
  if (config.dataDir !== void 0 && config.dataDir !== "") return config.dataDir;
@@ -11135,6 +12496,7 @@ const state = {
11135
12496
  identityError: void 0,
11136
12497
  rateBudgetPerSecond: 32,
11137
12498
  perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
12499
+ wsTls: void 0,
11138
12500
  connections: /* @__PURE__ */ new Map(),
11139
12501
  subscriptions: /* @__PURE__ */ new Map(),
11140
12502
  deviceStateListeners: /* @__PURE__ */ new Set(),
@@ -11200,7 +12562,11 @@ function connectionFor(deviceId) {
11200
12562
  const connection = new DeviceConnection({
11201
12563
  identity: requireIdentity(),
11202
12564
  record,
11203
- rateBudgetPerSecond: state.rateBudgetPerSecond
12565
+ rateBudgetPerSecond: state.rateBudgetPerSecond,
12566
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
12567
+ onAdvertisedEndpoints: (endpoints) => {
12568
+ state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity);
12569
+ }
11204
12570
  });
11205
12571
  state.connectionsBuilt += 1;
11206
12572
  connection.onState((deviceState) => {
@@ -11221,7 +12587,7 @@ function connectionFor(deviceId) {
11221
12587
  try {
11222
12588
  subsFor(deviceId).handleNotification(method, params);
11223
12589
  } catch (error) {
11224
- console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed`, error);
12590
+ console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`);
11225
12591
  }
11226
12592
  });
11227
12593
  state.connections.set(deviceId, connection);
@@ -11312,6 +12678,30 @@ function subsFor(deviceId) {
11312
12678
  return created;
11313
12679
  }
11314
12680
  /**
12681
+ * Bounds the link-recovery line's session-id list (issue #155).
12682
+ *
12683
+ * The number of ENTRIES is bounded only by the peer-advertised hello
12684
+ * `subscriptions` — a number the peer chooses, not one this assembly can size —
12685
+ * and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
12686
+ * `sessions.join(', ')` therefore emitted `entries × 258` characters into one
12687
+ * info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
12688
+ * ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
12689
+ * text is peer-controlled (the ids are echoed back from what the peer accepted
12690
+ * as subscriptions), so it needs a LOCAL bound like every other such log.
12691
+ *
12692
+ * `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
12693
+ * 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
12694
+ * own subscription cap fits WHOLE — the bound bites only on a peer that
12695
+ * advertises more room, or on a malformed over-long id.
12696
+ * `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
12697
+ * is never clipped; only a malformed one is. A list that stops early names the
12698
+ * count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
12699
+ * silently. The bound is on the LOG only — the loop still re-ensures every
12700
+ * session.
12701
+ */
12702
+ const RECOVERY_SESSIONS_MAX = 320;
12703
+ const RECOVERY_SESSION_ID_MAX = 256;
12704
+ /**
11315
12705
  * The reconnect recovery line (ruling 8): every session this device had
11316
12706
  * subscribed is re-ensured at its LAST cursor, so the peer replays only what
11317
12707
  * this harness has not yet consumed.
@@ -11325,14 +12715,17 @@ async function recoverSubscriptions(deviceId) {
11325
12715
  const subscriptions = state.subscriptions.get(deviceId);
11326
12716
  if (subscriptions === void 0) return;
11327
12717
  const sessions = subscriptions.activeSessions();
11328
- if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${sessions.join(", ")}`);
12718
+ if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(sessions, {
12719
+ max: RECOVERY_SESSIONS_MAX,
12720
+ itemMax: RECOVERY_SESSION_ID_MAX
12721
+ })}`);
11329
12722
  for (const sessionId of sessions) try {
11330
12723
  const result = subscriptions.read(sessionId);
11331
12724
  const cursor = "events" in result ? result.nextCursor : void 0;
11332
12725
  await subscriptions.ensure(sessionId, cursor);
11333
12726
  console.info(`[cherry-remote] subscription recovered for ${deviceId} session ${sessionId}` + (cursor === void 0 ? "" : ` at seq ${cursor.seq}`));
11334
12727
  } catch (error) {
11335
- console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed`, error);
12728
+ console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
11336
12729
  }
11337
12730
  }
11338
12731
  /** One dep object, shared by both faces, so there is exactly ONE pool. */
@@ -11360,6 +12753,7 @@ function assemblyDeps() {
11360
12753
  payload: request.payload,
11361
12754
  store: state.store,
11362
12755
  ...request.name === void 0 ? {} : { name: request.name },
12756
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
11363
12757
  onVerificationCode: request.onVerificationCode,
11364
12758
  onStatus: (stage) => publishPairingProgress({ stage })
11365
12759
  });
@@ -11428,6 +12822,7 @@ function apply(ctx, config = {}) {
11428
12822
  const bridgeConfig = resolveBridgeConfig(config);
11429
12823
  state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond;
11430
12824
  state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes;
12825
+ state.wsTls = bridgeConfig.wsTls;
11431
12826
  state.store = new DeviceStore(join(dataDir, "devices.json"));
11432
12827
  state.recentErrors.length = 0;
11433
12828
  state.connectionsBuilt = 0;