ccdeck 3.22.14 → 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 +252 -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 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -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-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
// something in it, which is when an account is actually broken. A deck whose
|
|
28
28
|
// accounts all work talks to its peers every minute and never asks for
|
|
29
29
|
// anything.
|
|
30
|
-
import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
|
-
import { connectToPeer, createBeacon, createSyncServer,
|
|
30
|
+
import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
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,46 @@ 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);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Which account a peer said it is on, as the panel may keep it: the key of one
|
|
152
|
+
* of the accounts it listed in the same frame, that its owner is hiding it, or
|
|
153
|
+
* that it is on one it does not share — and nothing for anything else. A key
|
|
154
|
+
* that is not in its own list is dropped rather than drawn: a deck only ever
|
|
155
|
+
* names an account it shares, and one that names another is saying something
|
|
156
|
+
* this deck will not show.
|
|
157
|
+
*/
|
|
158
|
+
export function heardCurrent(raw, list) {
|
|
159
|
+
if (!raw || typeof raw !== "object") return null;
|
|
160
|
+
if (raw.hidden === true) return { hidden: true };
|
|
161
|
+
if (raw.other === true) return { other: true };
|
|
162
|
+
if (typeof raw.key !== "string") return null;
|
|
163
|
+
return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
/**
|
|
@@ -136,15 +189,37 @@ export function createEngine({
|
|
|
136
189
|
/** This deck's own card — its version and its machine — handed to every
|
|
137
190
|
* paired deck and to nobody else. See lan-about.mjs. */
|
|
138
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,
|
|
139
207
|
} = {}) {
|
|
140
208
|
let cfg = {
|
|
141
209
|
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
142
210
|
autoAsk: true, autoAccept: true, aliases: {},
|
|
211
|
+
// Tell paired decks which shared account this one is on — see currentFor.
|
|
212
|
+
shareActive: true,
|
|
143
213
|
};
|
|
144
214
|
let identity = null;
|
|
145
215
|
let beacon = null;
|
|
146
216
|
let server = null;
|
|
147
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;
|
|
148
223
|
/**
|
|
149
224
|
* Decks that finished a handshake and that nobody here has accepted yet, and
|
|
150
225
|
* decks merely heard shouting on the network. Two lists because they are two
|
|
@@ -237,6 +312,8 @@ export function createEngine({
|
|
|
237
312
|
email: a.email,
|
|
238
313
|
alive: a.alive === true,
|
|
239
314
|
num: a.num,
|
|
315
|
+
// The one this deck is on — claude-swap's own answer, one at most.
|
|
316
|
+
active: a.active === true,
|
|
240
317
|
}));
|
|
241
318
|
};
|
|
242
319
|
|
|
@@ -252,7 +329,9 @@ export function createEngine({
|
|
|
252
329
|
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
253
330
|
* accepted. Nothing reaches this before both, which is the whole point of
|
|
254
331
|
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
255
|
-
* 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. */
|
|
256
335
|
const serve = async (msg, ctx) => {
|
|
257
336
|
// Before the verbs, and for every one of them: something that proved it
|
|
258
337
|
// holds a key this deck accepted is talking, now.
|
|
@@ -264,9 +343,19 @@ export function createEngine({
|
|
|
264
343
|
// here ever asks. A seal that does not open is a deck that said nothing.
|
|
265
344
|
const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
|
|
266
345
|
if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
|
|
346
|
+
// AND SO DOES ITS LIST, from a deck new enough to send one: what it
|
|
347
|
+
// offers, and which of those it is on. Kept only when it came — an
|
|
348
|
+
// older caller asks with its card alone, and a missing list is not an
|
|
349
|
+
// empty one. This is the only way a deck nothing here dials is ever
|
|
350
|
+
// known by what it offers.
|
|
351
|
+
if (Array.isArray(msg.accounts)) {
|
|
352
|
+
const list = offered(msg.accounts);
|
|
353
|
+
offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
|
|
354
|
+
}
|
|
267
355
|
const accounts = await localAccounts();
|
|
268
356
|
return ctx.send({
|
|
269
357
|
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
358
|
+
...currentFor(accounts, cfg.shared, cfg.shareActive),
|
|
270
359
|
...cardFor(ctx.key, ctx.peerFp),
|
|
271
360
|
});
|
|
272
361
|
}
|
|
@@ -299,6 +388,34 @@ export function createEngine({
|
|
|
299
388
|
}
|
|
300
389
|
};
|
|
301
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
|
+
|
|
302
419
|
/**
|
|
303
420
|
* Is anybody on the other end still deciding?
|
|
304
421
|
*
|
|
@@ -322,33 +439,58 @@ export function createEngine({
|
|
|
322
439
|
// The key pinned when this deck was accepted, so a second machine
|
|
323
440
|
// answering at that address is refused rather than talked to.
|
|
324
441
|
expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
|
|
442
|
+
sealFrames, ephemeral,
|
|
325
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.
|
|
326
457
|
const ask = frame => new Promise((resolve, reject) => {
|
|
327
458
|
const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
|
|
328
459
|
bell.unref?.();
|
|
329
460
|
let buf = "";
|
|
461
|
+
const give = (fn, arg) => {
|
|
462
|
+
clearTimeout(bell);
|
|
463
|
+
conn.sock.off("data", onData);
|
|
464
|
+
fn(arg);
|
|
465
|
+
};
|
|
330
466
|
const onData = chunk => {
|
|
331
467
|
buf += chunk;
|
|
468
|
+
if (buf.length > MAX_FRAME_BYTES) {
|
|
469
|
+
conn.sock.destroy();
|
|
470
|
+
return give(reject, new Error("frame too large"));
|
|
471
|
+
}
|
|
332
472
|
const i = buf.indexOf("\n");
|
|
333
473
|
if (i === -1) return;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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);
|
|
337
488
|
};
|
|
338
489
|
conn.sock.on("data", onData);
|
|
339
|
-
|
|
490
|
+
// And out through its own writer, which seals whenever the reader opens.
|
|
491
|
+
conn.send(frame);
|
|
340
492
|
});
|
|
341
493
|
|
|
342
|
-
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
|
|
343
|
-
// deck we have no pin for means the person at this keyboard put its
|
|
344
|
-
// address in the field, which is the same decision the accept button is
|
|
345
|
-
// on the other side. Pinning it here is what makes the two lists agree —
|
|
346
|
-
// without it this deck would dial a peer every minute and still show it
|
|
347
|
-
// as nobody, and its own listener would refuse the same deck calling
|
|
348
|
-
// back.
|
|
349
|
-
//
|
|
350
|
-
// A deck we DO have a pin for was checked before this line: connectToPeer
|
|
351
|
-
// was given expectPub and refuses a different key at that address.
|
|
352
494
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
353
495
|
// and nothing else until somebody answers it — and once one has, the deck
|
|
354
496
|
// on the other end has told us what it calls itself. The row says that
|
|
@@ -356,7 +498,46 @@ export function createEngine({
|
|
|
356
498
|
// address was trying to reach.
|
|
357
499
|
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
358
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.
|
|
359
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
|
+
}
|
|
360
541
|
const { list, added } = addTrusted(cfg.trusted, {
|
|
361
542
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
362
543
|
});
|
|
@@ -365,12 +546,21 @@ export function createEngine({
|
|
|
365
546
|
|
|
366
547
|
// Our card goes with the question and theirs comes back with the answer
|
|
367
548
|
// — see lan-about.mjs for why it is here and nowhere earlier.
|
|
368
|
-
|
|
549
|
+
// THE QUESTION CARRIES THIS DECK'S LIST TOO, and which of it this deck is
|
|
550
|
+
// on — so the deck being asked knows both without dialling back, which a
|
|
551
|
+
// deck with no address for this one never could. An older deck reads the
|
|
552
|
+
// question's `t` and card and nothing else, so it answers as it always did.
|
|
553
|
+
const mine = await localAccounts();
|
|
554
|
+
const theirs = await ask({
|
|
555
|
+
t: "manifest", accounts: manifestFor(mine, cfg.shared),
|
|
556
|
+
...currentFor(mine, cfg.shared, cfg.shareActive),
|
|
557
|
+
...cardFor(conn.key, conn.peerFp),
|
|
558
|
+
});
|
|
369
559
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
370
560
|
const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
|
|
371
561
|
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
372
|
-
|
|
373
|
-
|
|
562
|
+
const list = offered(theirs.accounts);
|
|
563
|
+
offersBy.set(conn.peerFp, { at: now(), accounts: list, current: heardCurrent(theirs.current, list) });
|
|
374
564
|
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
375
565
|
// a peer cannot push an account at me that I never agreed to hold.
|
|
376
566
|
// A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
|
|
@@ -380,7 +570,16 @@ export function createEngine({
|
|
|
380
570
|
// them, which is the whole of "I do not want to paste blobs any more".
|
|
381
571
|
// What can reach this is what a deck somebody here pressed accept on
|
|
382
572
|
// chose to offer.
|
|
383
|
-
|
|
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)
|
|
384
583
|
.filter(step => step.action === "add" || cfg.shared.includes(step.key));
|
|
385
584
|
const done = [];
|
|
386
585
|
for (const step of wanted) {
|
|
@@ -427,14 +626,21 @@ export function createEngine({
|
|
|
427
626
|
*
|
|
428
627
|
* Keyed by `host:port` rather than by fingerprint, because a fingerprint is
|
|
429
628
|
* what a deck says about itself after the handshake and this list has to
|
|
430
|
-
* 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. */
|
|
431
637
|
const manual = new Map();
|
|
432
638
|
/** What answered at a typed address, once something has. Keyed the same way
|
|
433
639
|
* `manual` is, because until a connection succeeds an address is all there
|
|
434
640
|
* is to key on. */
|
|
435
641
|
const learned = new Map();
|
|
436
642
|
|
|
437
|
-
const
|
|
643
|
+
const oneRound = async () => {
|
|
438
644
|
if (!beacon) return [];
|
|
439
645
|
const all = [];
|
|
440
646
|
// Heard first, typed second, and a typed one is skipped when the beacon
|
|
@@ -446,20 +652,75 @@ export function createEngine({
|
|
|
446
652
|
const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
|
|
447
653
|
for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
|
|
448
654
|
// Sequential rather than parallel. The store takes one mutation at a
|
|
449
|
-
// time anyway (
|
|
450
|
-
// account at once would race for a slot number claude-swap assigns
|
|
451
|
-
// 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.
|
|
452
658
|
all.push(...await roundWith(peer));
|
|
453
659
|
}
|
|
454
660
|
roundAt = now();
|
|
455
661
|
return all;
|
|
456
662
|
};
|
|
457
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
|
+
|
|
458
718
|
return {
|
|
459
719
|
async apply(next) {
|
|
460
720
|
/** The engine itself, for the callbacks handed to the socket below: they
|
|
461
721
|
* outlive this call and `this` is not theirs to keep. */
|
|
462
722
|
const self = this;
|
|
723
|
+
engine = this;
|
|
463
724
|
const was = cfg;
|
|
464
725
|
cfg = { ...cfg, ...next };
|
|
465
726
|
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
@@ -472,7 +733,7 @@ export function createEngine({
|
|
|
472
733
|
// The same for the other direction: switching `ask` on with four machines
|
|
473
734
|
// already listed asks those four.
|
|
474
735
|
if (!was.autoAsk && cfg.autoAsk) {
|
|
475
|
-
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 });
|
|
476
737
|
}
|
|
477
738
|
const restart = !was.enabled !== !cfg.enabled
|
|
478
739
|
|| was.secret !== cfg.secret
|
|
@@ -493,7 +754,7 @@ export function createEngine({
|
|
|
493
754
|
// back — so the pin drifts to a free port rather than failing.
|
|
494
755
|
server = createSyncServer({
|
|
495
756
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
496
|
-
name: cfg.name, handlers: serve, onError, prefer: cfg.port,
|
|
757
|
+
name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
|
|
497
758
|
trusted: () => cfg.trusted,
|
|
498
759
|
invite: () => (invite && invite.expiresAt > now() ? invite : null),
|
|
499
760
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
@@ -524,21 +785,10 @@ export function createEngine({
|
|
|
524
785
|
// told no again rather than becoming a row somebody has to answer
|
|
525
786
|
// twice. The socket sends the reason; this only knows the name.
|
|
526
787
|
declined: fp => declined.has(fp),
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
// nothing else: the same pin, from the same key this handshake just
|
|
532
|
-
// proved, so the deck is trusted on its next attempt a few seconds
|
|
533
|
-
// later exactly as it would be if a person had pressed it. Nothing
|
|
534
|
-
// about the wire changes — this connection is still refused, because
|
|
535
|
-
// trust is read fresh per connection.
|
|
536
|
-
//
|
|
537
|
-
// A deck already told no does NOT come back this way: lan-socket
|
|
538
|
-
// refuses it before this is ever called.
|
|
539
|
-
if (cfg.autoAccept) { self.accept(entry.fp); return; }
|
|
540
|
-
if (!had) onChange?.();
|
|
541
|
-
},
|
|
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,
|
|
542
792
|
});
|
|
543
793
|
let port;
|
|
544
794
|
try {
|
|
@@ -572,7 +822,9 @@ export function createEngine({
|
|
|
572
822
|
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
573
823
|
// would be thirty seconds of asking; and never one this deck's owner
|
|
574
824
|
// already turned away.
|
|
575
|
-
|
|
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; }
|
|
576
828
|
// Only a deck that is new to us is news. A beacon every thirty
|
|
577
829
|
// seconds from one already on the list is not a reason to redraw.
|
|
578
830
|
if (!had) onChange?.();
|
|
@@ -660,6 +912,15 @@ export function createEngine({
|
|
|
660
912
|
host: at.addr, port: at.port, timeoutMs: ROUND_MS,
|
|
661
913
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
662
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,
|
|
663
924
|
});
|
|
664
925
|
const { list } = addTrusted(cfg.trusted, {
|
|
665
926
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
@@ -693,7 +954,7 @@ export function createEngine({
|
|
|
693
954
|
* thing. A fingerprint nobody has actually met is refused rather than
|
|
694
955
|
* trusted on a name somebody typed.
|
|
695
956
|
*/
|
|
696
|
-
accept(fp) {
|
|
957
|
+
accept(fp, { byHand = true } = {}) {
|
|
697
958
|
const asked = pending.get(fp) ?? null;
|
|
698
959
|
const heard = strangers.get(fp) ?? null;
|
|
699
960
|
const seen = asked ?? heard;
|
|
@@ -712,7 +973,12 @@ export function createEngine({
|
|
|
712
973
|
// is the same two presses, in the other order.
|
|
713
974
|
if (!seen.pub) {
|
|
714
975
|
if (!seen.addr || !seen.port) return null;
|
|
715
|
-
|
|
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 });
|
|
716
982
|
strangers.delete(fp);
|
|
717
983
|
onChange?.();
|
|
718
984
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
@@ -784,22 +1050,83 @@ export function createEngine({
|
|
|
784
1050
|
*
|
|
785
1051
|
* `roundAt` is left alone: it says when EVERY paired deck was last asked,
|
|
786
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.
|
|
787
1083
|
*/
|
|
788
1084
|
async roundOne(fp) {
|
|
789
1085
|
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
790
1086
|
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
791
1087
|
const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
|
|
792
1088
|
const peer = heard ?? typed;
|
|
793
|
-
|
|
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) : []));
|
|
794
1096
|
},
|
|
795
1097
|
/** Dial this address on every round from now on. Returns false for an
|
|
796
1098
|
* address that is not one, rather than storing a row that can never
|
|
797
1099
|
* connect and reports an error every minute forever. */
|
|
798
|
-
addPeer(addr, port) {
|
|
1100
|
+
addPeer(addr, port, { typed = true } = {}) {
|
|
799
1101
|
const p = Number(port);
|
|
800
1102
|
if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
|
|
801
1103
|
const host = addr.trim();
|
|
802
|
-
|
|
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
|
+
});
|
|
803
1130
|
return true;
|
|
804
1131
|
},
|
|
805
1132
|
removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
|
|
@@ -835,6 +1162,8 @@ export function createEngine({
|
|
|
835
1162
|
// arrives is answered here or answered for you.
|
|
836
1163
|
autoAsk: !!cfg.autoAsk,
|
|
837
1164
|
autoAccept: !!cfg.autoAccept,
|
|
1165
|
+
// Whether paired decks are told which shared account this one is on.
|
|
1166
|
+
shareActive: cfg.shareActive !== false,
|
|
838
1167
|
fp: identity?.fp ?? null,
|
|
839
1168
|
// The address and port a person on another subnet types into the other
|
|
840
1169
|
// deck's field. Null when this machine has no ordinary one, which the
|
|
@@ -932,6 +1261,11 @@ export function createEngine({
|
|
|
932
1261
|
// and this is when. Undefined until it has, which is a row the
|
|
933
1262
|
// panel draws as unknown rather than as live.
|
|
934
1263
|
lastSeen: spokeAt.get(t.fp),
|
|
1264
|
+
// AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
|
|
1265
|
+
// of those it is on. The card was kept and never handed over, so
|
|
1266
|
+
// the dialog said "it runs an older version" about a deck that
|
|
1267
|
+
// had just told it exactly which version it runs.
|
|
1268
|
+
...card(t.fp),
|
|
935
1269
|
});
|
|
936
1270
|
}
|
|
937
1271
|
return rows;
|