ccdeck 3.22.15 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +244 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BYqkKWPC.js +0 -274
- package/dist/web/assets/index-s_dF8YYA.css +0 -1
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// accounts all work talks to its peers every minute and never asks for
|
|
29
29
|
// anything.
|
|
30
30
|
import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
|
-
import { connectToPeer, createBeacon, createSyncServer,
|
|
31
|
+
import { connectToPeer, createBeacon, createSyncServer, MAX_FRAME_BYTES } from "./lan-socket.mjs";
|
|
32
32
|
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
33
|
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
34
34
|
import { randomBytes } from "node:crypto";
|
|
@@ -57,7 +57,25 @@ export const ASKING_MS = 8_000;
|
|
|
57
57
|
/** How long one peer round may take before it is abandoned. A manifest is one
|
|
58
58
|
* round trip on a local network; anything past this is a peer that is not
|
|
59
59
|
* going to answer, and holding the attempt open would stall the next round. */
|
|
60
|
-
|
|
60
|
+
const ROUND_MS = 10_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The most addresses `autoAsk` may put on the dial list on its own.
|
|
64
|
+
*
|
|
65
|
+
* Measured, because the shape of it is not the obvious one: the dial list is
|
|
66
|
+
* keyed `host:port`, not by fingerprint, so five hundred beacons from one
|
|
67
|
+
* address on one port make one row. Five hundred beacons from one address on
|
|
68
|
+
* five hundred PORTS make five hundred rows, and a round dials them one at a
|
|
69
|
+
* time with a ROUND_MS bell on each — so a list that size is eighty minutes of
|
|
70
|
+
* round, and the decks somebody actually paired with sit at the end of it
|
|
71
|
+
* waiting their turn. The ceiling on that without this number is 65,535 rows
|
|
72
|
+
* from a single host.
|
|
73
|
+
*
|
|
74
|
+
* It bounds only what the deck added BY ITSELF. Addresses a person typed are
|
|
75
|
+
* not capped: a list of those is somebody's own decision and the deck is in no
|
|
76
|
+
* position to tell them they have too many machines.
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_AUTO_PEERS = 32;
|
|
61
79
|
|
|
62
80
|
|
|
63
81
|
/** What this machine calls itself when the user has not said. The hostname,
|
|
@@ -103,11 +121,30 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
103
121
|
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
104
122
|
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
105
123
|
*/
|
|
124
|
+
/** One peer-supplied string as the panel may draw it: no control or format
|
|
125
|
+
* characters, whitespace collapsed, bounded. The rule cleanName applies to a
|
|
126
|
+
* deck's name and lan-about's `field` to a card, applied to the two strings
|
|
127
|
+
* that sit next to the fingerprint in the import dialog. */
|
|
128
|
+
function flatten(v, max) {
|
|
129
|
+
if (typeof v !== "string") return "";
|
|
130
|
+
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
|
|
131
|
+
return [...flat].slice(0, max).join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
106
134
|
export function offered(list) {
|
|
107
135
|
return (Array.isArray(list) ? list : [])
|
|
108
136
|
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
109
137
|
.slice(0, 50)
|
|
110
|
-
|
|
138
|
+
// Character-filtered, not merely cut. These two are drawn beside the
|
|
139
|
+
// fingerprint at the moment the operator picks which of a peer's logins to
|
|
140
|
+
// import (LanPeerModal.tsx:497), and a bare slice let a format character
|
|
141
|
+
// through — the same class cleanName strips from the name one frame over.
|
|
142
|
+
.map(a => ({
|
|
143
|
+
key: flatten(a.key, 320),
|
|
144
|
+
email: flatten(a.email, 254),
|
|
145
|
+
alive: a.alive === true,
|
|
146
|
+
}))
|
|
147
|
+
.filter(a => a.key && a.email);
|
|
111
148
|
}
|
|
112
149
|
|
|
113
150
|
/**
|
|
@@ -152,6 +189,21 @@ export function createEngine({
|
|
|
152
189
|
/** This deck's own card — its version and its machine — handed to every
|
|
153
190
|
* paired deck and to nobody else. See lan-about.mjs. */
|
|
154
191
|
about = null,
|
|
192
|
+
/** Whether this deck seals every frame after the handshake with a deck that
|
|
193
|
+
* says it does too — see frameChannel in lan-sync.mjs. Nothing in the deck
|
|
194
|
+
* turns it off; the suite does, to play a deck from before #810, which is
|
|
195
|
+
* the only way to show that one still heals. */
|
|
196
|
+
sealFrames = true,
|
|
197
|
+
/** Whether this deck mixes a key pair made for each connection into that
|
|
198
|
+
* connection's key, with a deck that says it does too — see sessionKey in
|
|
199
|
+
* lan-sync.mjs. Nothing in the deck turns this off either; the suite does,
|
|
200
|
+
* to play a deck of #810's version, which seals and does not mix. It rides
|
|
201
|
+
* on `sealFrames`: with that off, this deck says neither. */
|
|
202
|
+
ephemeral = true,
|
|
203
|
+
/** Where the listener binds: every interface, which is what a peer dials,
|
|
204
|
+
* unless the suite says loopback — a test deck has no business being
|
|
205
|
+
* reachable from the office for the seconds it runs. */
|
|
206
|
+
host,
|
|
155
207
|
} = {}) {
|
|
156
208
|
let cfg = {
|
|
157
209
|
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
@@ -163,6 +215,11 @@ export function createEngine({
|
|
|
163
215
|
let beacon = null;
|
|
164
216
|
let server = null;
|
|
165
217
|
let timer = null;
|
|
218
|
+
/** This engine, for the helpers below `apply` that need to press its own
|
|
219
|
+
* accept — see askToAccept. Set on the first apply, which is the only thing
|
|
220
|
+
* that can start a round or a listener, so nothing reads it before it is
|
|
221
|
+
* there. */
|
|
222
|
+
let engine = null;
|
|
166
223
|
/**
|
|
167
224
|
* Decks that finished a handshake and that nobody here has accepted yet, and
|
|
168
225
|
* decks merely heard shouting on the network. Two lists because they are two
|
|
@@ -272,7 +329,9 @@ export function createEngine({
|
|
|
272
329
|
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
273
330
|
* accepted. Nothing reaches this before both, which is the whole point of
|
|
274
331
|
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
275
|
-
* connection's — see sessionKey.
|
|
332
|
+
* connection's — see sessionKey. `ctx.send` seals whatever it is handed when
|
|
333
|
+
* both ends said they seal, so nothing below has to know which kind of deck
|
|
334
|
+
* asked — see frameChannel. */
|
|
276
335
|
const serve = async (msg, ctx) => {
|
|
277
336
|
// Before the verbs, and for every one of them: something that proved it
|
|
278
337
|
// holds a key this deck accepted is talking, now.
|
|
@@ -329,6 +388,34 @@ export function createEngine({
|
|
|
329
388
|
}
|
|
330
389
|
};
|
|
331
390
|
|
|
391
|
+
/**
|
|
392
|
+
* A real deck nobody here has accepted: draw it as a row with an accept on it.
|
|
393
|
+
*
|
|
394
|
+
* ONE HELPER FOR BOTH DIRECTIONS, because the evidence is the same either
|
|
395
|
+
* way. A deck that DIALLED this one finishes a handshake at the listener and
|
|
396
|
+
* arrives through `onPending`; a deck this one dialled finishes the same
|
|
397
|
+
* handshake in roundWith. Both have proved they hold the key they announced,
|
|
398
|
+
* and neither has been agreed to. Before #969 only the first raised a
|
|
399
|
+
* request and the second silently pinned itself.
|
|
400
|
+
*
|
|
401
|
+
* SAY YES FOR SOMEBODY WHO SAID TO. `autoAccept` is the accept button and
|
|
402
|
+
* nothing else: the same pin, from the same key the handshake just proved.
|
|
403
|
+
* Nothing about the wire changes — an inbound connection is still refused,
|
|
404
|
+
* because trust is read fresh per connection, and the caller comes back a few
|
|
405
|
+
* seconds later.
|
|
406
|
+
*
|
|
407
|
+
* A deck already told no does not become a row again. On the inbound path
|
|
408
|
+
* lan-socket refuses it before this is ever called; on the outbound one there
|
|
409
|
+
* is nothing before this, so the check lives here.
|
|
410
|
+
*/
|
|
411
|
+
const askToAccept = entry => {
|
|
412
|
+
if (declined.has(entry.fp)) return;
|
|
413
|
+
const had = pending.get(entry.fp);
|
|
414
|
+
pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
|
|
415
|
+
if (cfg.autoAccept) { engine?.accept(entry.fp); return; }
|
|
416
|
+
if (!had) onChange?.();
|
|
417
|
+
};
|
|
418
|
+
|
|
332
419
|
/**
|
|
333
420
|
* Is anybody on the other end still deciding?
|
|
334
421
|
*
|
|
@@ -352,33 +439,58 @@ export function createEngine({
|
|
|
352
439
|
// The key pinned when this deck was accepted, so a second machine
|
|
353
440
|
// answering at that address is refused rather than talked to.
|
|
354
441
|
expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
|
|
442
|
+
sealFrames, ephemeral,
|
|
355
443
|
});
|
|
444
|
+
// A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
|
|
445
|
+
//
|
|
446
|
+
// lan-socket.mjs's header states the rule: "a peer that sends a megabyte
|
|
447
|
+
// with no newline in it is not sending a large frame, it is sending
|
|
448
|
+
// nothing at all, expensively... the buffer is ABANDONED rather than
|
|
449
|
+
// grown past it." frameReader enforces it; this reader did not.
|
|
450
|
+
//
|
|
451
|
+
// The frameReader connectToPeer installed IS still attached and does hit
|
|
452
|
+
// MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
|
|
453
|
+
// short-circuits on `settled`, so nothing destroys the socket. This
|
|
454
|
+
// buffer then grew unbounded for the full ROUND_MS at line rate. The
|
|
455
|
+
// reject path also never removed the listener; only roundWith's
|
|
456
|
+
// `finally { conn?.sock?.destroy(); }` stopped it.
|
|
356
457
|
const ask = frame => new Promise((resolve, reject) => {
|
|
357
458
|
const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
|
|
358
459
|
bell.unref?.();
|
|
359
460
|
let buf = "";
|
|
461
|
+
const give = (fn, arg) => {
|
|
462
|
+
clearTimeout(bell);
|
|
463
|
+
conn.sock.off("data", onData);
|
|
464
|
+
fn(arg);
|
|
465
|
+
};
|
|
360
466
|
const onData = chunk => {
|
|
361
467
|
buf += chunk;
|
|
468
|
+
if (buf.length > MAX_FRAME_BYTES) {
|
|
469
|
+
conn.sock.destroy();
|
|
470
|
+
return give(reject, new Error("frame too large"));
|
|
471
|
+
}
|
|
362
472
|
const i = buf.indexOf("\n");
|
|
363
473
|
if (i === -1) return;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
474
|
+
let parsed;
|
|
475
|
+
try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
|
|
476
|
+
// THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
|
|
477
|
+
// that is the only way in. A reply that does not open is not a reply
|
|
478
|
+
// with something wrong in it; it is a connection that stopped being
|
|
479
|
+
// the one the handshake proved — altered, replayed, or plain where
|
|
480
|
+
// both ends agreed to seal — so the round ends here instead of
|
|
481
|
+
// reading it as it stands. See frameChannel.
|
|
482
|
+
const got = conn.read(parsed);
|
|
483
|
+
if (conn.sealed && !got) {
|
|
484
|
+
conn.sock.destroy();
|
|
485
|
+
return give(reject, new Error("a reply from that deck did not open"));
|
|
486
|
+
}
|
|
487
|
+
give(resolve, got);
|
|
367
488
|
};
|
|
368
489
|
conn.sock.on("data", onData);
|
|
369
|
-
|
|
490
|
+
// And out through its own writer, which seals whenever the reader opens.
|
|
491
|
+
conn.send(frame);
|
|
370
492
|
});
|
|
371
493
|
|
|
372
|
-
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
|
|
373
|
-
// deck we have no pin for means the person at this keyboard put its
|
|
374
|
-
// address in the field, which is the same decision the accept button is
|
|
375
|
-
// on the other side. Pinning it here is what makes the two lists agree —
|
|
376
|
-
// without it this deck would dial a peer every minute and still show it
|
|
377
|
-
// as nobody, and its own listener would refuse the same deck calling
|
|
378
|
-
// back.
|
|
379
|
-
//
|
|
380
|
-
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
381
|
-
// was given expectPub and refuses a different key at that address.
|
|
382
494
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
383
495
|
// and nothing else until somebody answers it — and once one has, the deck
|
|
384
496
|
// on the other end has told us what it calls itself. The row says that
|
|
@@ -386,7 +498,46 @@ export function createEngine({
|
|
|
386
498
|
// address was trying to reach.
|
|
387
499
|
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
388
500
|
|
|
501
|
+
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
|
|
502
|
+
// deck we have no pin for used to mean the person at this keyboard put
|
|
503
|
+
// its address in the field, which is the same decision the accept button
|
|
504
|
+
// is on the other side. Pinning it here is what makes the two lists agree
|
|
505
|
+
// — without it this deck would dial a peer every minute and still show it
|
|
506
|
+
// as nobody, and its own listener would refuse the same deck calling back.
|
|
507
|
+
//
|
|
508
|
+
// THE PREMISE WAS NOT CHECKED, AND `autoAsk` BREAKS IT — which is #969,
|
|
509
|
+
// and it is a chain rather than one mistake. A beacon authenticates
|
|
510
|
+
// nothing, by construction: it carries a fingerprint and a port and
|
|
511
|
+
// nothing binds either to the address it came from. `autoAsk` ships on,
|
|
512
|
+
// and it answered every new fingerprint by putting that address on the
|
|
513
|
+
// dial list. The next round reached it, arrived here with no pin, and
|
|
514
|
+
// read "no pin" as "somebody typed this". Nobody typed anything. One
|
|
515
|
+
// unsolicited packet, zero presses, and the far end was in `cfg.trusted`
|
|
516
|
+
// — which index.mjs writes to prefs.json, and which is the whole inbound
|
|
517
|
+
// gate — so from then on it could authenticate to `serve` and ask for
|
|
518
|
+
// every account the owner had ticked. Reproduced end to end before this
|
|
519
|
+
// line changed.
|
|
520
|
+
//
|
|
521
|
+
// So the row has to say where it came from, and only a row a person
|
|
522
|
+
// named may be pinned unseen. A row the deck added itself raises the
|
|
523
|
+
// request instead — which is all `autoAsk` ever promised: it is the ASK
|
|
524
|
+
// switch, and the accept switch is the other one.
|
|
525
|
+
//
|
|
526
|
+
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
527
|
+
// was given expectPub and refuses a different key at that address.
|
|
389
528
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
529
|
+
if (!peer.typed) {
|
|
530
|
+
// The same row the listener's own `onPending` draws, from the other
|
|
531
|
+
// direction: this deck dialled rather than being dialled, and the
|
|
532
|
+
// handshake it just finished is the same evidence either way — a real
|
|
533
|
+
// deck holding the key it announced. What is missing is the press,
|
|
534
|
+
// and that is what this asks for.
|
|
535
|
+
askToAccept({
|
|
536
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
537
|
+
addr: peer.addr, port: peer.port,
|
|
538
|
+
});
|
|
539
|
+
throw new Error("waiting for somebody here to accept that deck");
|
|
540
|
+
}
|
|
390
541
|
const { list, added } = addTrusted(cfg.trusted, {
|
|
391
542
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
392
543
|
});
|
|
@@ -419,7 +570,16 @@ export function createEngine({
|
|
|
419
570
|
// them, which is the whole of "I do not want to paste blobs any more".
|
|
420
571
|
// What can reach this is what a deck somebody here pressed accept on
|
|
421
572
|
// chose to offer.
|
|
422
|
-
|
|
573
|
+
// OVER `list`, NOT THE RAW ARRAY. `offered` above slices to 50 and type-
|
|
574
|
+
// filters `key` and `email`; this line read `theirs.accounts` and got
|
|
575
|
+
// neither. syncAction answers "add" for anything this deck lacks and an
|
|
576
|
+
// add needs no tick, so a peer answering `manifest` with thousands of
|
|
577
|
+
// rows produced thousands of steps — each a sequential `want`/`have`
|
|
578
|
+
// round trip with its own 10s bell plus a claude-swap subprocess holding
|
|
579
|
+
// the store lock, while the panel drew 50 and every other paired deck
|
|
580
|
+
// waited behind it. `step.key` also reached transferChallenge and
|
|
581
|
+
// importAccount untyped, which `offered`'s filter would have caught.
|
|
582
|
+
const wanted = plan(mine, list)
|
|
423
583
|
.filter(step => step.action === "add" || cfg.shared.includes(step.key));
|
|
424
584
|
const done = [];
|
|
425
585
|
for (const step of wanted) {
|
|
@@ -466,14 +626,21 @@ export function createEngine({
|
|
|
466
626
|
*
|
|
467
627
|
* Keyed by `host:port` rather than by fingerprint, because a fingerprint is
|
|
468
628
|
* what a deck says about itself after the handshake and this list has to
|
|
469
|
-
* exist before there has been one.
|
|
629
|
+
* exist before there has been one.
|
|
630
|
+
*
|
|
631
|
+
* EVERY ROW SAYS WHERE IT CAME FROM, in `typed`, and the difference decides
|
|
632
|
+
* whether reaching it may pin a key sight unseen. A row somebody put in the
|
|
633
|
+
* address field — or pressed accept on, or joined by invite — is a person
|
|
634
|
+
* naming a machine. A row `autoAsk` added from a beacon is this deck
|
|
635
|
+
* answering a shout, which is not the same claim and must not read as one.
|
|
636
|
+
* See roundWith, where the difference is the whole of the trust rule. */
|
|
470
637
|
const manual = new Map();
|
|
471
638
|
/** What answered at a typed address, once something has. Keyed the same way
|
|
472
639
|
* `manual` is, because until a connection succeeds an address is all there
|
|
473
640
|
* is to key on. */
|
|
474
641
|
const learned = new Map();
|
|
475
642
|
|
|
476
|
-
const
|
|
643
|
+
const oneRound = async () => {
|
|
477
644
|
if (!beacon) return [];
|
|
478
645
|
const all = [];
|
|
479
646
|
// Heard first, typed second, and a typed one is skipped when the beacon
|
|
@@ -485,20 +652,75 @@ export function createEngine({
|
|
|
485
652
|
const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
|
|
486
653
|
for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
|
|
487
654
|
// Sequential rather than parallel. The store takes one mutation at a
|
|
488
|
-
// time anyway (
|
|
489
|
-
// account at once would race for a slot number claude-swap assigns
|
|
490
|
-
// max+1 without a lock of its own.
|
|
655
|
+
// time anyway (the mutex in store-lock.mjs), and two peers healing the
|
|
656
|
+
// same account at once would race for a slot number claude-swap assigns
|
|
657
|
+
// as max+1 without a lock of its own.
|
|
491
658
|
all.push(...await roundWith(peer));
|
|
492
659
|
}
|
|
493
660
|
roundAt = now();
|
|
494
661
|
return all;
|
|
495
662
|
};
|
|
496
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Where every round waits its turn — the whole list, or one deck from its own
|
|
666
|
+
* dialog — so that whichever is running is running alone. `round` and
|
|
667
|
+
* `roundOne` are the only two ways in, and both come through here.
|
|
668
|
+
*
|
|
669
|
+
* A LINE RATHER THAN A FLAG, because the two ways in want different things
|
|
670
|
+
* from what is already running and both have to end up behind it. A round
|
|
671
|
+
* asked for during a check cannot join it — a check asks one deck, and a
|
|
672
|
+
* round was asked to ask all of them — so it waits. A check asked for during
|
|
673
|
+
* a round can often join it, and sometimes cannot. Both need somewhere to
|
|
674
|
+
* stand that is after whatever is in flight, and this is it.
|
|
675
|
+
*
|
|
676
|
+
* The tail never rejects: `roundWith` reports a failure per peer instead of
|
|
677
|
+
* throwing, and anything else is swallowed here rather than left to stop
|
|
678
|
+
* every turn after it. The caller of the turn that threw still hears it.
|
|
679
|
+
*/
|
|
680
|
+
let _turn = Promise.resolve();
|
|
681
|
+
const inTurn = job => {
|
|
682
|
+
const run = _turn.then(job);
|
|
683
|
+
_turn = run.then(() => {}, () => {});
|
|
684
|
+
return run;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
/** The whole round in flight or waiting its turn, or null. See `round` below. */
|
|
688
|
+
let _round = null;
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* One round at a time, and the one already running is the answer (#1040).
|
|
692
|
+
*
|
|
693
|
+
* The sequential loop above reasons about peers being dialled one after
|
|
694
|
+
* another, which is a statement about the WHOLE round and was only ever true
|
|
695
|
+
* of a round running alone. Two ways in, and they meet: a self-scheduling
|
|
696
|
+
* timer (SYNC_MS, or ASKING_MS while somebody is waiting) and the "Sync now"
|
|
697
|
+
* press, which calls this straight from the route. A press landing on the
|
|
698
|
+
* timer's round gave two rounds walking the same peer list, each reading the
|
|
699
|
+
* same slot as empty and each force-importing a credential over the other —
|
|
700
|
+
* and the second one's blob wins for no reason anybody chose.
|
|
701
|
+
*
|
|
702
|
+
* JOINING rather than skipping, because the press has a reply to send: a
|
|
703
|
+
* caller that got `[]` for "a round is already running" would report "nothing
|
|
704
|
+
* to sync" about a round that was at that moment moving a credential. This is
|
|
705
|
+
* the shape `codexScanOnce` and ccusage's `_inflight` already use.
|
|
706
|
+
*
|
|
707
|
+
* THERE WAS A THIRD WAY IN, and it did not come through here (#1132). The
|
|
708
|
+
* `check now` in a deck's own dialog reaches `roundOne`, which called
|
|
709
|
+
* `roundWith` straight — so a press landing on the timer's round dialled the
|
|
710
|
+
* deck that round was already healing from, and the far side exported the
|
|
711
|
+
* same login twice for one account that needed repairing once. Both now take
|
|
712
|
+
* their turn in one line, above. A round joins only another whole round,
|
|
713
|
+
* never a check: joining a check would answer "ask every deck" with one
|
|
714
|
+
* deck's work and leave the rest waiting another minute.
|
|
715
|
+
*/
|
|
716
|
+
const round = () => (_round ??= inTurn(oneRound).finally(() => { _round = null; }));
|
|
717
|
+
|
|
497
718
|
return {
|
|
498
719
|
async apply(next) {
|
|
499
720
|
/** The engine itself, for the callbacks handed to the socket below: they
|
|
500
721
|
* outlive this call and `this` is not theirs to keep. */
|
|
501
722
|
const self = this;
|
|
723
|
+
engine = this;
|
|
502
724
|
const was = cfg;
|
|
503
725
|
cfg = { ...cfg, ...next };
|
|
504
726
|
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
@@ -511,7 +733,7 @@ export function createEngine({
|
|
|
511
733
|
// The same for the other direction: switching `ask` on with four machines
|
|
512
734
|
// already listed asks those four.
|
|
513
735
|
if (!was.autoAsk && cfg.autoAsk) {
|
|
514
|
-
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp);
|
|
736
|
+
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp, { byHand: false });
|
|
515
737
|
}
|
|
516
738
|
const restart = !was.enabled !== !cfg.enabled
|
|
517
739
|
|| was.secret !== cfg.secret
|
|
@@ -532,7 +754,7 @@ export function createEngine({
|
|
|
532
754
|
// back — so the pin drifts to a free port rather than failing.
|
|
533
755
|
server = createSyncServer({
|
|
534
756
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
535
|
-
name: cfg.name, handlers: serve, onError, prefer: cfg.port,
|
|
757
|
+
name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
|
|
536
758
|
trusted: () => cfg.trusted,
|
|
537
759
|
invite: () => (invite && invite.expiresAt > now() ? invite : null),
|
|
538
760
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
@@ -563,21 +785,10 @@ export function createEngine({
|
|
|
563
785
|
// told no again rather than becoming a row somebody has to answer
|
|
564
786
|
// twice. The socket sends the reason; this only knows the name.
|
|
565
787
|
declined: fp => declined.has(fp),
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
// nothing else: the same pin, from the same key this handshake just
|
|
571
|
-
// proved, so the deck is trusted on its next attempt a few seconds
|
|
572
|
-
// later exactly as it would be if a person had pressed it. Nothing
|
|
573
|
-
// about the wire changes — this connection is still refused, because
|
|
574
|
-
// trust is read fresh per connection.
|
|
575
|
-
//
|
|
576
|
-
// A deck already told no does NOT come back this way: lan-socket
|
|
577
|
-
// refuses it before this is ever called.
|
|
578
|
-
if (cfg.autoAccept) { self.accept(entry.fp); return; }
|
|
579
|
-
if (!had) onChange?.();
|
|
580
|
-
},
|
|
788
|
+
// The same helper the outbound round uses, because a deck that called
|
|
789
|
+
// in and a deck this one called have proved exactly the same thing —
|
|
790
|
+
// see askToAccept.
|
|
791
|
+
onPending: askToAccept,
|
|
581
792
|
});
|
|
582
793
|
let port;
|
|
583
794
|
try {
|
|
@@ -611,7 +822,9 @@ export function createEngine({
|
|
|
611
822
|
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
612
823
|
// would be thirty seconds of asking; and never one this deck's owner
|
|
613
824
|
// already turned away.
|
|
614
|
-
|
|
825
|
+
// Never `byHand`: a beacon is a shout from an address nobody here
|
|
826
|
+
// named, and the row it leaves may ask rather than pin.
|
|
827
|
+
if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
|
|
615
828
|
// Only a deck that is new to us is news. A beacon every thirty
|
|
616
829
|
// seconds from one already on the list is not a reason to redraw.
|
|
617
830
|
if (!had) onChange?.();
|
|
@@ -699,6 +912,15 @@ export function createEngine({
|
|
|
699
912
|
host: at.addr, port: at.port, timeoutMs: ROUND_MS,
|
|
700
913
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
701
914
|
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
915
|
+
// MAKE IT PROVE IT HOLDS THE CODE. Without this the loop pinned
|
|
916
|
+
// whatever answered first, and the addresses in a token are only as
|
|
917
|
+
// trustworthy as the network they name: `localAddresses` keeps
|
|
918
|
+
// RFC1918, so a container bridge address or a lease that has since
|
|
919
|
+
// moved to somebody else's machine is an ordinary thing to find in
|
|
920
|
+
// one. Stopping at the first that ANSWERS is right; stopping at the
|
|
921
|
+
// first that answers CORRECTLY is what it was supposed to mean.
|
|
922
|
+
inviteProvesBack: inv.provesBack,
|
|
923
|
+
sealFrames, ephemeral,
|
|
702
924
|
});
|
|
703
925
|
const { list } = addTrusted(cfg.trusted, {
|
|
704
926
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
@@ -732,7 +954,7 @@ export function createEngine({
|
|
|
732
954
|
* thing. A fingerprint nobody has actually met is refused rather than
|
|
733
955
|
* trusted on a name somebody typed.
|
|
734
956
|
*/
|
|
735
|
-
accept(fp) {
|
|
957
|
+
accept(fp, { byHand = true } = {}) {
|
|
736
958
|
const asked = pending.get(fp) ?? null;
|
|
737
959
|
const heard = strangers.get(fp) ?? null;
|
|
738
960
|
const seen = asked ?? heard;
|
|
@@ -751,7 +973,12 @@ export function createEngine({
|
|
|
751
973
|
// is the same two presses, in the other order.
|
|
752
974
|
if (!seen.pub) {
|
|
753
975
|
if (!seen.addr || !seen.port) return null;
|
|
754
|
-
|
|
976
|
+
// `byHand` IS WHAT THE ROW WILL BE ALLOWED TO DO LATER. A press here is
|
|
977
|
+
// a person naming a machine, so the row may be pinned on the round that
|
|
978
|
+
// reaches it. `autoAsk` reaches this same line with byHand false — the
|
|
979
|
+
// deck answering a shout — and the row it leaves may only ASK, which is
|
|
980
|
+
// what the switch's own name says it does. See roundWith.
|
|
981
|
+
this.addPeer(seen.addr, seen.port, { typed: byHand });
|
|
755
982
|
strangers.delete(fp);
|
|
756
983
|
onChange?.();
|
|
757
984
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
@@ -823,22 +1050,83 @@ export function createEngine({
|
|
|
823
1050
|
*
|
|
824
1051
|
* `roundAt` is left alone: it says when EVERY paired deck was last asked,
|
|
825
1052
|
* and asking one of them does not make that true.
|
|
1053
|
+
*
|
|
1054
|
+
* IN TURN, LIKE EVERY ROUND (#1132). This called `roundWith` straight, past
|
|
1055
|
+
* the guard `round` keeps, and a press during the timer's round dialled the
|
|
1056
|
+
* deck that round was mid-way through healing from: the far side exported
|
|
1057
|
+
* `[5, 5]` and this side imported twice. fillEmptySlot's verdict inside the
|
|
1058
|
+
* lock limited what the second write could do on the forced path; it did
|
|
1059
|
+
* not stop the dial, the export, or a second write behind one verdict.
|
|
1060
|
+
*
|
|
1061
|
+
* WHAT IS ALREADY RUNNING IS THE ANSWER, WHEN IT HAS ONE. The press waits
|
|
1062
|
+
* for everything ahead of it, and if an ask of this deck finished in that
|
|
1063
|
+
* time — the round was dialling it when the press landed, or reached it
|
|
1064
|
+
* after — that ask was the check, and its result is returned instead of
|
|
1065
|
+
* asking again. A second ask would be a second dial for nothing, and it
|
|
1066
|
+
* would also be WORSE for the person who pressed. The dialog does not read
|
|
1067
|
+
* this list: it redraws from `lastRound`, where a login that moved says
|
|
1068
|
+
* "arrived last round". An ask after a heal finds that login healthy —
|
|
1069
|
+
* importAccount drops the accounts cache, so the next read is claude-swap's
|
|
1070
|
+
* — moves nothing, and writes that over the record. The lane repaired from
|
|
1071
|
+
* this very deck a moment ago would stop saying so, in answer to the press
|
|
1072
|
+
* that asked about it.
|
|
1073
|
+
*
|
|
1074
|
+
* AND ONLY THEN. When nothing that ran asked this deck after the press — the
|
|
1075
|
+
* round had been past it already, or never had it on its list, as with a
|
|
1076
|
+
* deck first heard mid-round — joining would be a check that checked
|
|
1077
|
+
* nothing. So it asks this deck itself, in its turn, behind whatever was
|
|
1078
|
+
* ahead of it and never beside it. Told apart by the record, not the clock:
|
|
1079
|
+
* every ask writes a new one, so the same object before and after means
|
|
1080
|
+
* nothing here asked this deck since the press. Keyed by the ROW, because
|
|
1081
|
+
* that is what roundWith writes under, and a typed row's `fp` is the
|
|
1082
|
+
* placeholder built from its address rather than the fingerprint asked for.
|
|
826
1083
|
*/
|
|
827
1084
|
async roundOne(fp) {
|
|
828
1085
|
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
829
1086
|
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
830
1087
|
const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
|
|
831
1088
|
const peer = heard ?? typed;
|
|
832
|
-
|
|
1089
|
+
if (!peer) return null;
|
|
1090
|
+
const had = lastRound.get(peer.fp);
|
|
1091
|
+
await _turn;
|
|
1092
|
+
const got = lastRound.get(peer.fp);
|
|
1093
|
+
if (got && got !== had) return got.done ?? [];
|
|
1094
|
+
// A deck switched off while the press waited is not dialled after all.
|
|
1095
|
+
return inTurn(() => (beacon ? roundWith(peer) : []));
|
|
833
1096
|
},
|
|
834
1097
|
/** Dial this address on every round from now on. Returns false for an
|
|
835
1098
|
* address that is not one, rather than storing a row that can never
|
|
836
1099
|
* connect and reports an error every minute forever. */
|
|
837
|
-
addPeer(addr, port) {
|
|
1100
|
+
addPeer(addr, port, { typed = true } = {}) {
|
|
838
1101
|
const p = Number(port);
|
|
839
1102
|
if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
|
|
840
1103
|
const host = addr.trim();
|
|
841
|
-
|
|
1104
|
+
const at = `${host}:${p}`;
|
|
1105
|
+
// MAKING ROOM RATHER THAN REFUSING, and only among rows the deck added
|
|
1106
|
+
// itself. A hard refusal at the cap would let whoever got there first
|
|
1107
|
+
// keep the whole budget, so a real deck starting later would never be
|
|
1108
|
+
// asked — which turns a cap meant to protect the round into a way to
|
|
1109
|
+
// silence it. Evicted first is the oldest auto row that has never
|
|
1110
|
+
// answered: `learned` holds an entry only for an address that completed a
|
|
1111
|
+
// handshake, so a row with no entry there has cost a round and returned
|
|
1112
|
+
// nothing. When every auto row has answered, the new one waits.
|
|
1113
|
+
if (!typed && !manual.has(at)) {
|
|
1114
|
+
const auto = [...manual.entries()].filter(([, v]) => !v.typed);
|
|
1115
|
+
if (auto.length >= MAX_AUTO_PEERS) {
|
|
1116
|
+
const stale = auto.find(([k]) => !learned.has(k));
|
|
1117
|
+
if (!stale) return false;
|
|
1118
|
+
manual.delete(stale[0]);
|
|
1119
|
+
learned.delete(stale[0]);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// A row somebody typed outranks one the deck added: the same address
|
|
1123
|
+
// arriving by hand after a beacon put it there is a person vouching for
|
|
1124
|
+
// it, and nothing about that should be undone by the next beacon.
|
|
1125
|
+
const was = manual.get(at);
|
|
1126
|
+
manual.set(at, {
|
|
1127
|
+
fp: `manual:${at}`, name: host, addr: host, port: p, manual: true,
|
|
1128
|
+
typed: typed || was?.typed === true,
|
|
1129
|
+
});
|
|
842
1130
|
return true;
|
|
843
1131
|
},
|
|
844
1132
|
removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
|