dsh-cherry-bridge 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5,7 +5,8 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { WebSocket } from "ws";
8
- import { jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, remoteConnectPath, remoteFailureSchema } from "@cherrystudio/remote-protocol";
8
+ import { directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
9
+ import { peerIdFromString } from "@libp2p/peer-id";
9
10
  const FILE_MODE = 384;
10
11
  /**
11
12
  * Version-to-version migrations, keyed by the version they upgrade from.
@@ -13,15 +14,70 @@ const FILE_MODE = 384;
13
14
  * `{ 1 -> 2 }` is a mechanism sample only, mirroring the plan's convention that
14
15
  * real schema evolution starts at version 2 after release. A v1 archive is the
15
16
  * current field set minus `schemaVersion`.
16
- */
17
- const MIGRATIONS = { 1: (record) => ({
18
- ...record,
19
- schemaVersion: 2
20
- }) };
17
+ *
18
+ * `{ 2 -> 3 }` (issue #126) adds the per-host `security` map. v2 had `hosts` +
19
+ * one `port` and no scheme at all, so every host is backfilled `ws` — the
20
+ * plain dial v2 always used. An archive that already carries SOME entries
21
+ * (a half-migrated file, or one a newer build wrote) keeps them: a scheme the
22
+ * record remembers is never overwritten by the default, because that would
23
+ * downgrade a TLS-proxied desktop to a dial that cannot connect.
24
+ *
25
+ * `{ 3 -> 4 }` (issue #135) adds the per-host `ports` map. v3 had `hosts` + one
26
+ * shared `port` (the winner candidate's), so the step backfills `ports` from
27
+ * `port` for every host — the only one v3 ever dialed. A half-migrated file
28
+ * keeps the entries it already carries, for the same reason the scheme step
29
+ * does: a remembered port is the address the pairing actually used, and
30
+ * overwriting it would point the host at the winner's port again.
31
+ *
32
+ * **A legacy file is read loosely, this build's own output strictly —
33
+ * deliberately.** Each step fills its map for the CURRENT host list and
34
+ * nothing else: an entry naming a host the older file no longer lists is
35
+ * silently DROPPED (it was unreachable anyway), a non-object `security`/`ports`
36
+ * degrades to "every host on `ws`"/"every host at `port`", and a junk scheme or
37
+ * port VALUE is not repaired here at all — the migrated entry then continues
38
+ * down the chain and `assertValidSecurity`/`assertValidPorts` reject the whole
39
+ * archive. That split is the point: a v2 or v3 file is a supported legacy shape
40
+ * and is normalised into one, while a v4 file is this build's own output and
41
+ * any deviation from it is a hand edit worth failing loudly on.
42
+ */
43
+ const MIGRATIONS = {
44
+ 1: (record) => ({
45
+ ...record,
46
+ schemaVersion: 2
47
+ }),
48
+ 2: (record) => ({
49
+ ...record,
50
+ schemaVersion: 3,
51
+ security: backfilledSecurity(record)
52
+ }),
53
+ 3: (record) => ({
54
+ ...record,
55
+ schemaVersion: 4,
56
+ ports: backfilledPorts(record)
57
+ })
58
+ };
59
+ /** Every host of a v2 record, defaulting anything it did not already say. */
60
+ function backfilledSecurity(disk) {
61
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
62
+ const existing = disk.security ?? {};
63
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : "ws"]));
64
+ }
65
+ /** Every host of a v3 record, defaulting anything it did not already say. */
66
+ function backfilledPorts(disk) {
67
+ const hosts = Array.isArray(disk.hosts) ? disk.hosts : [];
68
+ const existing = disk.ports ?? {};
69
+ const fallback = disk.port;
70
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(existing, host) ? existing[host] : fallback]));
71
+ }
21
72
  /**
22
- * 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.
73
+ * The field set of a v1 entry: what the store wrote before it carried a
74
+ * version at all.
75
+ *
76
+ * FROZEN. It must not track the current record — a v1 archive predates every
77
+ * later field, so demanding one here (as `'security'` was, briefly) fails the
78
+ * completeness gate on an archive that is perfectly intact and quarantines it
79
+ * instead of migrating it. Later fields are the migration chain's business
80
+ * (`backfilledSecurity`/`backfilledPorts` supply `security`/`ports`).
25
81
  */
