dsh-cherry-bridge 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
package/dist/index.mjs
CHANGED
|
@@ -5,8 +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 { directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
8
|
+
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema, remoteLimits } from "@cherrystudio/remote-protocol";
|
|
9
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
|
+
}
|
|
10
62
|
const FILE_MODE = 384;
|
|
11
63
|
/**
|
|
12
64
|
* Version-to-version migrations, keyed by the version they upgrade from.
|
|
@@ -118,6 +170,7 @@ function assertValidRecord(disk) {
|
|
|
118
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");
|
|
119
171
|
assertValidSecurity(disk.hosts, disk.security);
|
|
120
172
|
assertValidPorts(disk.hosts, disk.ports);
|
|
173
|
+
assertValidForgotten(disk.hosts, disk.forgottenHosts);
|
|
121
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");
|
|
122
175
|
return disk;
|
|
123
176
|
}
|
|
@@ -166,6 +219,60 @@ function assertValidPorts(hosts, ports) {
|
|
|
166
219
|
}
|
|
167
220
|
}
|
|
168
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
|
+
/**
|
|
169
276
|
* Read path, fixed order: loose parse -> completeness gate -> version judgement
|
|
170
277
|
* -> migration -> assert the required `DeviceRecord` type.
|
|
171
278
|
*
|
|
@@ -193,7 +300,7 @@ function parseRecord(raw, archiveVersion) {
|
|
|
193
300
|
if (migrate === void 0) throw new Error(`no migration registered from device archive schemaVersion ${step}`);
|
|
194
301
|
migrated = migrate(migrated);
|
|
195
302
|
}
|
|
196
|
-
return assertValidRecord(migrated);
|
|
303
|
+
return assertValidRecord(reconcileForgottenMarks(migrated));
|
|
197
304
|
}
|
|
198
305
|
function parseArchive(parsed) {
|
|
199
306
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("device archive must be a JSON object");
|
|
@@ -311,6 +418,26 @@ var QuarantinedArchiveError = class QuarantinedArchiveError extends Error {
|
|
|
311
418
|
}
|
|
312
419
|
};
|
|
313
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
|
+
/**
|
|
314
441
|
* Preserves an unreadable archive as `<file>.corrupt` (best effort — a failed
|
|
315
442
|
* copy must not mask the original problem) and returns the path it landed at.
|
|
316
443
|
*/
|
|
@@ -477,6 +604,137 @@ var DeviceStore = class {
|
|
|
477
604
|
const devices = this.#load().filter((record) => record.deviceId !== deviceId);
|
|
478
605
|
this.#persist(devices);
|
|
479
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
|
+
}
|
|
480
738
|
#load() {
|
|
481
739
|
if (this.#devices !== void 0) return this.#devices;
|
|
482
740
|
let archive;
|
|
@@ -2156,10 +2414,11 @@ function projectMessageSummaries(raw) {
|
|
|
2156
2414
|
return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
|
|
2157
2415
|
}
|
|
2158
2416
|
/**
|
|
2159
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
2160
|
-
* caller's `limits` object might carry
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2417
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
2418
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
2419
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
2420
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
2421
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
2163
2422
|
*/
|
|
2164
2423
|
function projectLimits(raw) {
|
|
2165
2424
|
if (raw === null || typeof raw !== "object") return {};
|
|
@@ -2647,6 +2906,33 @@ const DEFAULT_BACKOFF_MAX_MS = 6e4;
|
|
|
2647
2906
|
const DEFAULT_REFRESH_GRACE_MS = 3e4;
|
|
2648
2907
|
const DEFAULT_CONNECT_TIMEOUT_MS$1 = 1e4;
|
|
2649
2908
|
const DEFAULT_PROTOCOL_VERSIONS = [1];
|
|
2909
|
+
/**
|
|
2910
|
+
* How long the optional `connection.endpoints` enrichment (issue #143) may hold
|
|
2911
|
+
* up ONE handshake before the connection gives up on it.
|
|
2912
|
+
*
|
|
2913
|
+
* The call runs INSIDE the handshake, so without a bound of its own it would
|
|
2914
|
+
* inherit the rpc layer's 60 s default: a peer that advertises
|
|
2915
|
+
* `connectionEndpointsVersion: 1` and then never answers (a wedged API gateway)
|
|
2916
|
+
* would keep the device in `connecting` for a minute, and every reconnect would
|
|
2917
|
+
* pay it again. THIS layer bounds it because the enrichment is worth
|
|
2918
|
+
* milliseconds, not a retry.
|
|
2919
|
+
*
|
|
2920
|
+
* WHY 2 s. The peer's handler is a local computation
|
|
2921
|
+
* (`getRemoteEndpoint()`: `networkInterfaces()` plus two config reads), so a
|
|
2922
|
+
* round trip is sub-millisecond on a LAN and 2 s is ~4 orders of magnitude of
|
|
2923
|
+
* slack.
|
|
2924
|
+
*
|
|
2925
|
+
* The UPPER bound is NOT derived from a claim about the whole handshake: a dial
|
|
2926
|
+
* already burns up to `DEFAULT_CONNECT_TIMEOUT_MS` (10 s) and hello/authenticate
|
|
2927
|
+
* ride the rpc layer's 60 s default, so `connected` can arrive long after a
|
|
2928
|
+
* tool's `DEVICE_SETTLE_WAIT_MS` (5 s) wait whatever this constant is. What is
|
|
2929
|
+
* derived is narrower and true: THIS wait alone must stay below that window, so
|
|
2930
|
+
* a peer that advertises the capability and then hangs on this one call cannot
|
|
2931
|
+
* by itself push the state flip past the window a tool already waited out. A
|
|
2932
|
+
* timeout costs one reconnect's worth of enrichment and says nothing about the
|
|
2933
|
+
* device.
|
|
2934
|
+
*/
|
|
2935
|
+
const ENDPOINTS_LEARN_TIMEOUT_MS = 2e3;
|
|
2650
2936
|
/** Ping-silence multiplier: 2×heartbeatMs without a pong judges the line dead. */
|
|
2651
2937
|
const SILENCE_FACTOR = 2;
|
|
2652
2938
|
/** The deliberate local-shutdown face of a disposed client (see the replay path). */
|
|
@@ -2735,7 +3021,8 @@ var SecondWindowRateLimiter = class {
|
|
|
2735
3021
|
async acquire() {
|
|
2736
3022
|
let release;
|
|
2737
3023
|
const ahead = this.#queue;
|
|
2738
|
-
|
|
3024
|
+
const next = () => new Promise((resolve) => release = resolve);
|
|
3025
|
+
this.#queue = ahead.then(next, next);
|
|
2739
3026
|
await ahead;
|
|
2740
3027
|
try {
|
|
2741
3028
|
for (;;) {
|
|
@@ -2920,6 +3207,50 @@ function businessWindowFor(reportedInFlightRequests) {
|
|
|
2920
3207
|
return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
|
|
2921
3208
|
}
|
|
2922
3209
|
/**
|
|
3210
|
+
* The destinations a `connection.endpoints` answer may contribute, in the order
|
|
3211
|
+
* the peer listed them (issue #143). Empty when the answer must be discarded.
|
|
3212
|
+
*
|
|
3213
|
+
* TWO answers are refused WHOLE:
|
|
3214
|
+
*
|
|
3215
|
+
* - **the identity pin.** The answer must name the very desktop this record is
|
|
3216
|
+
* pinned to (`DeviceRecord.desktopIdentity`). Noise already pins the peer's
|
|
3217
|
+
* static key, so this is not the adversarial case it protects against — it
|
|
3218
|
+
* is the defence against a CONFUSED answer (a proxy, a desktop whose identity
|
|
3219
|
+
* was regenerated, a future protocol that answers for more than one host):
|
|
3220
|
+
* the list decides where this client dials, so a list that cannot be
|
|
3221
|
+
* attributed to our own desktop may not change it;
|
|
3222
|
+
* - a MISSING or NON-STRING `desktopIdentity`: `undefined !== pin`, and a
|
|
3223
|
+
* number is not the pin either, so both fall out of the same comparison.
|
|
3224
|
+
*
|
|
3225
|
+
* One refusal is per ENTRY: `advertisedEndpointSchema`, the peer's own
|
|
3226
|
+
* destination schema, rejects a LOOPBACK destination (a shared address that
|
|
3227
|
+
* resolves to our own machine would send the next reconnect to the wrong host)
|
|
3228
|
+
* and normalizes the host (case, IPv6), so the archive never stores a spelling
|
|
3229
|
+
* the dial cannot use. One unusable row must not discard its neighbours.
|
|
3230
|
+
*
|
|
3231
|
+
* NOT total on the wire shape, deliberately: a non-object answer or a non-array
|
|
3232
|
+
* `endpoints` throws here, and `#learnEndpoints`' one structural `catch` is what
|
|
3233
|
+
* turns that into the silent no-op the contract promises. Defending each shape
|
|
3234
|
+
* twice would look safer and be worse: the redundancy masks which guard a test
|
|
3235
|
+
* actually pins (issue #114's lesson), and the whole ANSWER is untrusted
|
|
3236
|
+
* anyway, so the boundary belongs at the caller, where the handshake can see
|
|
3237
|
+
* it. `endpoints ?? []` is the one shape that needs no throw: it is the legal
|
|
3238
|
+
* "no addresses right now" answer (the desktop's `addresses` can be empty).
|
|
3239
|
+
*
|
|
3240
|
+
* No length bound is needed here: one JSON-RPC record is capped at 64 KiB by
|
|
3241
|
+
* the transport, and the desktop's own snapshot is `addresses.slice(0, 32)`.
|
|
3242
|
+
*/
|
|
3243
|
+
function advertisedEndpoints(result, desktopIdentity) {
|
|
3244
|
+
const answer = result;
|
|
3245
|
+
if (answer.desktopIdentity !== desktopIdentity) return [];
|
|
3246
|
+
const accepted = [];
|
|
3247
|
+
for (const raw of answer.endpoints ?? []) {
|
|
3248
|
+
const parsed = advertisedEndpointSchema.safeParse(raw);
|
|
3249
|
+
if (parsed.success) accepted.push(parsed.data);
|
|
3250
|
+
}
|
|
3251
|
+
return accepted;
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
2923
3254
|
* The desktop's content-read allowance: 512/s, and the SAME in both generations
|
|
2924
3255
|
* (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
|
|
2925
3256
|
* 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
|
|
@@ -3000,6 +3331,7 @@ var DeviceConnection = class {
|
|
|
3000
3331
|
#protocolVersions;
|
|
3001
3332
|
#helloProtocolVersions;
|
|
3002
3333
|
#transportRetryWaitMs;
|
|
3334
|
+
#onAdvertisedEndpoints;
|
|
3003
3335
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
3004
3336
|
/**
|
|
3005
3337
|
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
@@ -3062,6 +3394,7 @@ var DeviceConnection = class {
|
|
|
3062
3394
|
this.#protocolVersions = opts.protocolVersions ?? DEFAULT_PROTOCOL_VERSIONS;
|
|
3063
3395
|
this.#helloProtocolVersions = opts.helloProtocolVersions ?? this.#protocolVersions;
|
|
3064
3396
|
this.#transportRetryWaitMs = opts.transportRetryWaitMs ?? 5e3;
|
|
3397
|
+
this.#onAdvertisedEndpoints = opts.onAdvertisedEndpoints;
|
|
3065
3398
|
}
|
|
3066
3399
|
/**
|
|
3067
3400
|
* The business RPC surface: rate-smoothed and gated on `connected`. Typed
|
|
@@ -3372,11 +3705,13 @@ var DeviceConnection = class {
|
|
|
3372
3705
|
if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
|
|
3373
3706
|
throw error instanceof Error ? error : new Error(String(error));
|
|
3374
3707
|
}
|
|
3708
|
+
const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
|
|
3375
3709
|
this.#limits = {
|
|
3376
3710
|
heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
3377
3711
|
subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
|
|
3378
3712
|
pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
|
|
3379
3713
|
inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
3714
|
+
...unackedBytes === void 0 ? {} : { unackedBytes },
|
|
3380
3715
|
...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
|
|
3381
3716
|
};
|
|
3382
3717
|
link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
|
|
@@ -3388,6 +3723,7 @@ var DeviceConnection = class {
|
|
|
3388
3723
|
if (reason === "GRANT_REVOKED" || reason === "UNAUTHENTICATED") throw new AuthRejectedError(reason);
|
|
3389
3724
|
throw error instanceof Error ? error : new Error(String(error));
|
|
3390
3725
|
}
|
|
3726
|
+
await this.#learnEndpoints(link, hello.connectionEndpointsVersion);
|
|
3391
3727
|
if (epoch !== this.#epoch) {
|
|
3392
3728
|
client.dispose();
|
|
3393
3729
|
link.channel.close().catch(noop$1);
|
|
@@ -3411,6 +3747,51 @@ var DeviceConnection = class {
|
|
|
3411
3747
|
console.info(`[cherry-remote] device ${this.record.deviceId} connected (${digestIdentity(link.channel.remoteIdentity)})`);
|
|
3412
3748
|
}
|
|
3413
3749
|
/**
|
|
3750
|
+
* Best-effort enrichment of the dial list from the peer's advertised
|
|
3751
|
+
* endpoints (issue #143).
|
|
3752
|
+
*
|
|
3753
|
+
* WHAT THIS BUYS: candidates the pairing never saw — a VPN or second-NIC
|
|
3754
|
+
* address that appeared after the QR was minted. It does NOT heal a total
|
|
3755
|
+
* address change (the list is captured on the old path) and can never carry
|
|
3756
|
+
* TLS (the desktop's snapshot is `security: 'ws'`), so a `wss` route still
|
|
3757
|
+
* comes from the QR or the operator.
|
|
3758
|
+
*
|
|
3759
|
+
* EVERY failure here is SILENT and non-fatal, because this is an enrichment,
|
|
3760
|
+
* not a verdict: a peer that does not advertise the capability (`connection`
|
|
3761
|
+
* through 2.1.3 has neither the hello bit nor this method), one advertising a
|
|
3762
|
+
* version this client cannot speak (strictly `1` — a higher number is not
|
|
3763
|
+
* evidence that it speaks 1, the same rule the paged-summaries bit took), a
|
|
3764
|
+
* refused call (the desktop throws when its gateway is not running), a wedged
|
|
3765
|
+
* one (bounded by {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an unattributable
|
|
3766
|
+
* answer, or a store that cannot write. None of them is a CONNECTION failure:
|
|
3767
|
+
* the link comes up either way, and only the archive is left without the new
|
|
3768
|
+
* addresses — this instance's own record may already carry them, because the
|
|
3769
|
+
* merge runs before the callback (see the paragraph below).
|
|
3770
|
+
*
|
|
3771
|
+
* The MERGE happens in two places on purpose: here (this instance's record,
|
|
3772
|
+
* so its next reconnect dials the new address) and through the callback (the
|
|
3773
|
+
* assembly's archive, so a restart keeps it). They are the same pure rule
|
|
3774
|
+
* against two copies of the record, and each is idempotent.
|
|
3775
|
+
*
|
|
3776
|
+
* ONE `catch` covers everything after the gate, and that is the contract, not
|
|
3777
|
+
* laziness: a refused call, a wedged one (bounded by
|
|
3778
|
+
* {@link ENDPOINTS_LEARN_TIMEOUT_MS}), an answer of the wrong SHAPE (the
|
|
3779
|
+
* parser throws rather than defending every field twice), a throwing assembly
|
|
3780
|
+
* hook (an unwritable archive), or a bug in the merge must all land here —
|
|
3781
|
+
* none of them may fail the handshake. The observable promise is therefore
|
|
3782
|
+
* structural: this method cannot reject.
|
|
3783
|
+
*/
|
|
3784
|
+
async #learnEndpoints(link, capability) {
|
|
3785
|
+
if (capability !== 1) return;
|
|
3786
|
+
try {
|
|
3787
|
+
const endpoints = advertisedEndpoints(await this.#houseCall(link, "connection.endpoints", { domain: "agent" }, ENDPOINTS_LEARN_TIMEOUT_MS), this.record.desktopIdentity);
|
|
3788
|
+
if (endpoints.length === 0) return;
|
|
3789
|
+
const added = mergeAdvertisedEndpoints(this.record, endpoints);
|
|
3790
|
+
if (added > 0) console.info(`[cherry-remote] device ${this.record.deviceId} learned ${added} advertised endpoint(s)`);
|
|
3791
|
+
this.#onAdvertisedEndpoints?.(endpoints);
|
|
3792
|
+
} catch {}
|
|
3793
|
+
}
|
|
3794
|
+
/**
|
|
3414
3795
|
* authenticate with the stored token; a stale token (TOKEN_EXPIRED /
|
|
3415
3796
|
* UNAUTHENTICATED) is retried exactly once without it before the pairing
|
|
3416
3797
|
* is declared invalid.
|
|
@@ -4338,6 +4719,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
|
|
|
4338
4719
|
* not a tuning knob.
|
|
4339
4720
|
*/
|
|
4340
4721
|
const MAX_RECOVERY_ATTEMPTS = 4;
|
|
4722
|
+
/**
|
|
4723
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
4724
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
4725
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
4726
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
4727
|
+
*
|
|
4728
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
4729
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
4730
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
4731
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
4732
|
+
*
|
|
4733
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
4734
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
4735
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
4736
|
+
* into a round trip.
|
|
4737
|
+
*/
|
|
4738
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
4739
|
+
const AUTO_ACK_RETRY_MS = 3e3;
|
|
4740
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
4741
|
+
const AUTO_ACK_RETRY_MAX_MS = 3e4;
|
|
4341
4742
|
function positive$1(value, fallback) {
|
|
4342
4743
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
4343
4744
|
}
|
|
@@ -4375,6 +4776,8 @@ var SessionSubscriptions = class {
|
|
|
4375
4776
|
#limit;
|
|
4376
4777
|
#perSessionCacheBytes;
|
|
4377
4778
|
#checkpointMs;
|
|
4779
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
4780
|
+
#unackedSource;
|
|
4378
4781
|
#sessions = /* @__PURE__ */ new Map();
|
|
4379
4782
|
/**
|
|
4380
4783
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -4396,6 +4799,7 @@ var SessionSubscriptions = class {
|
|
|
4396
4799
|
this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
|
|
4397
4800
|
this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
|
|
4398
4801
|
this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
|
|
4802
|
+
this.#unackedSource = opts.unackedBytes;
|
|
4399
4803
|
}
|
|
4400
4804
|
/**
|
|
4401
4805
|
* Session ids with a live subscription, LRU-first. Task 10's reconnect
|
|
@@ -4495,6 +4899,7 @@ var SessionSubscriptions = class {
|
|
|
4495
4899
|
if (existing !== void 0) {
|
|
4496
4900
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
|
|
4497
4901
|
await this.#closeSubscription(existing.subscriptionId);
|
|
4902
|
+
this.#cancelAckRetry(existing);
|
|
4498
4903
|
this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
4499
4904
|
this.#sessions.delete(sessionId);
|
|
4500
4905
|
}
|
|
@@ -4570,13 +4975,18 @@ var SessionSubscriptions = class {
|
|
|
4570
4975
|
/**
|
|
4571
4976
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
4572
4977
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
4573
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
4574
|
-
*
|
|
4978
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
4979
|
+
*
|
|
4980
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
4981
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
4982
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
4983
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
4575
4984
|
*/
|
|
4576
4985
|
async ack(sessionId, cursor) {
|
|
4577
4986
|
const state = this.#sessions.get(sessionId);
|
|
4578
4987
|
if (state === void 0) return;
|
|
4579
|
-
const
|
|
4988
|
+
const requested = cursor === void 0 ? state.cursor : cursor;
|
|
4989
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
|
|
4580
4990
|
await this.#rpc.request("agent.subscriptions.ack", {
|
|
4581
4991
|
subscriptionId: state.subscriptionId,
|
|
4582
4992
|
cursor: applied
|
|
@@ -4628,6 +5038,7 @@ var SessionSubscriptions = class {
|
|
|
4628
5038
|
this.#closed = true;
|
|
4629
5039
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
|
|
4630
5040
|
for (const state of this.#sessions.values()) {
|
|
5041
|
+
this.#cancelAckRetry(state);
|
|
4631
5042
|
const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
|
|
4632
5043
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error);
|
|
4633
5044
|
}
|
|
@@ -4710,13 +5121,21 @@ var SessionSubscriptions = class {
|
|
|
4710
5121
|
*/
|
|
4711
5122
|
#installReplay(sessionId, descriptor) {
|
|
4712
5123
|
const previous = this.#sessions.get(sessionId);
|
|
4713
|
-
if (previous !== void 0)
|
|
5124
|
+
if (previous !== void 0) {
|
|
5125
|
+
this.#cancelAckRetry(previous);
|
|
5126
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
5127
|
+
}
|
|
4714
5128
|
this.#sessions.delete(sessionId);
|
|
4715
5129
|
this.#sessions.set(sessionId, {
|
|
4716
5130
|
subscriptionId: descriptor.subscriptionId,
|
|
4717
5131
|
cursor: descriptor.highWatermark,
|
|
4718
5132
|
buffer: [],
|
|
4719
5133
|
bytes: 0,
|
|
5134
|
+
bytesSinceAck: 0,
|
|
5135
|
+
ackInFlight: false,
|
|
5136
|
+
ackRetryAt: 0,
|
|
5137
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5138
|
+
ackRetryTimer: void 0,
|
|
4720
5139
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
4721
5140
|
evicted: false,
|
|
4722
5141
|
waiters: [],
|
|
@@ -4796,13 +5215,21 @@ var SessionSubscriptions = class {
|
|
|
4796
5215
|
*/
|
|
4797
5216
|
#installCheckpoint(sessionId, descriptor, items) {
|
|
4798
5217
|
const previous = this.#sessions.get(sessionId);
|
|
4799
|
-
if (previous !== void 0)
|
|
5218
|
+
if (previous !== void 0) {
|
|
5219
|
+
this.#cancelAckRetry(previous);
|
|
5220
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
|
|
5221
|
+
}
|
|
4800
5222
|
this.#sessions.delete(sessionId);
|
|
4801
5223
|
this.#sessions.set(sessionId, {
|
|
4802
5224
|
subscriptionId: descriptor.subscriptionId,
|
|
4803
5225
|
cursor: descriptor.checkpoint.cursor,
|
|
4804
5226
|
buffer: [],
|
|
4805
5227
|
bytes: 0,
|
|
5228
|
+
bytesSinceAck: 0,
|
|
5229
|
+
ackInFlight: false,
|
|
5230
|
+
ackRetryAt: 0,
|
|
5231
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5232
|
+
ackRetryTimer: void 0,
|
|
4806
5233
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
4807
5234
|
evicted: false,
|
|
4808
5235
|
waiters: [],
|
|
@@ -4848,6 +5275,7 @@ var SessionSubscriptions = class {
|
|
|
4848
5275
|
for (const [sessionId, state] of this.#sessions) {
|
|
4849
5276
|
if (sessionId === protectedSessionId) continue;
|
|
4850
5277
|
console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
|
|
5278
|
+
this.#cancelAckRetry(state);
|
|
4851
5279
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
|
|
4852
5280
|
this.#sessions.delete(sessionId);
|
|
4853
5281
|
await this.#closeSubscription(state.subscriptionId);
|
|
@@ -4929,11 +5357,86 @@ var SessionSubscriptions = class {
|
|
|
4929
5357
|
bytes
|
|
4930
5358
|
});
|
|
4931
5359
|
state.bytes += bytes;
|
|
5360
|
+
state.bytesSinceAck += bytes;
|
|
4932
5361
|
state.cursor = cursor;
|
|
4933
5362
|
appended += 1;
|
|
4934
5363
|
}
|
|
4935
5364
|
this.#trimToBudget(sessionId, state);
|
|
4936
5365
|
if (appended > 0) this.#wakeWaiters(state);
|
|
5366
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state);
|
|
5367
|
+
}
|
|
5368
|
+
/**
|
|
5369
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
5370
|
+
* window, so the peer keeps delivering (issue #212).
|
|
5371
|
+
*
|
|
5372
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
5373
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
5374
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
5375
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
5376
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
5377
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
5378
|
+
* below the acked position is still served — by the local buffer here and by
|
|
5379
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
5380
|
+
*
|
|
5381
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
5382
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
5383
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
5384
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
5385
|
+
* #212.
|
|
5386
|
+
*/
|
|
5387
|
+
#maybeAutoAck(sessionId, state) {
|
|
5388
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return;
|
|
5389
|
+
if (state.ackInFlight) return;
|
|
5390
|
+
if (Date.now() < state.ackRetryAt) return;
|
|
5391
|
+
state.ackInFlight = true;
|
|
5392
|
+
const covered = state.bytesSinceAck;
|
|
5393
|
+
this.ack(sessionId).then(() => {
|
|
5394
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
|
|
5395
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS;
|
|
5396
|
+
}).catch((error) => {
|
|
5397
|
+
const delay = state.ackRetryDelay;
|
|
5398
|
+
state.ackRetryAt = Date.now() + delay;
|
|
5399
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
|
|
5400
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
|
|
5401
|
+
this.#armAckRetry(sessionId, state, delay);
|
|
5402
|
+
}).finally(() => {
|
|
5403
|
+
state.ackInFlight = false;
|
|
5404
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5405
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5406
|
+
});
|
|
5407
|
+
}
|
|
5408
|
+
/**
|
|
5409
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
5410
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
5411
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
5412
|
+
*/
|
|
5413
|
+
#ackWatermark() {
|
|
5414
|
+
const source = this.#unackedSource;
|
|
5415
|
+
const advertised = typeof source === "function" ? source() : source;
|
|
5416
|
+
return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
|
|
5417
|
+
}
|
|
5418
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
5419
|
+
#cancelAckRetry(state) {
|
|
5420
|
+
if (state.ackRetryTimer === void 0) return;
|
|
5421
|
+
clearTimeout(state.ackRetryTimer);
|
|
5422
|
+
state.ackRetryTimer = void 0;
|
|
5423
|
+
}
|
|
5424
|
+
/**
|
|
5425
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
5426
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
5427
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
5428
|
+
*/
|
|
5429
|
+
#armAckRetry(sessionId, state, delayMs) {
|
|
5430
|
+
if (this.#closed) return;
|
|
5431
|
+
if (state.ackRetryTimer !== void 0) return;
|
|
5432
|
+
const timer = setTimeout(() => {
|
|
5433
|
+
state.ackRetryTimer = void 0;
|
|
5434
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5435
|
+
state.ackRetryAt = 0;
|
|
5436
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5437
|
+
}, delayMs);
|
|
5438
|
+
timer.unref?.();
|
|
5439
|
+
state.ackRetryTimer = timer;
|
|
4937
5440
|
}
|
|
4938
5441
|
/**
|
|
4939
5442
|
* `agent.subscriptions.resetRequired`: the peer tore the subscription down
|
|
@@ -4952,6 +5455,7 @@ var SessionSubscriptions = class {
|
|
|
4952
5455
|
for (const [sessionId, state] of this.#sessions) {
|
|
4953
5456
|
if (state.subscriptionId !== subscriptionId) continue;
|
|
4954
5457
|
console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
|
|
5458
|
+
this.#cancelAckRetry(state);
|
|
4955
5459
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
|
|
4956
5460
|
this.#sessions.delete(sessionId);
|
|
4957
5461
|
return;
|
|
@@ -5892,6 +6396,54 @@ function optionalString(value) {
|
|
|
5892
6396
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
5893
6397
|
}
|
|
5894
6398
|
/**
|
|
6399
|
+
* The `hosts` argument of `cherry_devices`' write actions (issue #185): a
|
|
6400
|
+
* non-empty list of non-empty host names, with no case-variant duplicate.
|
|
6401
|
+
*
|
|
6402
|
+
* Each rule is a state the store would have to interpret: an empty name can
|
|
6403
|
+
* never match a dialed host, and two spellings of one host would make "is this
|
|
6404
|
+
* address forgotten?" depend on which entry a comparison hit first. Values are
|
|
6405
|
+
* TRIMMED here (the model-facing surface is where a stray space is caught);
|
|
6406
|
+
* the archive then stores the spelling it already had for a dialed host.
|
|
6407
|
+
*/
|
|
6408
|
+
function hostListArgument(value) {
|
|
6409
|
+
if (!Array.isArray(value) || value.length === 0) return {
|
|
6410
|
+
error: "invalid-hosts",
|
|
6411
|
+
message: "`hosts` must be a non-empty array of host names"
|
|
6412
|
+
};
|
|
6413
|
+
const hosts = [];
|
|
6414
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6415
|
+
for (const entry of value) {
|
|
6416
|
+
if (typeof entry !== "string" || entry.trim() === "") return {
|
|
6417
|
+
error: "invalid-hosts",
|
|
6418
|
+
message: "`hosts` entries must be non-empty strings"
|
|
6419
|
+
};
|
|
6420
|
+
let host = entry.trim();
|
|
6421
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
6422
|
+
try {
|
|
6423
|
+
new URL(`http://${host}/`);
|
|
6424
|
+
} catch {
|
|
6425
|
+
return {
|
|
6426
|
+
error: "invalid-hosts",
|
|
6427
|
+
message: `\`hosts\` entry "${host}" is not a valid IPv6 literal`
|
|
6428
|
+
};
|
|
6429
|
+
}
|
|
6430
|
+
host = host.slice(1, -1);
|
|
6431
|
+
}
|
|
6432
|
+
if (host.length > 256) return {
|
|
6433
|
+
error: "invalid-hosts",
|
|
6434
|
+
message: "`hosts` entry exceeds 256 characters"
|
|
6435
|
+
};
|
|
6436
|
+
const key = host.toLowerCase();
|
|
6437
|
+
if (seen.has(key)) return {
|
|
6438
|
+
error: "invalid-hosts",
|
|
6439
|
+
message: `\`hosts\` names "${host}" more than once (host names are case-insensitive)`
|
|
6440
|
+
};
|
|
6441
|
+
seen.add(key);
|
|
6442
|
+
hosts.push(host);
|
|
6443
|
+
}
|
|
6444
|
+
return hosts;
|
|
6445
|
+
}
|
|
6446
|
+
/**
|
|
5895
6447
|
* The peer's `agent.messages.send` refusal for a STALE `expectedIdleRevision`,
|
|
5896
6448
|
* in the exact shape Cherry Studio answers it: a `rejected` receipt whose
|
|
5897
6449
|
* `error` is `{reason: 'CONFLICT', message: 'Session is not idle at the
|
|
@@ -6572,26 +7124,86 @@ var CherryToolEngine = class {
|
|
|
6572
7124
|
#devicesTool() {
|
|
6573
7125
|
return defineCherryTool({
|
|
6574
7126
|
name: "cherry_devices",
|
|
6575
|
-
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).",
|
|
7127
|
+
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`.",
|
|
6576
7128
|
parameters: {
|
|
6577
7129
|
type: "object",
|
|
6578
|
-
properties: {
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
7130
|
+
properties: {
|
|
7131
|
+
device: {
|
|
7132
|
+
type: "string",
|
|
7133
|
+
description: "Optional device selector: exact deviceId, or a unique prefix of a deviceId or display name."
|
|
7134
|
+
},
|
|
7135
|
+
action: {
|
|
7136
|
+
type: "string",
|
|
7137
|
+
enum: [
|
|
7138
|
+
"list",
|
|
7139
|
+
"forget",
|
|
7140
|
+
"remember"
|
|
7141
|
+
],
|
|
7142
|
+
description: "What to do: `list` (the default) reads the devices; `forget`/`remember` edit ONE device's forgotten addresses and require `device` and `hosts`."
|
|
7143
|
+
},
|
|
7144
|
+
hosts: {
|
|
7145
|
+
type: "array",
|
|
7146
|
+
items: { type: "string" },
|
|
7147
|
+
description: "For `forget`/`remember`: the host names to act on, matched case-insensitively. `forget` refuses to remove the last remaining address."
|
|
7148
|
+
}
|
|
7149
|
+
},
|
|
6582
7150
|
additionalProperties: false
|
|
6583
7151
|
},
|
|
6584
7152
|
execute: async (args) => {
|
|
7153
|
+
const action = optionalString(args["action"]) ?? "list";
|
|
7154
|
+
if (action !== "list" && action !== "forget" && action !== "remember") return {
|
|
7155
|
+
error: "invalid-action",
|
|
7156
|
+
message: "`action` accepts exactly \"list\", \"forget\" or \"remember\""
|
|
7157
|
+
};
|
|
6585
7158
|
const selector = optionalString(args["device"]);
|
|
7159
|
+
if (action === "list") {
|
|
7160
|
+
const quarantined = this.#quarantineVerdict();
|
|
7161
|
+
if (quarantined !== void 0) return quarantined;
|
|
7162
|
+
if (selector === void 0) {
|
|
7163
|
+
const records = this.#deps.store.list();
|
|
7164
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
7165
|
+
}
|
|
7166
|
+
const selected = this.#selectRecords(selector);
|
|
7167
|
+
if (!Array.isArray(selected)) return selected;
|
|
7168
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
|
|
7169
|
+
}
|
|
7170
|
+
if (selector === void 0) return {
|
|
7171
|
+
error: "device-required",
|
|
7172
|
+
message: "`action` \"forget\"/\"remember\" needs `device`: a selector that names exactly one device"
|
|
7173
|
+
};
|
|
7174
|
+
const hosts = hostListArgument(args["hosts"]);
|
|
7175
|
+
if (!Array.isArray(hosts)) return hosts;
|
|
6586
7176
|
const quarantined = this.#quarantineVerdict();
|
|
6587
7177
|
if (quarantined !== void 0) return quarantined;
|
|
6588
|
-
if (selector === void 0) {
|
|
6589
|
-
const records = this.#deps.store.list();
|
|
6590
|
-
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
6591
|
-
}
|
|
6592
7178
|
const selected = this.#selectRecords(selector);
|
|
6593
7179
|
if (!Array.isArray(selected)) return selected;
|
|
6594
|
-
|
|
7180
|
+
const record = selected[0];
|
|
7181
|
+
let removed = [];
|
|
7182
|
+
let marked = [];
|
|
7183
|
+
let restored = [];
|
|
7184
|
+
try {
|
|
7185
|
+
if (action === "forget") ({removed, marked} = this.#deps.store.forgetEndpoints(record.deviceId, hosts));
|
|
7186
|
+
else ({restored} = this.#deps.store.rememberEndpoints(record.deviceId, hosts));
|
|
7187
|
+
} catch (error) {
|
|
7188
|
+
if (error instanceof LastCandidateHostError) return {
|
|
7189
|
+
error: "last-address",
|
|
7190
|
+
device: record.deviceId,
|
|
7191
|
+
message: error.message
|
|
7192
|
+
};
|
|
7193
|
+
throw error;
|
|
7194
|
+
}
|
|
7195
|
+
if (removed.length > 0 || marked.length > 0 || restored.length > 0) await this.#deps.evict(record.deviceId);
|
|
7196
|
+
const fresh = this.#deps.store.list().find((entry) => entry.deviceId === record.deviceId);
|
|
7197
|
+
if (fresh === void 0) return this.#notFoundVerdict(selector);
|
|
7198
|
+
const device = await this.#deviceView(fresh);
|
|
7199
|
+
return action === "forget" ? {
|
|
7200
|
+
device,
|
|
7201
|
+
removed,
|
|
7202
|
+
marked
|
|
7203
|
+
} : {
|
|
7204
|
+
device,
|
|
7205
|
+
restored
|
|
7206
|
+
};
|
|
6595
7207
|
}
|
|
6596
7208
|
});
|
|
6597
7209
|
}
|
|
@@ -6645,7 +7257,8 @@ var CherryToolEngine = class {
|
|
|
6645
7257
|
state,
|
|
6646
7258
|
enabled: record.enabled,
|
|
6647
7259
|
hosts: record.hosts,
|
|
6648
|
-
port: record.port
|
|
7260
|
+
port: record.port,
|
|
7261
|
+
forgottenHosts: record.forgottenHosts ?? []
|
|
6649
7262
|
};
|
|
6650
7263
|
}
|
|
6651
7264
|
#agentsTool() {
|
|
@@ -8673,8 +9286,9 @@ var CherryToolEngine = class {
|
|
|
8673
9286
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
8674
9287
|
* layers separately).
|
|
8675
9288
|
*
|
|
8676
|
-
* Waiters
|
|
8677
|
-
*
|
|
9289
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
9290
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
9291
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
8678
9292
|
*
|
|
8679
9293
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
8680
9294
|
* the device was unpaired, or the connection closed. That is not a timeout and
|
|
@@ -10900,7 +11514,12 @@ function parsePatch(body) {
|
|
|
10900
11514
|
if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
|
|
10901
11515
|
return normalizeHost(entry);
|
|
10902
11516
|
});
|
|
10903
|
-
|
|
11517
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11518
|
+
for (const host of patch.hosts) {
|
|
11519
|
+
const key = host.toLowerCase();
|
|
11520
|
+
if (seen.has(key)) throw new PatchError("hosts must not contain duplicates");
|
|
11521
|
+
seen.add(key);
|
|
11522
|
+
}
|
|
10904
11523
|
}
|
|
10905
11524
|
const port = body["port"];
|
|
10906
11525
|
if (port !== void 0) {
|
|
@@ -11256,6 +11875,23 @@ async function handleRequest(req, res, route) {
|
|
|
11256
11875
|
writeError(res, 405, "method-not-allowed", "DELETE or PATCH only");
|
|
11257
11876
|
return;
|
|
11258
11877
|
}
|
|
11878
|
+
if (first === "devices" && match.segments.length === 3 && second !== void 0) {
|
|
11879
|
+
const deviceId = decodeSegment(second);
|
|
11880
|
+
if (deviceId === void 0) {
|
|
11881
|
+
writeError(res, 400, "invalid-request", "device id is not valid URL encoding");
|
|
11882
|
+
return;
|
|
11883
|
+
}
|
|
11884
|
+
const action = match.segments[2];
|
|
11885
|
+
if (action !== "forget" && action !== "remember") {
|
|
11886
|
+
writeError(res, 404, "not-found", "unknown route");
|
|
11887
|
+
return;
|
|
11888
|
+
}
|
|
11889
|
+
if (method !== "POST") {
|
|
11890
|
+
writeError(res, 405, "method-not-allowed", "POST only");
|
|
11891
|
+
return;
|
|
11892
|
+
}
|
|
11893
|
+
return await changeEndpoints(req, res, route, deviceId, action);
|
|
11894
|
+
}
|
|
11259
11895
|
if (first === "pair" && match.segments.length === 1) {
|
|
11260
11896
|
if (method !== "POST") {
|
|
11261
11897
|
writeError(res, 405, "method-not-allowed", "POST only");
|
|
@@ -11293,7 +11929,18 @@ async function handleRequest(req, res, route) {
|
|
|
11293
11929
|
}
|
|
11294
11930
|
}
|
|
11295
11931
|
function listDevices(res, route) {
|
|
11296
|
-
writeJson(res, 200, route.deps.store.list().map((device) => (
|
|
11932
|
+
writeJson(res, 200, route.deps.store.list().map((device) => deviceSummary(route, device)));
|
|
11933
|
+
}
|
|
11934
|
+
/**
|
|
11935
|
+
* One device as the browser sees it: the public archive projection, never a
|
|
11936
|
+
* credential.
|
|
11937
|
+
*
|
|
11938
|
+
* ONE builder, because FOUR faces answer this shape — `GET /devices`, the
|
|
11939
|
+
* `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
|
|
11940
|
+
* per face is how a field ends up on one and not the others.
|
|
11941
|
+
*/
|
|
11942
|
+
function deviceSummary(route, device) {
|
|
11943
|
+
return {
|
|
11297
11944
|
deviceId: device.deviceId,
|
|
11298
11945
|
displayName: device.displayName,
|
|
11299
11946
|
state: projectState(device, route.deps.connect),
|
|
@@ -11301,8 +11948,9 @@ function listDevices(res, route) {
|
|
|
11301
11948
|
hosts: [...device.hosts],
|
|
11302
11949
|
port: device.port,
|
|
11303
11950
|
security: { ...device.security },
|
|
11304
|
-
ports: { ...device.ports }
|
|
11305
|
-
|
|
11951
|
+
ports: { ...device.ports },
|
|
11952
|
+
forgottenHosts: [...device.forgottenHosts ?? []]
|
|
11953
|
+
};
|
|
11306
11954
|
}
|
|
11307
11955
|
/**
|
|
11308
11956
|
* The `GET /diagnostics` body (spec §5).
|
|
@@ -11530,8 +12178,10 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
11530
12178
|
const previousSecurity = existing.security;
|
|
11531
12179
|
const previousPorts = existing.ports;
|
|
11532
12180
|
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);
|
|
12181
|
+
const nextForgotten = patch.hosts === void 0 || existing.forgottenHosts === void 0 ? void 0 : existing.forgottenHosts.filter((host) => !patch.hosts.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()));
|
|
11533
12182
|
const next = {
|
|
11534
12183
|
...existing,
|
|
12184
|
+
...nextForgotten === void 0 ? {} : { forgottenHosts: nextForgotten },
|
|
11535
12185
|
...patch.displayName === void 0 ? {} : { displayName: patch.displayName },
|
|
11536
12186
|
...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
|
|
11537
12187
|
...patch.hosts === void 0 ? {} : {
|
|
@@ -11557,21 +12207,12 @@ async function patchDevice(req, res, route, deviceId) {
|
|
|
11557
12207
|
} catch (error) {
|
|
11558
12208
|
console.warn(`[cherry-remote] device ${deviceId} reconnect failed`, error);
|
|
11559
12209
|
}
|
|
11560
|
-
const
|
|
12210
|
+
const summary = deviceSummary(route, next);
|
|
11561
12211
|
if (patch.enabled !== void 0 && patch.enabled !== previousEnabled) route.hub.publish("device-state", {
|
|
11562
12212
|
deviceId,
|
|
11563
|
-
state
|
|
11564
|
-
});
|
|
11565
|
-
writeJson(res, 200, {
|
|
11566
|
-
deviceId: next.deviceId,
|
|
11567
|
-
displayName: next.displayName,
|
|
11568
|
-
state,
|
|
11569
|
-
enabled: next.enabled,
|
|
11570
|
-
hosts: [...next.hosts],
|
|
11571
|
-
port: next.port,
|
|
11572
|
-
security: { ...next.security },
|
|
11573
|
-
ports: { ...next.ports }
|
|
12213
|
+
state: summary.state
|
|
11574
12214
|
});
|
|
12215
|
+
writeJson(res, 200, summary);
|
|
11575
12216
|
}
|
|
11576
12217
|
function warnConnect(deviceId) {
|
|
11577
12218
|
return (error) => {
|
|
@@ -11583,14 +12224,128 @@ function sameHosts(a, b) {
|
|
|
11583
12224
|
return a.length === b.length && a.every((host, index) => host === b[index]);
|
|
11584
12225
|
}
|
|
11585
12226
|
/**
|
|
12227
|
+
* `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
|
|
12228
|
+
* settings page's door onto #185's endpoint maintenance, which until now only
|
|
12229
|
+
* the `cherry_devices` tool could open.
|
|
12230
|
+
*
|
|
12231
|
+
* One action per request, and the action IS the request: there is no composite
|
|
12232
|
+
* "patch the record AND forget a host" body whose ordering would have to be
|
|
12233
|
+
* defined. Every judgement is the store's — the last-candidate refusal, the
|
|
12234
|
+
* case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
|
|
12235
|
+
* the two doors share ONE set of STORE rules; this route only maps the verdicts
|
|
12236
|
+
* onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
|
|
12237
|
+
* differ on purpose — see `parseEndpointHosts`.)
|
|
12238
|
+
*
|
|
12239
|
+
* EVICTION on a change, exactly like the tool's own arm and a PATCH's address
|
|
12240
|
+
* maintenance, and for a DIFFERENT reason on each action — which is why both
|
|
12241
|
+
* are stated here rather than waved through as "parity":
|
|
12242
|
+
*
|
|
12243
|
+
* - FORGET: a pooled connection CAPTURES its record at construction, so the
|
|
12244
|
+
* socket built before this write would keep dialing the host we just forgot.
|
|
12245
|
+
* (The mark itself is honoured by the store's learn merge, so a reconnect is
|
|
12246
|
+
* about the DIAL LIST, not about suppressing the address.)
|
|
12247
|
+
* - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
|
|
12248
|
+
* stored record. Without the eviction a desktop that stays connected would
|
|
12249
|
+
* not re-handshake for as long as the link holds, so the restored address
|
|
12250
|
+
* would come back only after some later flap — the button would look inert.
|
|
12251
|
+
*
|
|
12252
|
+
* The cost is real and inherited from the tool's arm: an eviction disposes the
|
|
12253
|
+
* subscription layer and closes a healthy socket, so a write can drop an
|
|
12254
|
+
* in-flight command's link. One operator click now reaches that churn; that is
|
|
12255
|
+
* the price of "the archive is the truth" for a live connection.
|
|
12256
|
+
*/
|
|
12257
|
+
async function changeEndpoints(req, res, route, deviceId, action) {
|
|
12258
|
+
const existing = route.deps.store.list().find((device) => device.deviceId === deviceId);
|
|
12259
|
+
if (existing === void 0) {
|
|
12260
|
+
writeError(res, 404, "not-found", `no device ${deviceId}`);
|
|
12261
|
+
return;
|
|
12262
|
+
}
|
|
12263
|
+
let hosts;
|
|
12264
|
+
try {
|
|
12265
|
+
hosts = parseEndpointHosts(await readJsonBody(req));
|
|
12266
|
+
} catch (error) {
|
|
12267
|
+
writeError(res, 400, "invalid-request", error instanceof Error ? error.message : String(error));
|
|
12268
|
+
return;
|
|
12269
|
+
}
|
|
12270
|
+
try {
|
|
12271
|
+
if (action === "forget") {
|
|
12272
|
+
const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts);
|
|
12273
|
+
if (removed.length > 0 || marked.length > 0) evict(route, deviceId);
|
|
12274
|
+
} else {
|
|
12275
|
+
const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts);
|
|
12276
|
+
if (restored.length > 0) evict(route, deviceId);
|
|
12277
|
+
}
|
|
12278
|
+
} catch (error) {
|
|
12279
|
+
if (writeQuarantineRefusal(res, route, error)) return;
|
|
12280
|
+
if (error instanceof LastCandidateHostError) {
|
|
12281
|
+
writeError(res, 400, "last-address", error.message);
|
|
12282
|
+
return;
|
|
12283
|
+
}
|
|
12284
|
+
writeError(res, 400, "invalid-device", error instanceof Error ? error.message : String(error));
|
|
12285
|
+
return;
|
|
12286
|
+
}
|
|
12287
|
+
writeJson(res, 200, deviceSummary(route, route.deps.store.list().find((device) => device.deviceId === deviceId) ?? existing));
|
|
12288
|
+
}
|
|
12289
|
+
/**
|
|
12290
|
+
* The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
|
|
12291
|
+
* normalized the same way the PATCH normalizes an address.
|
|
12292
|
+
*
|
|
12293
|
+
* The normalization is not cosmetic: an IPv6 literal is stored bare but a
|
|
12294
|
+
* browser copy-pastes it bracketed, and the store matches by host spelling —
|
|
12295
|
+
* so `[::1]` would be treated as an address the record does not dial and merely
|
|
12296
|
+
* marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
|
|
12297
|
+
* spelling (same `normalizeHost`), and deliberately NOT for its duplicate
|
|
12298
|
+
* refusal: a repeated host in an action body names one host twice, which the
|
|
12299
|
+
* store's own sets collapse, while the PATCH's duplicates would build an
|
|
12300
|
+
* ambiguous record (`hosts`, `security` and `ports` are keyed by host).
|
|
12301
|
+
*/
|
|
12302
|
+
function parseEndpointHosts(body) {
|
|
12303
|
+
const hosts = body["hosts"];
|
|
12304
|
+
if (!Array.isArray(hosts) || hosts.length === 0) throw new PatchError("hosts must be a non-empty array");
|
|
12305
|
+
return hosts.map((entry) => {
|
|
12306
|
+
if (typeof entry !== "string") throw new PatchError("hosts entries must be strings");
|
|
12307
|
+
return normalizeHost(entry);
|
|
12308
|
+
});
|
|
12309
|
+
}
|
|
12310
|
+
/**
|
|
11586
12311
|
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
11587
12312
|
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
11588
12313
|
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
11589
12314
|
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
11590
12315
|
* demote a TLS-proxied desktop to a plain dial.
|
|
12316
|
+
*
|
|
12317
|
+
* "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
|
|
12318
|
+
* learn merge, the forget/remember actions and the mark clearing already use:
|
|
12319
|
+
* `NAS.example` and `nas.example` are one address, so a PATCH that re-types
|
|
12320
|
+
* the case keeps the remembered scheme instead of resetting it to `ws`.
|
|
12321
|
+
* `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
|
|
12322
|
+
* address edit happened at all, and a new spelling IS a change to persist.
|
|
11591
12323
|
*/
|
|
11592
12324
|
function securityFor(hosts, previous) {
|
|
11593
|
-
|
|
12325
|
+
const remembered = indexByHost(previous);
|
|
12326
|
+
return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? "ws"]));
|
|
12327
|
+
}
|
|
12328
|
+
/**
|
|
12329
|
+
* A case-insensitive index over a per-host map (issue #189).
|
|
12330
|
+
*
|
|
12331
|
+
* FIRST spelling wins on a collision. A hand-edited archive CAN hold two
|
|
12332
|
+
* spellings of one host (the store does not reject it, and this face does not
|
|
12333
|
+
* touch `previous`), so callers look their exact spelling up FIRST and consult
|
|
12334
|
+
* this index only as the fallback: a surviving spelling therefore keeps its
|
|
12335
|
+
* OWN entry, and the collision rule only decides what a THIRD spelling
|
|
12336
|
+
* inherits.
|
|
12337
|
+
*/
|
|
12338
|
+
function indexByHost(previous) {
|
|
12339
|
+
const index = /* @__PURE__ */ new Map();
|
|
12340
|
+
for (const [host, value] of Object.entries(previous)) {
|
|
12341
|
+
const key = host.toLowerCase();
|
|
12342
|
+
if (!index.has(key)) index.set(key, value);
|
|
12343
|
+
}
|
|
12344
|
+
return index;
|
|
12345
|
+
}
|
|
12346
|
+
/** The exact spelling's entry, else the case-insensitive one, else nothing. */
|
|
12347
|
+
function exactOrRemembered(previous, remembered, host) {
|
|
12348
|
+
return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase());
|
|
11594
12349
|
}
|
|
11595
12350
|
/**
|
|
11596
12351
|
* The port map for a host list (issue #135): each named host keeps the port it
|
|
@@ -11599,11 +12354,13 @@ function securityFor(hosts, previous) {
|
|
|
11599
12354
|
*
|
|
11600
12355
|
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
11601
12356
|
* `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
|
|
11603
|
-
*
|
|
12357
|
+
* field on this face must move the whole list. Same `Object.hasOwn`/
|
|
12358
|
+
* case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
|
|
12359
|
+
* keeps its own port, and an inherited function is never a remembered port.
|
|
11604
12360
|
*/
|
|
11605
12361
|
function portsFor(hosts, previous, fallback) {
|
|
11606
|
-
|
|
12362
|
+
const remembered = indexByHost(previous);
|
|
12363
|
+
return Object.fromEntries(hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]));
|
|
11607
12364
|
}
|
|
11608
12365
|
/** Order-insensitive comparison for two scheme maps. */
|
|
11609
12366
|
function sameSecurity(a, b) {
|
|
@@ -11933,7 +12690,10 @@ function connectionFor(deviceId) {
|
|
|
11933
12690
|
identity: requireIdentity(),
|
|
11934
12691
|
record,
|
|
11935
12692
|
rateBudgetPerSecond: state.rateBudgetPerSecond,
|
|
11936
|
-
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls }
|
|
12693
|
+
...state.wsTls === void 0 ? {} : { wsOptions: state.wsTls },
|
|
12694
|
+
onAdvertisedEndpoints: (endpoints) => {
|
|
12695
|
+
state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity);
|
|
12696
|
+
}
|
|
11937
12697
|
});
|
|
11938
12698
|
state.connectionsBuilt += 1;
|
|
11939
12699
|
connection.onState((deviceState) => {
|
|
@@ -12038,7 +12798,8 @@ function subsFor(deviceId) {
|
|
|
12038
12798
|
const connection = connectionFor(deviceId);
|
|
12039
12799
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
12040
12800
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
12041
|
-
perSessionCacheBytes: state.perSessionCacheBytes
|
|
12801
|
+
perSessionCacheBytes: state.perSessionCacheBytes,
|
|
12802
|
+
unackedBytes: () => connection.limits.unackedBytes
|
|
12042
12803
|
});
|
|
12043
12804
|
state.subscriptions.set(deviceId, created);
|
|
12044
12805
|
console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);
|