26
82
  const V1_KEYS = [
27
83
  "deviceId",
@@ -32,7 +88,18 @@ const V1_KEYS = [
32
88
  "grants",
33
89
  "enabled"
34
90
  ];
35
- const REQUIRED_KEYS = [...V1_KEYS, "schemaVersion"];
91
+ /**
92
+ * Every field of the CURRENT set plus `schemaVersion`. Unlike {@link V1_KEYS}
93
+ * this is not a historical snapshot: it grows with the record, and it is what
94
+ * `upsert` and the post-migration exit demand (which is why a v4 entry without
95
+ * `security`/`ports` never reaches its own validator).
96
+ */
97
+ const REQUIRED_KEYS = [
98
+ ...V1_KEYS,
99
+ "security",
100
+ "ports",
101
+ "schemaVersion"
102
+ ];
36
103
  /**
37
104
  * A version-less entry reads as the legacy (v1) shape only when every v1 field
38
105
  * is present. A missing `schemaVersion` alone means v1; a missing
@@ -44,14 +111,61 @@ function hasCompleteFieldSet(disk) {
44
111
  }
45
112
  /** Asserts the required `DeviceRecord` shape. The read path's exit. */
46
113
  function assertValidRecord(disk) {
47
- if (disk.schemaVersion !== 2) throw new Error(`device archive entry has unsupported schemaVersion ${String(disk.schemaVersion)}; expected 2`);
114
+ if (disk.schemaVersion !== 4) throw new Error(`device archive entry has unsupported schemaVersion ${String(disk.schemaVersion)}; expected 4`);
48
115
  const missing = REQUIRED_KEYS.filter((key) => disk[key] === void 0);
49
116
  if (missing.length > 0) throw new Error(`device archive entry is missing required field(s): ${missing.join(", ")}`);
50
117
  if (!Array.isArray(disk.hosts)) throw new Error("device archive entry field hosts must be an array");
118
+ if (typeof disk.port !== "number" || !Number.isInteger(disk.port) || disk.port < 1 || disk.port > 65535) throw new Error("device archive entry field port must be an integer in 1..65535");
119
+ assertValidSecurity(disk.hosts, disk.security);
120
+ assertValidPorts(disk.hosts, disk.ports);
51
121
  if (disk.accessToken !== void 0 !== (disk.tokenExpiresAt !== void 0)) throw new Error("device archive entry must carry accessToken and tokenExpiresAt together or not at all");
52
122
  return disk;
53
123
  }
54
124
  /**
125
+ * The `security` map must be EXACTLY one entry per host, each a known scheme.
126
+ *
127
+ * Keyed by host, so a record can carry an entry the dial will never consult
128
+ * (a host it no longer lists) and a host the dial will fall back on silently.
129
+ * Both are hand-edited-archive states, and both are rejected rather than
130
+ * repaired in memory: the archive a human edits is the archive the store
131
+ * judges, and a silent repair would hide the very mistake. The message names
132
+ * the offending HOSTS (they are addresses an operator chose, not secrets).
133
+ */
134
+ function assertValidSecurity(hosts, security) {
135
+ if (security === void 0) return;
136
+ if (security === null || typeof security !== "object" || Array.isArray(security)) throw new Error("device archive entry field security must be an object keyed by host");
137
+ const map = security;
138
+ const wanted = new Set(hosts);
139
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
140
+ if (unknownHost !== void 0) throw new Error(`device archive entry field security names host "${unknownHost}", which is not in hosts`);
141
+ for (const host of hosts) {
142
+ const scheme = map[host];
143
+ if (scheme !== "ws" && scheme !== "wss") throw new Error(`device archive entry field security["${host}"] must be "ws" or "wss"`);
144
+ }
145
+ }
146
+ /**
147
+ * The `ports` map must be EXACTLY one entry per host, each an integer port.
148
+ *
149
+ * The same reasoning as {@link assertValidSecurity}: an entry for a host the
150
+ * record no longer lists would never be consulted, and a missing one would
151
+ * leave that host on the record-level (winner's) port — the exact failure the
152
+ * map exists to prevent. Both are rejected rather than repaired. The
153
+ * message names the offending HOST, never the value (a port is not a secret, but
154
+ * echoing a junk value back from a hand-edited file adds nothing actionable).
155
+ */
156
+ function assertValidPorts(hosts, ports) {
157
+ if (ports === void 0) return;
158
+ if (ports === null || typeof ports !== "object" || Array.isArray(ports)) throw new Error("device archive entry field ports must be an object keyed by host");
159
+ const map = ports;
160
+ const wanted = new Set(hosts);
161
+ const unknownHost = Object.keys(map).find((host) => !wanted.has(host));
162
+ if (unknownHost !== void 0) throw new Error(`device archive entry field ports names host "${unknownHost}", which is not in hosts`);
163
+ for (const host of hosts) {
164
+ const port = map[host];
165
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`device archive entry field ports["${host}"] must be an integer in 1..65535`);
166
+ }
167
+ }
168
+ /**
55
169
  * Read path, fixed order: loose parse -> completeness gate -> version judgement
56
170
  * -> migration -> assert the required `DeviceRecord` type.
57
171
  *
@@ -74,7 +188,7 @@ function parseRecord(raw, archiveVersion) {
74
188
  if (declared === void 0 && archiveVersion > 1) throw new Error(`device archive entry has no schemaVersion but its archive is schemaVersion ${archiveVersion}; legacy (v1) entries are only valid inside a schemaVersion-1 archive`);
75
189
  if (declared !== void 0 && declared !== archiveVersion) throw new Error(`device archive entry declares schemaVersion ${declared} but its archive is schemaVersion ${archiveVersion}; the archive version is authoritative`);
76
190
  let migrated = disk;
77
- for (let step = archiveVersion; step < 2; step += 1) {
191
+ for (let step = archiveVersion; step < 4; step += 1) {
78
192
  const migrate = MIGRATIONS[step];
79
193
  if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
80
194
  migrated = migrate(migrated);
@@ -85,7 +199,7 @@ function parseArchive(parsed) {
85
199
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
86
200
  const archive = parsed;
87
201
  if (typeof archive.schemaVersion !== "number") throw new Error("device archive is missing a numeric schemaVersion");
88
- if (archive.schemaVersion > 2) throw new Error(`device archive declares schemaVersion ${archive.schemaVersion}, newer than supported 2; refusing to read it`);
202
+ if (archive.schemaVersion > 4) throw new Error(`device archive declares schemaVersion ${archive.schemaVersion}, newer than supported 4; refusing to read it`);
89
203
  if (!Array.isArray(archive.devices)) throw new Error("device archive is missing a devices array");
90
204
  const archiveVersion = archive.schemaVersion;
91
205
  return {
@@ -95,7 +209,7 @@ function parseArchive(parsed) {
95
209
  }
96
210
  function serializeArchive(devices) {
97
211
  return `${JSON.stringify({
98
- schemaVersion: 2,
212
+ schemaVersion: 4,
99
213
  devices
100
214
  }, null, 2)}\n`;
101
215
  }
@@ -376,7 +490,7 @@ var DeviceStore = class {
376
490
  }
377
491
  const devices = archive === void 0 ? [] : archive.devices;
378
492
  this.#devices = devices;
379
- if (archive !== void 0 && archive.schemaVersion < 2) this.#persist(devices);
493
+ if (archive !== void 0 && archive.schemaVersion < 4) this.#persist(devices);
380
494
  return devices;
381
495
  }
382
496
  #persist(devices) {
@@ -471,6 +585,124 @@ async function generateIdentity(dataDir, path) {
471
585
  return parseIdentity(key);
472
586
  }
473
587
  //#endregion
588
+ //#region src/text.ts
589
+ /**
590
+ * Bounded rendering for peer- and payload-controlled text (issue #155).
591
+ *
592
+ * The ONE helper this audit STANDARDISED ON — not the first attempt at the
593
+ * idea: `src/diagnostics.ts`'s `truncateErrorMessage` (the diagnostics error
594
+ * digest) and `src/rpc/jsonRpcClient.ts`'s `bounded()` (the frame-drop
595
+ * warning's classification fields) predate it and stay where they are, for
596
+ * their own callers. #155 is where the rendering rule was stated once and
597
+ * applied to the SIX sites the audit found — four files: pairing's unknown-key
598
+ * warning and stage verdict, the link-recovery line, the two connection error
599
+ * lines, and the JSON-RPC notification-handler line. Each was unbounded for a
600
+ * different "why not" (a 10 KiB payload cap, a hello-advertised field, the
601
+ * 64 KiB record budget), which is exactly how the class of defect recurs: each
602
+ * site answered the question for itself.
603
+ *
604
+ * The rule these helpers establish is stated once, here: bound the text where
605
+ * it is RENDERED (never where it is carried — an error message is a value
606
+ * until a log line or a verdict makes it text), name the amount removed with
607
+ * the `… (+N chars)` marker, and never drop a list ENTRY silently (the
608
+ * `(+K more)` count is explicit).
609
+ *
610
+ * Issue #161 swept the SAME SHAPE at the sites #155 did not name — a catch
611
+ * handing an error OBJECT to `console.warn`'s second argument — on the paths
612
+ * PEER traffic reaches. One renderer states the answer there, {@link
613
+ * failureText}: the message alone, bounded. A purely LOCAL catch keeps the
614
+ * object print, deliberately: its stack is the useful part and no peer text
615
+ * can reach it (fs quarantine, the host registry, our own listener / settle
616
+ * fan-out, a REST route's synchronous construction). The two earlier forms it
617
+ * did NOT absorb — `truncateErrorMessage` (the diagnostics digest) and
618
+ * `bounded()` (the frame-drop path) — are left alone: each emits a different
619
+ * marker (`…[truncated]`, a bare `…`) that callers and docs depend on, so
620
+ * folding would change output rather than converge on the rule.
621
+ */
622
+ /**
623
+ * Clips `text` into `max` characters, the `… (+N chars)` marker included in
624
+ * the count. `kept` is a fixed point — the number of removed characters sets
625
+ * the marker's own digits — and it is the LARGEST prefix that fits, so a
626
+ * caller's ceiling is used without ever being exceeded.
627
+ *
628
+ * The final guard never ends the kept prefix between a UTF-16 surrogate pair: a
629
+ * clip there would emit a lone surrogate, which is not valid unicode text (the
630
+ * peer's `unicodeText` params face refuses it) and would travel in a
631
+ * user-visible verdict. Dropping one char pays for a marker that can grow by
632
+ * one digit at a 9→10 boundary, and the marker is re-derived from the new
633
+ * dropped count. Pinned by `tests/unit/text.test.ts` — the branch used to be a
634
+ * declared coverage gap while the clip point was private to pairing.
635
+ */
636
+ function clipText(text, max) {
637
+ if (text.length <= max) return text;
638
+ let kept = max;
639
+ for (;;) {
640
+ const next = max - `… (+${text.length - kept} chars)`.length;
641
+ if (next === kept) break;
642
+ if (next < 0) return "…".slice(0, max);
643
+ kept = next;
644
+ }
645
+ const before = text.charCodeAt(kept - 1);
646
+ const after = text.charCodeAt(kept);
647
+ if (kept > 0 && before >= 55296 && before <= 56319 && after >= 56320 && after <= 57343) kept -= 1;
648
+ return `${text.slice(0, kept)}… (+${text.length - kept} chars)`;
649
+ }
650
+ /**
651
+ * Renders `items` as a comma-joined list under `limits.max`, each item clipped
652
+ * to `limits.itemMax` FIRST. When anything is left out the list says so:
653
+ * ` (+K more)` where K is the number of items NOT named.
654
+ *
655
+ * Caller contract: `itemMax <= max`, so the first item always fits and a list
656
+ * carrying a single over-long entry never renders an empty one. The marker
657
+ * rides ON TOP of `limits.max` — it is a count, not an item, so the named text
658
+ * never has to absorb its length. `[]` renders as `''`.
659
+ *
660
+ * Pairing's progress line (`stageHosts`) deliberately does NOT use this helper:
661
+ * it always shows the FIRST host in full even above its own budget, because
662
+ * that host is the run's identity. That is not an oversight, and it is not a
663
+ * reason to weaken the contract here.
664
+ */
665
+ function boundedJoin(items, limits) {
666
+ const rendered = items.map((item) => clipText(item, limits.itemMax));
667
+ const first = rendered[0];
668
+ if (first === void 0) return "";
669
+ let text = first;
670
+ let shown = 1;
671
+ for (; shown < rendered.length; shown += 1) {
672
+ if (text.length + 2 + rendered[shown].length > limits.max) break;
673
+ text += `, ${rendered[shown]}`;
674
+ }
675
+ const omitted = items.length - shown;
676
+ return omitted > 0 ? `${text} (+${omitted} more)` : text;
677
+ }
678
+ /**
679
+ * Renders ONE caught failure for a log line: the message alone, clipped to
680
+ * {@link LOG_TEXT_MAX}.
681
+ *
682
+ * This is the ONE form a caught failure takes in a PEER-TRIGGERED catch (issue
683
+ * #161): a catch reached from peer traffic or a peer-triggered callback. The
684
+ * old shape — `console.warn(prefix, error)` — handed the console the OBJECT.
685
+ * On Node the console prints the error's name, message and stack (own
686
+ * properties only when inspected), so the peer-controlled message and the
687
+ * stack were both unbounded; for a defensive catch the stack is the noise.
688
+ *
689
+ * A caught failure from a purely LOCAL path (an fs copy, the host registry, our
690
+ * own observers) keeps the object print: its stack is the useful part and no
691
+ * peer text can reach it.
692
+ *
693
+ * TOTAL by construction: it runs INSIDE catch blocks, so a captured value whose
694
+ * `toString` (or an Error subclass whose `message` getter) throws must not
695
+ * escape the catch. The extraction is guarded and falls back to the fixed token
696
+ * `<unrenderable failure>`.
697
+ */
698
+ function failureText(error) {
699
+ try {
700
+ return clipText(error instanceof Error ? error.message : String(error), 240);
701
+ } catch {
702
+ return "<unrenderable failure>";
703
+ }
704
+ }
705
+ //#endregion
474
706
  //#region src/rpc/jsonRpcClient.ts
475
707
  /** Default cap on business requests in flight on a single connection. */
476
708
  const DEFAULT_MAX_IN_FLIGHT = 8;
@@ -896,7 +1128,7 @@ var JsonRpcClient = class {
896
1128
  for (const handler of [...this.handlers]) try {
897
1129
  handler(method, params);
898
1130
  } catch (error) {
899
- console.warn(`[cherry-remote] notification handler for "${method}" threw`, error);
1131
+ console.warn(`[cherry-remote] notification handler for "${bounded(method)}" threw: ${failureText(error)}`);
900
1132
  }
901
1133
  return;
902
1134
  }
@@ -1887,12 +2119,15 @@ function sanitizeDevice(record, state, limits) {
1887
2119
  hosts: [...record.hosts],
1888
2120
  port: record.port,
1889
2121
  desktopIdentity: digestIdentity(record.desktopIdentity),
1890
- ...limits === void 0 ? {} : { limits: {
1891
- heartbeatMs: limits.heartbeatMs,
1892
- subscriptions: limits.subscriptions,
1893
- pageItems: limits.pageItems,
1894
- inFlightRequests: limits.inFlightRequests
1895
- } }
2122
+ ...limits === void 0 ? {} : {
2123
+ limits: {
2124
+ heartbeatMs: limits.heartbeatMs,
2125
+ subscriptions: limits.subscriptions,
2126
+ pageItems: limits.pageItems,
2127
+ inFlightRequests: limits.inFlightRequests
2128
+ },
2129
+ ...limits.messageSummaries === void 0 ? {} : { messageSummaries: limits.messageSummaries }
2130
+ }
1896
2131
  };
1897
2132
  }
1898
2133
  /** Narrows an unknown bag to the report's own shapes, dropping everything else. */
@@ -1907,10 +2142,20 @@ function projectDevice(raw) {
1907
2142
  hosts: Array.isArray(value["hosts"]) ? value["hosts"].map((host) => String(host)) : [],
1908
2143
  port: typeof value["port"] === "number" ? value["port"] : 0,
1909
2144
  desktopIdentity: desktopIdentity === "unknown" || /^[0-9a-f]{12}$/.test(desktopIdentity) ? desktopIdentity : digestIdentity(desktopIdentity),
1910
- ...projectLimits(value["limits"])
2145
+ ...projectLimits(value["limits"]),
2146
+ ...projectMessageSummaries(value["messageSummaries"])
1911
2147
  };
1912
2148
  }
1913
2149
  /**
2150
+ * Re-projects the ONE capability field, dropping anything that is not a finite
2151
+ * number: a version this report cannot read is reported as absent (not
2152
+ * advertised), which is the safe side — the field only ever amplifies what the
2153
+ * peer's hello said.
2154
+ */
2155
+ function projectMessageSummaries(raw) {
2156
+ return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
2157
+ }
2158
+ /**
1914
2159
  * Re-projects the four numeric limit keys, dropping any foreign key that a
1915
2160
  * caller's `limits` object might carry. A whitelist inside the whitelist: the
1916
2161
  * shape is rebuilt field by field rather than spread, so a future addition to
@@ -2305,21 +2550,6 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2305
2550
  */
2306
2551
  const MAX_RESUME_TOKEN_CHARS = 4096;
2307
2552
  //#endregion
2308
- //#region src/urlHost.ts
2309
- /**
2310
- * Renders a host as a URL authority component. The device archive and pairing
2311
- * flow store BARE hosts (plan-mandated normalization), so a bare IPv6 literal
2312
- * must be bracketed before it is spliced into a URL — `ws://::1:35543/...`
2313
- * is not a valid URL and `new WebSocket(...)` throws synchronously on it.
2314
- *
2315
- * Shared home for the dial sites: both `connection.ts` (per-device dial) and
2316
- * `pairing.ts` (candidate dial) splice hosts into URLs and must bracket the
2317
- * same way.
2318
- */
2319
- function urlHost(host) {
2320
- return host.includes(":") ? `[${host}]` : host;
2321
- }
2322
- //#endregion
2323
2553
  //#region src/connection.ts
2324
2554
  /**
2325
2555
  * The transport call the dial hands a socket to (see
@@ -2818,8 +3048,10 @@ var DeviceConnection = class {
2818
3048
  #pendingPings = /* @__PURE__ */ new Set();
2819
3049
  #token;
2820
3050
  #tokenExpiresAt;
3051
+ #wsOptions;
2821
3052
  constructor(opts) {
2822
3053
  this.record = opts.record;
3054
+ this.#wsOptions = opts.wsOptions;
2823
3055
  this.#identity = opts.identity;
2824
3056
  this.#limiter = new TieredRateLimiter(opts.rateBudgetPerSecond ?? 32, contentBudgetFor(opts.rateBudgetPerSecond));
2825
3057
  this.#backoffInitialMs = opts.backoffInitialMs ?? DEFAULT_BACKOFF_INITIAL_MS;
@@ -2997,7 +3229,7 @@ var DeviceConnection = class {
2997
3229
  if (error instanceof ProtocolVersionError) {
2998
3230
  this.#running = false;
2999
3231
  this.#setState("offline");
3000
- console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${error.message}); upgrade Cherry Studio / the plugin`);
3232
+ console.error(`[cherry-remote] device ${this.record.deviceId}: protocol version incompatible (${clipText(error.message, 240)}); upgrade Cherry Studio / the plugin`);
3001
3233
  this.#cycle.reject(/* @__PURE__ */ new Error(`device ${this.record.deviceId}: protocol version incompatible`));
3002
3234
  return;
3003
3235
  }
@@ -3015,7 +3247,8 @@ var DeviceConnection = class {
3015
3247
  if (!this.#running) return;
3016
3248
  const delay = this.#backoffDelay();
3017
3249
  const hint = isAllHostsUnreachable(error) ? " — all candidate addresses were unreachable; if the desktop moved, update them via the device card 地址维护 (address maintenance)" : "";
3018
- console.warn(`[cherry-remote] device ${this.record.deviceId} offline (${error instanceof Error ? error.message : String(error)}); reconnecting in ${delay}ms${hint}`);
3250
+ const detail = error instanceof Error ? error.message : String(error);
3251
+ console.warn(`[cherry-remote] device ${this.record.deviceId} offline (${clipText(detail, 240)}); reconnecting in ${delay}ms${hint}`);
3019
3252
  this.#setTimer("backoff", setTimeout(() => this.#tryEstablish(), delay));
3020
3253
  }
3021
3254
  #backoffDelay() {
@@ -3029,6 +3262,12 @@ var DeviceConnection = class {
3029
3262
  * network failures fall through to the next candidate — the §6
3030
3263
  * 「候选地址全部不可达」 verdict only holds when EVERY host failed.
3031
3264
  *
3265
+ * Each candidate dials with ITS OWN port from `record.ports`; the record-level
3266
+ * `port` (the winner's at pairing, the operator's after a PATCH) is the
3267
+ * fallback for an in-memory record assembled without the map (issue #135).
3268
+ * Before that, one port was shared by every host, so a reconnect to a
3269
+ * non-winner host dialed the right scheme at the wrong port.
3270
+ *
3032
3271
  * "Per-host" covers the CHANNEL-LEVEL handshake failures as well as the dial:
3033
3272
  * a socket that opens and then dies mid-handshake (the peer accepted the TCP
3034
3273
  * connection and went away, a proxy reset it, the Noise exchange was cut) is a
@@ -3046,7 +3285,8 @@ var DeviceConnection = class {
3046
3285
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
3047
3286
  let link;
3048
3287
  try {
3049
- link = await this.#dial(host, this.record.port, epoch);
3288
+ const port = this.record.ports?.[host] ?? this.record.port;
3289
+ link = await this.#dial(host, port, epoch);
3050
3290
  } catch (error) {
3051
3291
  if (error instanceof IdentityMismatchError) throw error;
3052
3292
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -3067,8 +3307,11 @@ var DeviceConnection = class {
3067
3307
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
3068
3308
  }
3069
3309
  async #dial(host, port, epoch) {
3070
- const url = `ws://${urlHost(host)}:${port}${remoteConnectPath}`;
3071
- const ws = new WebSocket(url);
3310
+ const ws = new WebSocket(directEndpointUrl({
3311
+ host,
3312
+ port,
3313
+ security: this.record.security?.[host] ?? "ws"
3314
+ }), this.#wsOptions);
3072
3315
  const controller = new AbortController();
3073
3316
  const opened = new Promise((resolve) => ws.once("open", () => resolve()));
3074
3317
  const failure = new Promise((_, reject) => {
@@ -3133,7 +3376,8 @@ var DeviceConnection = class {
3133
3376
  heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
3134
3377
  subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
3135
3378
  pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
3136
- inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests
3379
+ inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
3380
+ ...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
3137
3381
  };
3138
3382
  link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
3139
3383
  let auth;
@@ -3498,24 +3742,56 @@ var PairingError = class extends Error {
3498
3742
  Object.setPrototypeOf(this, new.target.prototype);
3499
3743
  }
3500
3744
  };
3501
- const PAYLOAD_TYPE = "cherry-studio-pair";
3502
- const PAYLOAD_VERSION = 2;
3503
3745
  /** Overall payload ceiling: a QR code never approaches this. */
3504
3746
  const MAX_PAYLOAD_BYTES = 10240;
3505
- /** `opaqueId` cap for invitationId/invitationSecret/desktopIdentity. */
3506
- const OPAQUE_ID_MAX = 256;
3747
+ /**
3748
+ * Bounds the unknown-key WARNING (issue #155). The keys in that warning are
3749
+ * payload-CONTROLLED NAMES, and `MAX_PAYLOAD_BYTES` is no bound at all on the
3750
+ * line: a 10 KiB payload carrying 32 unknown keys of ~256 characters used to
3751
+ * join into ~8.2 KB of attacker-chosen text (the measured fixture is 8,192
3752
+ * characters of keys and an 8,312-character line) on the operator's log.
3753
+ *
3754
+ * `UNKNOWN_FIELDS_MAX` is the joined list's budget — the same 240-character
3755
+ * working budget as `LOG_TEXT_MAX`, so "one bounded peer detail" means one
3756
+ * number for one detail-sized face (pairing's `FAILURE_SEGMENT_MAX` is the
3757
+ * same NUMBER bounding a different quantity: a whole `<label>: <detail>`).
3758
+ * `UNKNOWN_FIELD_MAX` clips ONE
3759
+ * key: 64 characters is longer than any field name the protocol defines, so a
3760
+ * clipped key is always a malformed or hostile payload, never a real typo the
3761
+ * operator needs whole. A list that stops early names the count (`(+K more)`);
3762
+ * entries are never dropped silently.
3763
+ */
3764
+ const UNKNOWN_FIELDS_MAX = 240;
3765
+ const UNKNOWN_FIELD_MAX = 64;
3507
3766
  /** The claim's deviceName cap; a hostname may reach 253, so it is truncated. */
3508
3767
  const DEVICE_NAME_MAX = 64;
3509
3768
  /** Bounds a single candidate host string (IPv6 literals are ~45). */
3510
3769
  const HOST_MAX$1 = 256;
3511
3770
  /** Matches the protocol's unicodeText: no lone UTF-16 surrogates. */
3512
3771
  const LONE_SURROGATE_RE$1 = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
3513
- /** Fields the implementation consumes; everything else in the payload is ignored. */
3514
- const KNOWN_FIELDS = /* @__PURE__ */ new Set([
3772
+ /**
3773
+ * The keys each QR ARM consumes (selected by `v`). A payload field outside
3774
+ * the MATCHED arm's set is ignored with a warning — the desktop names hosts
3775
+ * the design does not use. One union set would silently swallow a v2 payload
3776
+ * carrying a spurious `endpoints` (or v3 carrying `ips`/`port`); the arm
3777
+ * split keeps the pre-schema warning behavior.
3778
+ */
3779
+ const V2_KEYS = /* @__PURE__ */ new Set([
3515
3780
  "v",
3516
3781
  "t",
3517
- "port",
3782
+ "name",
3518
3783
  "ips",
3784
+ "port",
3785
+ "invitationId",
3786
+ "invitationSecret",
3787
+ "desktopIdentity",
3788
+ "protocolVersions"
3789
+ ]);
3790
+ const V3_KEYS = /* @__PURE__ */ new Set([
3791
+ "v",
3792
+ "t",
3793
+ "name",
3794
+ "endpoints",
3519
3795
  "invitationId",
3520
3796
  "invitationSecret",
3521
3797
  "desktopIdentity",
@@ -3537,12 +3813,12 @@ function invalid(message) {
3537
3813
  function isUnicodeText(value) {
3538
3814
  return !LONE_SURROGATE_RE$1.test(value);
3539
3815
  }
3540
- 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;
3816
+ /** Rejections quote issue PATHS (deduplicated, sorted). The "never inputs"
3817
+ * promise is the CALL SITE's property — only `issue.path` is read here; an
3818
+ * editor reaching for `issue.message` or `String(error)` must re-check what
3819
+ * zod embeds there before keeping that promise. */
3820
+ function schemaIssuePaths(error) {
3821
+ return [...new Set(error.issues.map((issue) => issue.path.join(".") || "payload"))].sort().join(", ");
3546
3822
  }
3547
3823
  /**
3548
3824
  * Normalizes one candidate host (ruling 2). The QR may carry IPv6 literals in
@@ -3566,9 +3842,9 @@ function normalizeHost$1(raw) {
3566
3842
  return inner;
3567
3843
  }
3568
3844
  /**
3569
- * Parses and validates the pairing payload. 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.
3845
+ * Parses and validates the pairing payload against the published
3846
+ * `pairingQrSchema` (v2 or v3). Unknown fields are ignored (with a warning);
3847
+ * every field judgement is the schema's, capped by the local size rule.
3572
3848
  */
3573
3849
  function parsePayload(payload) {
3574
3850
  if (Buffer.byteLength(payload, "utf8") > MAX_PAYLOAD_BYTES) invalid(`payload exceeds the ${MAX_PAYLOAD_BYTES}-byte limit`);
@@ -3580,38 +3856,100 @@ function parsePayload(payload) {
3580
3856
  }
3581
3857
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) invalid("payload must be a JSON object");
3582
3858
  const raw = parsed;
3583
- if (raw["v"] !== PAYLOAD_VERSION) invalid(`payload version must be ${PAYLOAD_VERSION}`);
3584
- if (raw["t"] !== PAYLOAD_TYPE) invalid(`payload type must be "${PAYLOAD_TYPE}"`);
3585
- const ignored = Object.keys(raw).filter((key) => !KNOWN_FIELDS.has(key));
3586
- if (ignored.length > 0) console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${ignored.join(", ")}`);
3587
- const port = raw["port"];
3588
- if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) invalid("payload field \"port\" must be an integer in 1..65535");
3589
3859
  const ips = raw["ips"];
3590
- 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");
3860
+ const input = Array.isArray(ips) ? {
3861
+ ...raw,
3862
+ ips: ips.map((entry) => typeof entry === "string" ? normalizeHost$1(entry) : entry)
3863
+ } : raw;
3864
+ const result = pairingQrSchema.safeParse(input);
3865
+ if (!result.success) invalid(`payload rejected by the pairing QR schema (v2 or v3): ${schemaIssuePaths(result.error)}`);
3866
+ const qr = result.data;
3867
+ const ignored = Object.keys(raw).filter((key) => !(qr.v === 2 ? V2_KEYS : V3_KEYS).has(key));
3868
+ if (ignored.length > 0) console.warn(`[cherry-remote] pairing payload ignored unknown field(s): ${boundedJoin(ignored, {
3869
+ max: UNKNOWN_FIELDS_MAX,
3870
+ itemMax: UNKNOWN_FIELD_MAX
3871
+ })}`);
3872
+ const candidates = qr.v === 2 ? qr.ips.map((host) => ({
3873
+ host,
3874
+ port: qr.port,
3875
+ security: "ws"
3876
+ })) : qr.endpoints.map((endpoint) => ({
3877
+ host: endpoint.host,
3878
+ port: endpoint.port,
3879
+ security: endpoint.security
3880
+ }));
3881
+ const seenHosts = /* @__PURE__ */ new Set();
3882
+ for (const candidate of candidates) {
3883
+ if (!Number.isInteger(candidate.port) || candidate.port < 1 || candidate.port > 65535) invalid(`payload endpoint "${candidate.host}": port must be an integer in 1..65535`);
3884
+ if (seenHosts.has(candidate.host)) invalid(`payload endpoints must not repeat host "${candidate.host}"`);
3885
+ seenHosts.add(candidate.host);
3886
+ }
3887
+ for (const field of [
3888
+ "invitationId",
3889
+ "invitationSecret",
3890
+ "desktopIdentity"
3891
+ ]) if (!isUnicodeText(qr[field])) invalid(`payload field "${field}" is not valid unicode text`);
3892
+ try {
3893
+ peerIdFromString(qr.desktopIdentity);
3894
+ } catch {
3895
+ invalid("payload field \"desktopIdentity\" is not a peer id");
3896
+ }
3597
3897
  return {
3598
- 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
- })
3898
+ candidates,
3899
+ invitationId: qr.invitationId,
3900
+ invitationSecret: qr.invitationSecret,
3901
+ desktopIdentity: qr.desktopIdentity,
3902
+ protocolVersions: qr.protocolVersions
3607
3903
  };
3608
3904
  }
3609
3905
  const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS = [1];
3610
3906
  const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
3907
+ /**
3908
+ * ONE aggregate budget for the candidate DIAL walk (issue #141). A v3 QR admits
3909
+ * up to 32 endpoints, so 32 serial `connectTimeoutMs` dials could spend 320 s
3910
+ * on an invitation that lives 120 s. It bounds the SUM, never one attempt.
3911
+ */
3912
+ const DEFAULT_DIAL_BUDGET_MS = 3e4;
3611
3913
  const DEFAULT_POLL_INTERVAL_MS = 1e3;
3612
3914
  /** The protocol invitationMs: 120s of invitation, and our poll race guard. */
3613
3915
  const DEFAULT_POLL_TIMEOUT_MS = 12e4;
3614
3916
  const VERIFICATION_CODE_RE = /^[0-9]{6}$/;
3917
+ /** Bounds the close frame's reason text quoted into a candidate failure. */
3918
+ const CLOSE_REASON_MAX = 200;
3919
+ /**
3920
+ * Bounds ONE candidate failure segment of the joined `unreachable` verdict
3921
+ * (issue #148). Unbounded, a v3 QR's 32 endpoints × a 253-char host — repeated
3922
+ * again inside the dial error — reached ~17-18 KB in one tool result that
3923
+ * `cherry_pair` passes through whole. The bound is per SEGMENT, never a
3924
+ * "first N + and M more" cut: issue #141's contract is that every candidate,
3925
+ * attempted or not, is NAMED.
3926
+ *
3927
+ * `FAILURE_LABEL_MAX` clips an over-long `host:port` in the HOST and keeps the
3928
+ * PORT (`<host prefix>…:<port>`) — a prefix without its port names no address.
3929
+ * `FAILURE_SEGMENT_MAX` bounds the whole `<label>: <detail>`: the detail is
3930
+ * clipped to the room the label leaves, and the `… (+N chars)` marker's own
3931
+ * length is charged to the bound. 32 candidates then join to at most
3932
+ * 32 × 240 + 31 × 2 = 7,742 characters.
3933
+ */
3934
+ const FAILURE_LABEL_MAX = 128;
3935
+ const FAILURE_SEGMENT_MAX = 240;
3936
+ /**
3937
+ * Bounds the progress line's HOST TEXT (issue #152). `report()` runs once per
3938
+ * stage transition and used to join every candidate host: a v3 QR admits 32
3939
+ * endpoints and a host name may be 253 characters, so one stage wrote ~8 KB.
3940
+ *
3941
+ * The FIRST host is always shown — a line with no host at all would lose the
3942
+ * run's identity — then further hosts are appended while the host NAMES fit
3943
+ * this budget; a truncated list carries an explicit `(+K more)` marker that
3944
+ * rides ON TOP of the budget (the marker is a COUNT, not a host name).
3945
+ *
3946
+ * Invariant: the host names consume ≤ `STAGE_HOSTS_CHARS`, with ONE exception
3947
+ * — a FIRST host that alone exceeds it, still shown because the identity rule
3948
+ * outranks the bound. A LATER over-long host can never enter the bounded
3949
+ * section (`used` already holds the first host, so it fails the fit test) and
3950
+ * is omitted into the marker.
3951
+ */
3952
+ const STAGE_HOSTS_CHARS = 160;
3615
3953
  const noop = () => {};
3616
3954
  /** A callable silent libp2p logger (same shape as the connection layer's). */
3617
3955
  function makeSilentLogger() {
@@ -3626,8 +3964,75 @@ const silentComponentLogger = { forComponent: () => makeSilentLogger() };
3626
3964
  function sleep$1(ms) {
3627
3965
  return new Promise((resolve) => setTimeout(resolve, ms));
3628
3966
  }
3967
+ /**
3968
+ * Tags a PRE-OPEN `ws` error as a candidate-dial failure (issue #142). The
3969
+ * `websocket:` prefix is the KIND: the operator's fix is this address or its
3970
+ * TLS, not the network — the contrast with `timeout:` is the whole point of the
3971
+ * prefix, and it is what the joined `unreachable` verdict shows. The `ws` error
3972
+ * carries the discriminator a bare message drops — an OS `code`
3973
+ * (`ECONNREFUSED`, `ENOTFOUND`, a TLS code) or a numeric ws code — so it is
3974
+ * appended, and only when it is actually a string or a number (never
3975
+ * `undefined`/`null`).
3976
+ */
3977
+ function webSocketFailure(error) {
3978
+ const code = error.code;
3979
+ const suffix = typeof code === "string" || typeof code === "number" ? ` (code ${code})` : "";
3980
+ return /* @__PURE__ */ new Error(`websocket: ${error.message}${suffix}`);
3981
+ }
3982
+ /**
3983
+ * Tags a POST-OPEN channel failure as a candidate-dial failure (issue #147).
3984
+ * `channel:` is the THIRD kind, deliberately not `websocket:`: the upgrade
3985
+ * completed, so the address works and only the channel's establishment failed
3986
+ * (Noise handshake, remote identity proof, protocol offer). Blaming the
3987
+ * address would send the operator to inspect something that demonstrably
3988
+ * answered. No code suffix is appended: a Noise-stage error can carry its own
3989
+ * string code (`ERR_INVALID_CRYPTO_EXCHANGE`), but that is an internal label
3990
+ * rather than the ws/OS discriminator the other kinds keep, so it is
3991
+ * deliberately dropped.
3992
+ */
3993
+ function channelFailure(error) {
3994
+ return /* @__PURE__ */ new Error(`channel: ${error.message}`);
3995
+ }
3996
+ /**
3997
+ * The ONE text form of a candidate failure (issue #148). All three
3998
+ * `failures.push` sites render through it, so the segment bound cannot hold on
3999
+ * one arm (budget-exhausted, dial failure, claim fall-through) and fail on
4000
+ * another. A short label and a short detail are returned BYTE FOR BYTE; the
4001
+ * enforcement is never a rewrite of ordinary text.
4002
+ *
4003
+ * The detail goes through the SHARED {@link clipText} (issue #155), the same
4004
+ * `… (+N chars)` form every other peer-controlled log detail uses; the clip
4005
+ * point is no longer private to pairing.
4006
+ */
4007
+ function failureSegment(label, detail) {
4008
+ const boundedLabel = clipFailureLabel(label);
4009
+ const room = FAILURE_SEGMENT_MAX - boundedLabel.length - 2;
4010
+ if (room <= 0) return boundedLabel.slice(0, FAILURE_SEGMENT_MAX);
4011
+ return `${boundedLabel}: ${clipText(detail, room)}`;
4012
+ }
4013
+ /**
4014
+ * Clips a `host:port` label in the HOST, keeping the port (see
4015
+ * {@link FAILURE_LABEL_MAX}).
4016
+ *
4017
+ * Identification here is PREFIX + PORT, not uniqueness: two schema-distinct
4018
+ * 253-char hosts that agree on their first ~121 characters AND on the port clip
4019
+ * to identical labels (ports may repeat — only hosts are deduped). Each still
4020
+ * contributes its own segment, so #141's per-candidate count is intact.
4021
+ */
4022
+ function clipFailureLabel(label) {
4023
+ if (label.length <= FAILURE_LABEL_MAX) return label;
4024
+ const colon = label.lastIndexOf(":");
4025
+ if (colon < 0) return `${label.slice(0, 127)}…`;
4026
+ const suffix = `…${label.slice(colon)}`;
4027
+ const hostRoom = FAILURE_LABEL_MAX - suffix.length;
4028
+ return hostRoom > 0 ? `${label.slice(0, hostRoom)}${suffix}` : suffix.slice(0, FAILURE_LABEL_MAX);
4029
+ }
3629
4030
  /** Dials one candidate address and completes the Noise handshake.
3630
4031
  *
4032
+ * The URL is the published `directEndpointUrl` — it brackets IPv6 literals,
4033
+ * appends the fixed `/v1/remote/connect` path, and selects `ws:` or `wss:`
4034
+ * from the candidate's own `security` (v3 TLS-proxy endpoints dial TLS).
4035
+ *
3631
4036
  * Both halves start concurrently (`Promise.all`): the peer's accept half is
3632
4037
  * already running on its side, so a sequential dial would deadlock the
3633
4038
  * prelude.
@@ -3638,28 +4043,54 @@ function sleep$1(ms) {
3638
4043
  * rather than assumed: the payload's `desktopIdentity` is the peer the Noise
3639
4044
  * XX handshake must prove, so a swapped-in impostor fails the handshake
3640
4045
  * instead of being pinned as the desktop.
3641
- */
3642
- async function dial(host, port, identity, desktopIdentity, protocolVersions, timeoutMs) {
3643
- const ws = new WebSocket(`ws://${urlHost(host)}:${port}${remoteConnectPath}`);
4046
+ *
4047
+ * A failure carries one of THREE kinds into the joined `unreachable` verdict,
4048
+ * because each names the operator's next check: `timeout:` (the per-attempt
4049
+ * timer won its race — check the network), `websocket:` (the socket failed
4050
+ * BEFORE the upgrade — check this address or its TLS), and `channel:` (the
4051
+ * socket came up and the channel failed AFTER it — check the peer's identity
4052
+ * or the protocol offer). A stalled handshake stays `timeout:`: the dial
4053
+ * promise's own timer settles first, so the abort it triggers cannot win.
4054
+ */
4055
+ async function dial(candidate, identity, desktopIdentity, protocolVersions, timeoutMs, wsOptions) {
4056
+ const { host, port } = candidate;
4057
+ const ws = new WebSocket(directEndpointUrl(candidate), wsOptions);
3644
4058
  const controller = new AbortController();
3645
4059
  let timer;
3646
4060
  const timedOut = new Promise((_, reject) => {
3647
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`connect to ${host}:${port} timed out`)), timeoutMs);
4061
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout: connect to ${host}:${port} timed out`)), timeoutMs);
3648
4062
  });
3649
4063
  try {
3650
4064
  const connecting = (async () => {
3651
4065
  await new Promise((resolve, reject) => {
3652
- 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
4066
+ const settle = (outcome) => {
4067
+ ws.off("open", onOpen);
4068
+ ws.off("error", onError);
4069
+ ws.off("close", onClose);
4070
+ outcome();
4071
+ };
4072
+ const onOpen = () => settle(resolve);
4073
+ const onError = (error) => settle(() => reject(webSocketFailure(error)));
4074
+ const onClose = (code, reason) => {
4075
+ const text = reason.toString("utf8").slice(0, CLOSE_REASON_MAX);
4076
+ settle(() => reject(/* @__PURE__ */ new Error(`websocket: closed before open (code ${code})${text === "" ? "" : ` ${text}`}`)));
4077
+ };
4078
+ ws.once("open", onOpen);
4079
+ ws.once("error", onError);
4080
+ ws.on("close", onClose);
3662
4081
  });
4082
+ try {
4083
+ const stream = new RemoteSocketStream(toRemoteSocket(ws), silentComponentLogger.forComponent("pairing"), "outbound");
4084
+ return await connectSecureChannel(stream, {
4085
+ identity,
4086
+ logger: silentComponentLogger,
4087
+ protocolVersions: [...protocolVersions],
4088
+ signal: controller.signal,
4089
+ remoteIdentity: desktopIdentity
4090
+ });
4091
+ } catch (error) {
4092
+ throw channelFailure(error instanceof Error ? error : new Error(String(error)));
4093
+ }
3663
4094
  })();
3664
4095
  const channel = await Promise.race([connecting, timedOut.finally(() => controller.abort(/* @__PURE__ */ new Error("pairing connect timed out")))]);
3665
4096
  return {
@@ -3692,6 +4123,27 @@ function rpcReason(error) {
3692
4123
  }
3693
4124
  }
3694
4125
  /**
4126
+ * Renders the progress line's host list under {@link STAGE_HOSTS_CHARS} (the
4127
+ * invariant lives on that constant). A truncated list names the first host,
4128
+ * then says how many candidates it is NOT naming — the count is explicit so a
4129
+ * reader can tell a short host list from a bounded one.
4130
+ */
4131
+ function stageHosts(hosts) {
4132
+ const [first, ...rest] = hosts;
4133
+ if (first === void 0) return "";
4134
+ let text = first;
4135
+ let used = first.length;
4136
+ let shown = 1;
4137
+ for (const host of rest) {
4138
+ if (used + host.length + 2 > STAGE_HOSTS_CHARS) break;
4139
+ text += `, ${host}`;
4140
+ used += host.length + 2;
4141
+ shown += 1;
4142
+ }
4143
+ const omitted = hosts.length - shown;
4144
+ return omitted > 0 ? `${text} (+${omitted} more)` : text;
4145
+ }
4146
+ /**
3695
4147
  * Pairs this DSH profile with a Cherry Studio desktop from its pairing payload,
3696
4148
  * returning the PERSISTED record (ruling 6).
3697
4149
  *
@@ -3712,35 +4164,45 @@ async function pairWithPayload(opts) {
3712
4164
  const payload = parsePayload(opts.payload);
3713
4165
  const onStatus = opts.onStatus ?? noop;
3714
4166
  const report = (stage) => {
3715
- console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${payload.hosts.join(", ")})`);
4167
+ console.info(`[cherry-remote] pairing stage ${stage} (hosts: ${stageHosts(payload.candidates.map((candidate) => candidate.host))})`);
3716
4168
  onStatus(stage);
3717
4169
  };
3718
4170
  report("validate");
3719
4171
  const supported = opts.supportedProtocolVersions ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS;
3720
4172
  const offered = payload.protocolVersions.filter((version) => supported.includes(version));
3721
4173
  const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
4174
+ const dialBudgetMs = typeof opts.dialBudgetMs === "number" && Number.isFinite(opts.dialBudgetMs) ? opts.dialBudgetMs : DEFAULT_DIAL_BUDGET_MS;
4175
+ const dialDeadline = performance.now() + dialBudgetMs;
3722
4176
  report("connect");
3723
4177
  const failures = [];
3724
- for (const host of payload.hosts) {
4178
+ let approved;
4179
+ for (const candidate of payload.candidates) {
4180
+ const label = `${candidate.host}:${candidate.port}`;
4181
+ const remaining = dialDeadline - performance.now();
4182
+ if (remaining <= 0) {
4183
+ failures.push(failureSegment(label, `not attempted (the ${dialBudgetMs} ms dial budget was exhausted before this candidate)`));
4184
+ continue;
4185
+ }
3725
4186
  let temp;
3726
4187
  try {
3727
- temp = await dial(host, payload.port, opts.identity, payload.desktopIdentity, offered, connectTimeoutMs);
4188
+ temp = await dial(candidate, opts.identity, payload.desktopIdentity, offered, Math.min(connectTimeoutMs, remaining), opts.wsOptions);
3728
4189
  } catch (error) {
3729
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
4190
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
3730
4191
  continue;
3731
4192
  }
3732
4193
  try {
3733
- const record = await claimAndAwait(temp.client, payload, offered, opts, report);
4194
+ approved = await claimAndAwait(temp.client, payload, offered, opts, report, candidate);
3734
4195
  destroy(temp);
3735
- opts.store.upsert(record);
3736
- return record;
4196
+ break;
3737
4197
  } catch (error) {
3738
4198
  destroy(temp);
3739
4199
  if (error instanceof PairingError) throw error;
3740
- failures.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
4200
+ failures.push(failureSegment(label, error instanceof Error ? error.message : String(error)));
3741
4201
  }
3742
4202
  }
3743
- throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
4203
+ if (approved === void 0) throw new PairingError("unreachable", `no candidate address could complete pairing (${failures.join("; ")})`);
4204
+ opts.store.upsert(approved);
4205
+ return approved;
3744
4206
  }
3745
4207
  /**
3746
4208
  * The RPC half of one candidate: confirm the protocol, claim, present the code,
@@ -3748,7 +4210,7 @@ async function pairWithPayload(opts) {
3748
4210
  * deviceId, grants and token (ruling 3) — the claim receipt carries the code
3749
4211
  * and nothing else.
3750
4212
  */
3751
- async function claimAndAwait(client, payload, protocolVersions, opts, onStatus) {
4213
+ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus, candidate) {
3752
4214
  const deviceName = (opts.name ?? hostname()).slice(0, DEVICE_NAME_MAX);
3753
4215
  try {
3754
4216
  await client.request("connection.hello", { protocolVersions: [...protocolVersions] });
@@ -3782,7 +4244,7 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
3782
4244
  }
3783
4245
  if (result.status === "rejected") throw new PairingError("rejected", "the desktop rejected this pairing request — verify that the 6-digit code shown on the desktop side matches the code this harness displayed, then generate a fresh pairing payload and try again");
3784
4246
  if (result.status === "expired") throw new PairingError("expired", "the pairing invitation has expired — regenerate the pairing code in Cherry Studio (the remote-access QR/payload) and retry");
3785
- if (result.status === "approved") return recordFrom(payload, result, deviceName);
4247
+ if (result.status === "approved") return recordFrom(payload, result, deviceName, candidate);
3786
4248
  if (Date.now() >= deadline) throw new PairingError("expired", "the pairing invitation expired while waiting for approval — regenerate the pairing code in Cherry Studio and retry");
3787
4249
  await sleep$1(interval);
3788
4250
  }
@@ -3793,18 +4255,34 @@ async function claimAndAwait(client, payload, protocolVersions, opts, onStatus)
3793
4255
  * `displayName` is the name the claim actually carried — the record must not
3794
4256
  * disagree with what the desktop shows for this device, and re-deriving it
3795
4257
  * here would lose the 64-char truncation.
3796
- */
3797
- function recordFrom(payload, approved, deviceName) {
4258
+ *
4259
+ * The address projection: every candidate host is kept (the v2 record always
4260
+ * did, and reconnect re-walks them), and `port` is the one the pairing actually
4261
+ * completed on — the WINNER's port, and the record-level fallback. A mixed-port
4262
+ * v3 payload is now persisted FAITHFULLY, per host, through `ports` (issue
4263
+ * #135): the 2.1.8 generation side emits one endpoint per custom-address
4264
+ * invitation (`ApiGatewayService.getRemoteEndpoint`:
4265
+ * `advertisedEndpoint ? [advertisedEndpoint] : …`), but a QR that does carry a
4266
+ * mixed list must still reconnect each host on its own port. `security` IS
4267
+ * persisted (issue #126), one entry per host, taken from the very candidates the
4268
+ * walk dialed — so a schemaVersion-4 record remembers the scheme the pairing
4269
+ * actually used, and the steady-state dial (src/connection.ts) rebuilds the
4270
+ * same URL instead of a plain `ws://`. Before that, #124 paired a TLS endpoint
4271
+ * and made it unreachable on every reconnect.
4272
+ */
4273
+ function recordFrom(payload, approved, deviceName, winner) {
3798
4274
  const grants = (approved.authorization?.grants ?? []).map((grant) => grant.domain).filter((domain) => domain === "agent");
3799
4275
  if (grants.length === 0) throw new PairingError("rejected", "the desktop approved no agent capability for this device");
3800
4276
  const tokenExpiresAt = Date.parse(approved.expiresAt);
3801
4277
  if (!Number.isFinite(tokenExpiresAt)) throw new PairingError("rejected", `the desktop approved the pairing with an unreadable token expiry (${JSON.stringify(approved.expiresAt)}); re-pair the device`);
3802
4278
  return {
3803
- schemaVersion: 2,
4279
+ schemaVersion: 4,
3804
4280
  deviceId: approved.deviceId,
3805
4281
  displayName: deviceName,
3806
- hosts: [...payload.hosts],
3807
- port: payload.port,
4282
+ hosts: payload.candidates.map((candidate) => candidate.host),
4283
+ port: winner.port,
4284
+ security: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.security])),
4285
+ ports: Object.fromEntries(payload.candidates.map((candidate) => [candidate.host, candidate.port])),
3808
4286
  desktopIdentity: payload.desktopIdentity,
3809
4287
  grants,
3810
4288
  accessToken: approved.accessToken,
@@ -3822,7 +4300,7 @@ function asPairingError(error, stage) {
3822
4300
  if (error instanceof PairingError) return error;
3823
4301
  const reason = rpcReason(error);
3824
4302
  if (reason !== void 0) return new PairingError("rejected", `the desktop refused the pairing ${stage} (${reason})`);
3825
- return new PairingError("unreachable", `pairing ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
4303
+ return new PairingError("unreachable", failureSegment(`pairing ${stage} failed`, error instanceof Error ? error.message : String(error)));
3826
4304
  }
3827
4305
  //#endregion
3828
4306
  //#region src/subscriptions.ts
@@ -4345,7 +4823,7 @@ var SessionSubscriptions = class {
4345
4823
  try {
4346
4824
  await this.#rpc.request("agent.subscriptions.close", { subscriptionId });
4347
4825
  } catch (error) {
4348
- console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed`, error);
4826
+ console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed: ${failureText(error)}`);
4349
4827
  }
4350
4828
  }
4351
4829
  /**
@@ -4680,6 +5158,42 @@ const TERMINAL_EXECUTION_STATUSES = [
4680
5158
  ];
4681
5159
  /** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
4682
5160
  const PAGE_ITEMS_FALLBACK = 100;
5161
+ /**
5162
+ * The page this bridge asks for when the peer's hello advertised
5163
+ * `agentMessageSummariesVersion: 1` (issue #105).
5164
+ *
5165
+ * WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
5166
+ * exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
5167
+ * schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
5168
+ * record cannot hold and the transport kills the connection (`defaults.ts`).
5169
+ * What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
5170
+ * revision, role, status and optional usage/model/failure, and NO `partIds`.
5171
+ *
5172
+ * WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
5173
+ * the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
5174
+ * prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
5175
+ * at a halved limit, so its reply records stay under `recordBytes` = 65,536
5176
+ * whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
5177
+ * shape our walk already tolerates) instead of a killed link. The single-item
5178
+ * `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
5179
+ * exception arm, and a schema-valid summary row cannot reach it: measured
5180
+ * against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
5181
+ * 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
5182
+ * ≈37 KB — under the 62,279 budget, so the refusal exists for full
5183
+ * `partIds`-bearing rows, not summaries.
5184
+ *
5185
+ * 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
5186
+ * all: `#aggregate` still intersects this with the peer's NEGOTIATED
5187
+ * `pageItems`, so a peer advertising less keeps its size. Residual exposure —
5188
+ * a hypothetical peer advertising the capability WITHOUT a page budget —
5189
+ * contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
5190
+ * the same "pragmatic, not provable" class `defaults.ts` already scopes.
5191
+ *
5192
+ * The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
5193
+ * in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
5194
+ * fattest item there is.
5195
+ */
5196
+ const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK;
4683
5197
  /** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
4684
5198
  const MAX_AGGREGATE_LIMIT = 1e3;
4685
5199
  /**
@@ -4768,6 +5282,33 @@ const DEFAULT_PARTS_SAFETY_BOUND = 1e4;
4768
5282
  * returning a partial (or empty) result as though it were complete.
4769
5283
  */
4770
5284
  const MAX_AGGREGATE_PAGES = 1e4;
5285
+ /**
5286
+ * The `agent.messages.list` request for ONE message walk, and the page size to
5287
+ * ask for — the ONE place the paged-summaries dialect is decided (issue #105).
5288
+ *
5289
+ * THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
5290
+ * `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
5291
+ * through 2.1.7 ships — is a STRICT object with no such key, so sending it
5292
+ * there is a protocol violation, not a wasted field. Both message-walk faces
5293
+ * (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
5294
+ * the same reason the resume token has one helper: two call sites spelling the
5295
+ * dialect would eventually disagree about which peer may hear it.
5296
+ *
5297
+ * With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
5298
+ * summary rows are metadata, so the byte-safe clamp no longer bounds anything
5299
+ * they can carry. Without it, the clamp stays exactly as it was: the
5300
+ * count-paged peer still returns `partIds`-bearing rows.
5301
+ */
5302
+ function messageListRequest(connection) {
5303
+ if (connection.limits.messageSummaries === 1) return {
5304
+ params: { partsMode: "paged" },
5305
+ maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS
5306
+ };
5307
+ return {
5308
+ params: {},
5309
+ maxPerPage: 16
5310
+ };
5311
+ }
4771
5312
  /** Token prefix for a peer page position; the one `#aggregate` can send back. */
4772
5313
  const RESUME_CURSOR_PREFIX = "cursor:";
4773
5314
  /** Token prefix for "strictly older than this message id". */
@@ -5106,6 +5647,44 @@ function failureReason(error) {
5106
5647
  if (isRecord(data) && typeof data["reason"] === "string") return data["reason"];
5107
5648
  }
5108
5649
  /**
5650
+ * The phrase this hint leaves behind, so applying it twice reads once.
5651
+ */
5652
+ const PEER_REFUSAL_FINAL_MARK = "retrying the same request cannot help";
5653
+ /**
5654
+ * The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
5655
+ * carrying `details.retryable === false`, which is the desktop's per-item byte
5656
+ * budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
5657
+ * `itemBytes` / `budgetBytes`), issue #105.
5658
+ *
5659
+ * WHY THE WARNING HAS TO BE ADDED: the official client marks every
5660
+ * `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
5661
+ * exponential backoff, so a byte-budget refusal never converges — and the
5662
+ * condition really is permanent: the same item over the same budget will be
5663
+ * over it again. A caller (or a model reading the error) that retries learns
5664
+ * nothing but spends the peer's rate window.
5665
+ *
5666
+ * The sentence is deliberately narrower than the reason: only the
5667
+ * `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
5668
+ * the upload window, the subscription cap, a legacy refusal with no `details` —
5669
+ * keeps its own text, because some of those ARE worth a later retry.
5670
+ *
5671
+ * Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
5672
+ * structured `data` and the {@link JsonRpcRequestError} identity stay the
5673
+ * peer's, so branching callers see exactly what it sent.
5674
+ */
5675
+ function withPeerRefusalHint(error) {
5676
+ if (!(error instanceof JsonRpcRequestError)) return error;
5677
+ if (failureReason(error) !== "RESOURCE_EXHAUSTED") return error;
5678
+ const data = error.data;
5679
+ if (!isRecord(data) || !isRecord(data["details"]) || data["details"]["retryable"] !== false) return error;
5680
+ if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error;
5681
+ const kind = optionalString(data["details"]["kind"]);
5682
+ const cause = kind === "ITEM_TOO_LARGE" ? "an ITEM_TOO_LARGE page item over its byte budget" : "the same item";
5683
+ const tail = kind === "ITEM_TOO_LARGE" ? "the item ITSELF exceeds the peer’s per-item budget, so no page size or request shape can fetch it (nor the items behind it — upstream #21951)" : "ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget";
5684
+ error.message = `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); retrying the same request cannot help — ${tail}`;
5685
+ return error;
5686
+ }
5687
+ /**
5109
5688
  * One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
5110
5689
  * digest the ref advertises (or there was no digest to check against).
5111
5690
  *
@@ -5434,7 +6013,19 @@ function registerCherryToolsWithEngine(ctx, deps, options = {}) {
5434
6013
  }
5435
6014
  };
5436
6015
  }
5437
- /** One tool declaration, in the exact shape the host registry consumes. */
6016
+ /**
6017
+ * One tool declaration, in the exact shape the host registry consumes.
6018
+ *
6019
+ * This helper adds no result WRAPPER and this ticket adds no result-side
6020
+ * truncation (issue #161): a tool result is the MODEL's signal, and the peer is
6021
+ * the user's own Noise-authenticated desktop, so shortening the peer's
6022
+ * explanation costs the caller the text it acts on. The two result-text clamps
6023
+ * that DO exist are per-field product choices, not a log rule — `aggregateError`
6024
+ * (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
6025
+ * 200-character diagnostics digest used by `projectMessageFailure`. The LOG
6026
+ * face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
6027
+ * this ticket's sweep does not change tool output.
6028
+ */
5438
6029
  function defineCherryTool(definition) {
5439
6030
  return {
5440
6031
  name: definition.name,
@@ -5902,7 +6493,7 @@ var CherryToolEngine = class {
5902
6493
  result = await connection.rpc.request(method, request, deadline === void 0 ? void 0 : { deadline: deadline.instant });
5903
6494
  } catch (error) {
5904
6495
  if (deadline !== void 0 && deadlineExhaustion(error)) return this.#truncated(items, "deadline", cursor);
5905
- throw error;
6496
+ throw withPeerRefusalHint(error);
5906
6497
  }
5907
6498
  const extracted = extract(result);
5908
6499
  if (extracted.notAnArray !== void 0) throw new Error(`${method}: peer answered a page whose \`items\` is not an array (\`items\` is ${extracted.notAnArray}) for ${JSON.stringify(params)}; refusing a silently short result — every page of a paged list must carry an items array`);
@@ -6313,16 +6904,18 @@ var CherryToolEngine = class {
6313
6904
  const session = isRecord(sessionResult["session"]) ? sessionResult["session"] : {};
6314
6905
  const historyRevision = session["historyRevision"];
6315
6906
  if (typeof historyRevision !== "string") throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`);
6907
+ const messageList = messageListRequest(connection);
6316
6908
  const page = await this.#aggregate(connection, "agent.messages.list", {
6317
6909
  sessionId,
6318
- historyRevision
6910
+ historyRevision,
6911
+ ...messageList.params
6319
6912
  }, (result) => ({
6320
6913
  ...arrayOf(result["items"]),
6321
6914
  nextCursor: result["nextCursor"]
6322
6915
  }), {
6323
6916
  limit,
6324
6917
  safetyBound: MAX_AGGREGATE_LIMIT,
6325
- maxPerPage: 16,
6918
+ maxPerPage: messageList.maxPerPage,
6326
6919
  resume: resume === void 0 ? void 0 : resume.kind === "cursor" ? {
6327
6920
  kind: "cursor",
6328
6921
  cursor: resume.cursor
@@ -6496,7 +7089,7 @@ var CherryToolEngine = class {
6496
7089
  * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
6497
7090
  * `content.text`, observed on the wire (2.1.7 desktop, image upload,
6498
7091
  * 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
7092
+ * peer at all: the pinned protocol 0.7.0 types `content` as a strict
6500
7093
  * `{text}` | `{ref}` union, so the old documentation described a shape no
6501
7094
  * peer can emit — and the real one was never lifted, which is how an image
6502
7095
  * upload reached a model as a JSON blob.
@@ -6769,7 +7362,7 @@ var CherryToolEngine = class {
6769
7362
  try {
6770
7363
  await subscriptions.ack(sessionId, result.nextCursor);
6771
7364
  } catch (error) {
6772
- console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed`, error);
7365
+ console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` + failureText(error));
6773
7366
  this.#reEnsureInBackground(record.deviceId, sessionId);
6774
7367
  }
6775
7368
  return {
@@ -8570,13 +9163,23 @@ var CherryToolEngine = class {
8570
9163
  * identity object: the verdict is already known and true, and only the
8571
9164
  * content behind it failed.
8572
9165
  *
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.
9166
+ * `aggregateError` is a TOOL RESULT, not a log line — see
9167
+ * {@link defineCherryTool} for the product decision (issue #161): a result is
9168
+ * the MODEL's signal, so it is not bounded by the logging rule, and the
9169
+ * 200-character clip here is this field's own choice.
9170
+ *
9171
+ * The LOG line IS the logging face, and it renders the failure as ONE bounded
9172
+ * string through {@link failureText} (issue #161). An assembly failure is
9173
+ * usually a peer RPC refusal — its `error.message` is the peer's own text,
9174
+ * capped at 512 characters by the failure schema — and the old line
9175
+ * interpolated it whole: one 512-char refusal made a 654-character line for
9176
+ * the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
9177
+ * RESULT keeps the raw message's first 200 characters; a bound on the log
9178
+ * does not change what the model receives.
8576
9179
  */
8577
9180
  #assemblyFailed(identity, error) {
8578
9181
  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`);
9182
+ console.warn(`[cherry-remote] the ${String(identity["status"])} answer for session ${String(identity["sessionId"])} could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`);
8580
9183
  return {
8581
9184
  ...identity,
8582
9185
  aggregateError: message.slice(0, 200),
@@ -8655,15 +9258,18 @@ var CherryToolEngine = class {
8655
9258
  async #readTerminalMessages(connection, sessionId, session, deadline) {
8656
9259
  const historyRevision = optionalString(session["historyRevision"]);
8657
9260
  if (historyRevision === void 0) return { messages: [] };
9261
+ const messageList = messageListRequest(connection);
8658
9262
  const page = await this.#aggregate(connection, "agent.messages.list", {
8659
9263
  sessionId,
8660
- historyRevision
9264
+ historyRevision,
9265
+ ...messageList.params
8661
9266
  }, (result) => ({
8662
9267
  ...arrayOf(result["items"]),
8663
9268
  nextCursor: result["nextCursor"]
8664
9269
  }), {
8665
9270
  limit: TERMINAL_AGGREGATE_MESSAGES,
8666
- deadline
9271
+ deadline,
9272
+ maxPerPage: messageList.maxPerPage
8667
9273
  });
8668
9274
  const messages = [];
8669
9275
  let projectedBytes = 0;
@@ -8721,11 +9327,11 @@ var CherryToolEngine = class {
8721
9327
  try {
8722
9328
  layer = this.#deps.subs(deviceId);
8723
9329
  } catch (error) {
8724
- console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}`, error);
9330
+ console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`);
8725
9331
  return;
8726
9332
  }
8727
9333
  layer.ensure(sessionId).catch((error) => {
8728
- console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed`, error);
9334
+ console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
8729
9335
  });
8730
9336
  }
8731
9337
  /**
@@ -10257,9 +10863,20 @@ function normalizeHost(raw) {
10257
10863
  return raw.slice(1, -1);
10258
10864
  }
10259
10865
  /**
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.
10866
+ * Validates a PATCH body. `hosts` is WHOLE-REPLACE by contract.
10867
+ *
10868
+ * The map semantics this face owes (the record's per-host maps are DERIVED,
10869
+ * never patched directly — there is no `ports` field in this body):
10870
+ *
10871
+ * - neither `hosts` nor `port` patched → the maps are unchanged;
10872
+ * - `hosts` replaced → a host that stays keeps ITS OWN scheme and port, a new
10873
+ * host gets `ws` and the record-level port;
10874
+ * - a CHANGED `port` (with or without `hosts`) → EVERY final host is
10875
+ * re-pointed to it. The face has one port field, so leaving per-host
10876
+ * entries would make the edit inert for every listed host that already had
10877
+ * one. Sending the CURRENT value is a no-op for the maps — the settings page
10878
+ * sends both fields on every save, and a mixed-port record must not be
10879
+ * flattened by a save that did not change the port.
10263
10880
  */
10264
10881
  function parsePatch(body) {
10265
10882
  const patch = {};
@@ -10283,6 +10900,7 @@ function parsePatch(body) {
10283
10900
  if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
10284
10901
  return normalizeHost(entry);
10285
10902
  });
10903
+ if (new Set(patch.hosts).size !== patch.hosts.length) throw new PatchError("hosts must not contain duplicates");
10286
10904
  }
10287
10905
  const port = body["port"];
10288
10906
  if (port !== void 0) {
@@ -10681,7 +11299,9 @@ function listDevices(res, route) {
10681
11299
  state: projectState(device, route.deps.connect),
10682
11300
  enabled: device.enabled,
10683
11301
  hosts: [...device.hosts],
10684
- port: device.port
11302
+ port: device.port,
11303
+ security: { ...device.security },
11304
+ ports: { ...device.ports }
10685
11305
  })));
10686
11306
  }
10687
11307
  /**
@@ -10851,11 +11471,13 @@ function deviceById(deps, deviceId) {
10851
11471
  const found = deps.store.list().find((device) => device.deviceId === deviceId);
10852
11472
  if (found !== void 0) return found;
10853
11473
  return {
10854
- schemaVersion: 2,
11474
+ schemaVersion: 4,
10855
11475
  deviceId,
10856
11476
  displayName: "",
10857
11477
  hosts: [],
10858
11478
  port: 0,
11479
+ security: {},
11480
+ ports: {},
10859
11481
  desktopIdentity: "",
10860
11482
  grants: [],
10861
11483
  enabled: false
@@ -10905,12 +11527,19 @@ async function patchDevice(req, res, route, deviceId) {
10905
11527
  const previousEnabled = existing.enabled;
10906
11528
  const previousHosts = existing.hosts;
10907
11529
  const previousPort = existing.port;
11530
+ const previousSecurity = existing.security;
11531
+ const previousPorts = existing.ports;
11532
+ const nextPorts = patch.port !== void 0 && patch.port !== previousPort ? portsFor(patch.hosts ?? previousHosts, {}, patch.port) : patch.hosts === void 0 ? previousPorts : portsFor(patch.hosts, previousPorts, previousPort);
10908
11533
  const next = {
10909
11534
  ...existing,
10910
11535
  ...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
10911
11536
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
10912
- ...patch.hosts === void 0 ? {} : { hosts: patch.hosts },
10913
- ...patch.port === void 0 ? {} : { port: patch.port }
11537
+ ...patch.hosts === void 0 ? {} : {
11538
+ hosts: patch.hosts,
11539
+ security: securityFor(patch.hosts, previousSecurity)
11540
+ },
11541
+ ...patch.port === void 0 ? {} : { port: patch.port },
11542
+ ports: nextPorts
10914
11543
  };
10915
11544
  try {
10916
11545
  route.deps.store.upsert(next);
@@ -10919,7 +11548,7 @@ async function patchDevice(req, res, route, deviceId) {
10919
11548
  writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
10920
11549
  return;
10921
11550
  }
10922
- const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort;
11551
+ const addressChanged = patch.hosts !== void 0 && !sameHosts(patch.hosts, previousHosts) || patch.port !== void 0 && patch.port !== previousPort || !sameSecurity(next.security, previousSecurity);
10923
11552
  const enabledChanged = patch.enabled !== void 0 && patch.enabled !== previousEnabled;
10924
11553
  if (addressChanged) evict(route, deviceId);
10925
11554
  if (enabledChanged) hibernate(route, deviceId);
@@ -10939,18 +11568,49 @@ async function patchDevice(req, res, route, deviceId) {
10939
11568
  state,
10940
11569
  enabled: next.enabled,
10941
11570
  hosts: [...next.hosts],
10942
- port: next.port
11571
+ port: next.port,
11572
+ security: { ...next.security },
11573
+ ports: { ...next.ports }
10943
11574
  });
10944
11575
  }
10945
11576
  function warnConnect(deviceId) {
10946
11577
  return (error) => {
10947
- console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed`, error);
11578
+ console.warn(`[cherry-remote] device ${deviceId} lifecycle change failed: ${failureText(error)}`);
10948
11579
  };
10949
11580
  }
10950
11581
  /** Order-sensitive host comparison: `hosts` is an ordered candidate list. */
10951
11582
  function sameHosts(a, b) {
10952
11583
  return a.length === b.length && a.every((host, index) => host === b[index]);
10953
11584
  }
11585
+ /**
11586
+ * The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
11587
+ * keeps the scheme it was dialed with, a host the list just gained defaults to
11588
+ * `ws`, and hosts the list dropped leave no entry behind. An edit is an
11589
+ * address edit, not a scheme edit — re-typing the same host must not silently
11590
+ * demote a TLS-proxied desktop to a plain dial.
11591
+ */
11592
+ function securityFor(hosts, previous) {
11593
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : "ws"]));
11594
+ }
11595
+ /**
11596
+ * The port map for a host list (issue #135): each named host keeps the port it
11597
+ * was dialed with, a host the list just gained defaults to `fallback` (the
11598
+ * record-level port), and hosts the list dropped leave no entry behind.
11599
+ *
11600
+ * `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
11601
+ * `port` patch): there is no remembered entry to keep, by design — one port
11602
+ * field on this face must move the whole list. Same `Object.hasOwn` rule as
11603
+ * `securityFor`: an inherited function is not a remembered port.
11604
+ */
11605
+ function portsFor(hosts, previous, fallback) {
11606
+ return Object.fromEntries(hosts.map((host) => [host, Object.hasOwn(previous, host) ? previous[host] : fallback]));
11607
+ }
11608
+ /** Order-insensitive comparison for two scheme maps. */
11609
+ function sameSecurity(a, b) {
11610
+ const keys = Object.keys(a);
11611
+ if (keys.length !== Object.keys(b).length) return false;
11612
+ return keys.every((key) => a[key] === b[key]);
11613
+ }
10954
11614
  /** Drops the pooled connection, closing any live socket first. */
10955
11615
  function evict(route, deviceId) {
10956
11616
  try {
@@ -11065,11 +11725,12 @@ function openEvents(req, res, route) {
11065
11725
  //#region src/index.ts
11066
11726
  const name = "cherry-remote";
11067
11727
  const inject = ["tools"];
11068
- /** Applies the documented fallbacks to the plugin config's numeric keys. */
11728
+ /** Applies the documented fallbacks and validates the plugin config's keys. */
11069
11729
  function resolveBridgeConfig(config = {}) {
11070
11730
  return {
11071
11731
  rateBudgetPerSecond: positive("rateBudgetPerSecond", config.rateBudgetPerSecond, 32),
11072
- perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
11732
+ perSessionCacheBytes: positive("perSessionCacheBytes", config.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES),
11733
+ wsTls: dialOptions(config.wsTls)
11073
11734
  };
11074
11735
  }
11075
11736
  /**
@@ -11094,6 +11755,76 @@ function positive(key, value, fallback) {
11094
11755
  }
11095
11756
  return value;
11096
11757
  }
11758
+ /** The keys {@link WsDialOptions} carries; anything else is a config mistake. */
11759
+ const WS_TLS_KEYS = /* @__PURE__ */ new Set(["rejectUnauthorized", "ca"]);
11760
+ /** The shape a non-object config value arrived as, for a value-free warning. */
11761
+ function shapeOf(value) {
11762
+ if (value === null) return "null";
11763
+ if (Array.isArray(value)) return "array";
11764
+ return typeof value;
11765
+ }
11766
+ /**
11767
+ * One `ca` entry: PEM CONTENT for a string, non-empty bytes for a Buffer.
11768
+ *
11769
+ * A string is the certificate TEXT itself, NEVER a file path: `tls.connect`
11770
+ * would happily read whatever path it is handed, so accepting `'/etc/ssl/ca.pem'`
11771
+ * would load bytes the operator never inspected. The header check is a SHAPE
11772
+ * check only — whether the PEM parses beyond `-----BEGIN` is Node's business,
11773
+ * and an unparseable body fails the handshake loudly, which is the honest
11774
+ * verdict. Buffers keep the non-empty test with no header requirement: DER and
11775
+ * programmatically built PEM bytes are legitimate `ca` values.
11776
+ */
11777
+ function certificateEntry(value) {
11778
+ if (typeof value === "string") return value.trimStart().startsWith("-----BEGIN");
11779
+ if (Buffer.isBuffer(value)) return value.length > 0;
11780
+ return false;
11781
+ }
11782
+ /**
11783
+ * A configured TLS trust bag, or `undefined` when absent/unusable.
11784
+ *
11785
+ * ABSENT is the documented default (Node's trust store) and says nothing —
11786
+ * exactly like {@link positive}. A key that is PRESENT but unusable is a
11787
+ * deployment mistake that silently changes which peers are reachable, so it is
11788
+ * warned about by name rather than swallowed — but NEVER by value: a `ca` is
11789
+ * PEM CONTENT, and a certificate must not reach a log.
11790
+ *
11791
+ * FAIL CLOSED, WHOLE OBJECT. One unknown or malformed entry discards the WHOLE
11792
+ * bag rather than forwarding the parts that happen to look right. Trust must be
11793
+ * applied AS WRITTEN: a partially applied bag would reach some peers and not
11794
+ * others with nothing telling the operator which half took effect, and their
11795
+ * `ca` intention would be silently replaced by Node's default store. The
11796
+ * fallback is "no override" — the same state as absent.
11797
+ */
11798
+ function dialOptions(value) {
11799
+ if (value === void 0) return void 0;
11800
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
11801
+ console.warn(`[cherry-remote] config "wsTls" must be a plain object of flat tls.connect options (got ${shapeOf(value)}); ignoring it`);
11802
+ return;
11803
+ }
11804
+ const record = value;
11805
+ const unknownKeys = Object.keys(record).filter((key) => !WS_TLS_KEYS.has(key));
11806
+ if (unknownKeys.length > 0) {
11807
+ console.warn(`[cherry-remote] config "wsTls" carries unsupported key(s) ${unknownKeys.join(", ")}; only flat rejectUnauthorized / ca are accepted — ignoring the whole object`);
11808
+ return;
11809
+ }
11810
+ const rejectUnauthorized = record["rejectUnauthorized"];
11811
+ if (rejectUnauthorized !== void 0 && typeof rejectUnauthorized !== "boolean") {
11812
+ console.warn("[cherry-remote] config \"wsTls.rejectUnauthorized\" must be a boolean; ignoring the whole object");
11813
+ return;
11814
+ }
11815
+ const ca = record["ca"];
11816
+ if (ca !== void 0) {
11817
+ if (!(Array.isArray(ca) ? ca.length > 0 && ca.every((entry) => certificateEntry(entry)) : certificateEntry(ca))) {
11818
+ console.warn("[cherry-remote] config \"wsTls.ca\" must be PEM CONTENT (the certificate text itself, not a file path) — a \"-----BEGIN\" string, a non-empty Buffer, or a non-empty array of those; ignoring the whole object");
11819
+ return;
11820
+ }
11821
+ }
11822
+ if (rejectUnauthorized === void 0 && ca === void 0) return void 0;
11823
+ return {
11824
+ ...rejectUnauthorized === void 0 ? {} : { rejectUnauthorized },
11825
+ ...ca === void 0 ? {} : { ca }
11826
+ };
11827
+ }
11097
11828
  /** The data directory convention every DSH plugin follows. */
11098
11829
  function resolveDataDir(config = {}, env = process.env) {
11099
11830
  if (config.dataDir !== void 0 && config.dataDir !== "") return config.dataDir;
@@ -11135,6 +11866,7 @@ const state = {
11135
11866
  identityError: void 0,
11136
11867
  rateBudgetPerSecond: 32,
11137
11868
  perSessionCacheBytes: DEFAULT_PER_SESSION_CACHE_BYTES,
11869
+ wsTls: void 0,
11138
11870
  connections: /* @__PURE__ */ new Map(),
11139
11871
  subscriptions: /* @__PURE__ */ new Map(),
11140
11872
  deviceStateListeners: /* @__PURE__ */ new Set(),
@@ -11200,7 +11932,8 @@ function connectionFor(deviceId) {
11200
11932
  const connection = new DeviceConnection({
11201
11933
  identity: requireIdentity(),
11202
11934
  record,
11203
- rateBudgetPerSecond: state.rateBudgetPerSecond
11935
+ rateBudgetPerSecond: state.rateBudgetPerSecond,
11936
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
11204
11937
  });
11205
11938
  state.connectionsBuilt += 1;
11206
11939
  connection.onState((deviceState) => {
@@ -11221,7 +11954,7 @@ function connectionFor(deviceId) {
11221
11954
  try {
11222
11955
  subsFor(deviceId).handleNotification(method, params);
11223
11956
  } catch (error) {
11224
- console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed`, error);
11957
+ console.warn(`[cherry-remote] notification "${method}" for ${deviceId} failed: ${failureText(error)}`);
11225
11958
  }
11226
11959
  });
11227
11960
  state.connections.set(deviceId, connection);
@@ -11312,6 +12045,30 @@ function subsFor(deviceId) {
11312
12045
  return created;
11313
12046
  }
11314
12047
  /**
12048
+ * Bounds the link-recovery line's session-id list (issue #155).
12049
+ *
12050
+ * The number of ENTRIES is bounded only by the peer-advertised hello
12051
+ * `subscriptions` — a number the peer chooses, not one this assembly can size —
12052
+ * and a session id is the protocol's `opaqueId` (≤ 256 characters). The old
12053
+ * `sessions.join(', ')` therefore emitted `entries × 258` characters into one
12054
+ * info line: the protocol's own `remoteLimits.subscriptions` = 8 still allows
12055
+ * ~2 KB, and a peer advertising more grows the line with no ceiling at all. The
12056
+ * text is peer-controlled (the ids are echoed back from what the peer accepted
12057
+ * as subscriptions), so it needs a LOCAL bound like every other such log.
12058
+ *
12059
+ * `RECOVERY_SESSIONS_MAX` = 320 is the joined ids' budget: eight normal
12060
+ * 36-char UUIDs render as 8 × 36 + 7 × 2 = 302 characters, so the protocol's
12061
+ * own subscription cap fits WHOLE — the bound bites only on a peer that
12062
+ * advertises more room, or on a malformed over-long id.
12063
+ * `RECOVERY_SESSION_ID_MAX` = 256 is that `opaqueId` cap, so a schema-valid id
12064
+ * is never clipped; only a malformed one is. A list that stops early names the
12065
+ * count (`(+K more)`) and rides ON TOP of the budget; entries are never dropped
12066
+ * silently. The bound is on the LOG only — the loop still re-ensures every
12067
+ * session.
12068
+ */
12069
+ const RECOVERY_SESSIONS_MAX = 320;
12070
+ const RECOVERY_SESSION_ID_MAX = 256;
12071
+ /**
11315
12072
  * The reconnect recovery line (ruling 8): every session this device had
11316
12073
  * subscribed is re-ensured at its LAST cursor, so the peer replays only what
11317
12074
  * this harness has not yet consumed.
@@ -11325,14 +12082,17 @@ async function recoverSubscriptions(deviceId) {
11325
12082
  const subscriptions = state.subscriptions.get(deviceId);
11326
12083
  if (subscriptions === void 0) return;
11327
12084
  const sessions = subscriptions.activeSessions();
11328
- if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${sessions.join(", ")}`);
12085
+ if (sessions.length > 0) console.info(`[cherry-remote] recovering ${sessions.length} subscription(s) for ${deviceId}: ${boundedJoin(sessions, {
12086
+ max: RECOVERY_SESSIONS_MAX,
12087
+ itemMax: RECOVERY_SESSION_ID_MAX
12088
+ })}`);
11329
12089
  for (const sessionId of sessions) try {
11330
12090
  const result = subscriptions.read(sessionId);
11331
12091
  const cursor = "events" in result ? result.nextCursor : void 0;
11332
12092
  await subscriptions.ensure(sessionId, cursor);
11333
12093
  console.info(`[cherry-remote] subscription recovered for ${deviceId} session ${sessionId}` + (cursor === void 0 ? "" : ` at seq ${cursor.seq}`));
11334
12094
  } catch (error) {
11335
- console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed`, error);
12095
+ console.warn(`[cherry-remote] re-subscribing session ${sessionId} of ${deviceId} failed: ${failureText(error)}`);
11336
12096
  }
11337
12097
  }
11338
12098
  /** One dep object, shared by both faces, so there is exactly ONE pool. */
@@ -11360,6 +12120,7 @@ function assemblyDeps() {
11360
12120
  payload: request.payload,
11361
12121
  store: state.store,
11362
12122
  ...request.name === void 0 ? {} : { name: request.name },
12123
+ ...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
11363
12124
  onVerificationCode: request.onVerificationCode,
11364
12125
  onStatus: (stage) => publishPairingProgress({ stage })
11365
12126
  });
@@ -11428,6 +12189,7 @@ function apply(ctx, config = {}) {
11428
12189
  const bridgeConfig = resolveBridgeConfig(config);
11429
12190
  state.rateBudgetPerSecond = bridgeConfig.rateBudgetPerSecond;
11430
12191
  state.perSessionCacheBytes = bridgeConfig.perSessionCacheBytes;
12192
+ state.wsTls = bridgeConfig.wsTls;
11431
12193
  state.store = new DeviceStore(join(dataDir, "devices.json"));
11432
12194
  state.recentErrors.length = 0;
11433
12195
  state.connectionsBuilt = 0;