agent-dag 3.22.1 → 3.22.3

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,941 +0,0 @@
1
- // The rules for finding other decks on this network and agreeing who is in the
2
- // group. No sockets, no timers, no filesystem — those live in lan-socket.mjs,
3
- // and everything here is a pure function so the suite can run all of it on one
4
- // machine, which is the only machine there is.
5
- //
6
- // WHAT THIS IS FOR. An account's login dies on a machine that has not used it
7
- // for a while, while the same account stays alive on a machine that has. Today
8
- // the fix is a blob copied out of one deck and pasted into another. This is
9
- // that, without the copying: decks on one network find each other, agree they
10
- // belong to the same group, say which accounts they hold and how fresh each
11
- // one is, and hand over the fresher copy.
12
- //
13
- // WHAT IT IS NOT. Not a way to keep one account working on two machines at
14
- // once — a quota is the account's, not the machine's, so a second machine
15
- // buys no capacity. Not a backup. Not a way to reach a deck across the
16
- // internet: everything here is link-local by construction.
17
- //
18
- // ── the shape of it, and where each piece came from ──────────────────────────
19
- //
20
- // Three shipped systems solve nearly this problem and were read before any of
21
- // it was written, because the failure modes are all in the parts that look
22
- // simple.
23
- //
24
- // SYNCTHING (docs.syncthing.net/specs/localdisco-v4.html) announces on UDP
25
- // with NO authentication in the announcement at all, and puts the trust
26
- // somewhere else entirely: the announcement's only identity claim is a hash of
27
- // the device's long-term key. That is exactly right and it is what this does.
28
- // An announcement is a shout in a room; anything it asserts is a claim by
29
- // whoever shouted.
30
- //
31
- // It also broadcasts on IPv4 rather than multicasting — 255.255.255.255, port
32
- // 21027 — and multicasts only on IPv6. That looked like an oddity and is not:
33
- // consumer switches and access points forward broadcast where they drop
34
- // unregistered multicast groups, so the "modern" choice is the one that fails
35
- // on more real networks. Copied.
36
- //
37
- // And it re-announces IMMEDIATELY on start rather than waiting for the next
38
- // interval, so a deck that just came up appears at once instead of up to a
39
- // minute later. Copied.
40
- //
41
- // LOCALSEND (github.com/localsend/protocol) is the closest thing to this that
42
- // people actually run. Its fingerprint is a hash of a long-term key, used both
43
- // to recognise a peer across restarts and to ignore its own announcements —
44
- // the second of which is not obvious until you watch a deck discover itself.
45
- // Copied.
46
- //
47
- // Its other lesson is about WHERE a check goes. The group secret authenticates
48
- // the channel; LocalSend still puts a separate PIN on the transfer endpoint
49
- // itself, so the sensitive operation carries its own proof rather than
50
- // inheriting one from a session that may be old. See `transferChallenge`.
51
- //
52
- // KDE CONNECT had CVE-2020-26164: several issues in the daemon that listens on
53
- // the LAN, in a design whose protocol was fine. The lesson is not "do TLS
54
- // better", it is that a process listening on a hostile network must do as
55
- // close to nothing as possible before it knows who it is talking to. So
56
- // `readBeacon` refuses on length before it parses, refuses on magic before it
57
- // reads a field, and never allocates anything sized by the packet.
58
- //
59
- // ── what is deliberately NOT here ───────────────────────────────────────────
60
- //
61
- // NO SUBNET SCAN. LocalSend falls back to POSTing to every address on the
62
- // subnet when multicast finds nobody, and it is a defensible choice for
63
- // consumer software. It is a port scan, which on a corporate network is the
64
- // thing that gets a machine quarantined by the very systems that exist to
65
- // notice it. A manual address covers the routed and VPN cases that a scan
66
- // cannot reach anyway, and it costs one text field.
67
- //
68
- // PER-PEER PAIRING, AND THIS REVERSES AN EARLIER DECISION. The first version
69
- // used one group passphrase, on the argument that a fleet of n decks should not
70
- // cost n² pairings. That argument is sound and it was answering the wrong
71
- // question. What two people actually hit on real hardware was this: a
72
- // passphrase that differs by one character produces a closed socket and no
73
- // other symptom, on both machines, with the panel unable to tell them apart
74
- // from a firewall — because a secret is the one value the panel must never
75
- // print, so neither person can check theirs against the other's.
76
- //
77
- // A deck is now identified by its own long-term key, and a peer is somebody
78
- // this deck has been told to trust: an address typed into the panel, or an
79
- // incoming connection somebody pressed accept on. Nothing is shared before that
80
- // press, and the press is a decision about a named machine at a named address
81
- // rather than about a string neither person can see.
82
- //
83
- // TRUST ON FIRST USE, and said plainly rather than implied. The first
84
- // connection to a fingerprint is taken on faith and pinned; every one after it
85
- // is checked against the pin. That stops a stranger replacing a paired deck and
86
- // does not stop somebody standing in the middle of the very first exchange. The
87
- // answer to that is comparing fingerprints out of band, which the panel shows
88
- // and which is the next thing to build.
89
- //
90
- // NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
91
- // timingSafeEqual, all of which this needs and none of which it should be
92
- // implementing. The one PAKE package on npm was last published in 2022, has 37
93
- // downloads a week and four dependencies of its own; running unmaintained
94
- // cryptography to protect live credentials is worse than the plain construction
95
- // below.
96
- import {
97
- createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey,
98
- createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes,
99
- timingSafeEqual,
100
- } from "node:crypto";
101
- import os from "node:os";
102
-
103
- /** Marks a packet as ours before anything reads a field of it. Four bytes of
104
- * ASCII rather than a number, so a stray packet on the port is recognisable
105
- * in a capture as "something else's" rather than as a corrupt one of ours. */
106
- export const MAGIC = "CCDK";
107
-
108
- /** The wire format. Bumped when a field changes meaning, never when one is
109
- * added — a reader that does not know a field ignores it, which is what makes
110
- * a deck a version behind still discoverable. */
111
- export const PROTOCOL = 2;
112
-
113
- /** The most a beacon may be. A beacon is a name, a port and two hashes; the
114
- * worst realistic case is under 300 bytes. Refusing at 512 before parsing is
115
- * the cheapest possible answer to a packet built to be expensive to read. */
116
- export const MAX_BEACON_BYTES = 512;
117
-
118
- /** How often a deck announces itself, and how long a peer is remembered as
119
- * present after its last one.
120
- *
121
- * 30 seconds is Syncthing's floor and it is chosen for the same reason: a
122
- * quiet network should not carry more of this than it has to, and a deck that
123
- * went away is not urgent news. `PRESENT_MS` is three intervals, so two lost
124
- * packets do not make a live deck flicker out of the list. */
125
- export const ANNOUNCE_MS = 30_000;
126
- export const PRESENT_MS = ANNOUNCE_MS * 3 + 5_000;
127
-
128
- /** Longest a display name may be, in characters rather than bytes so the limit
129
- * means the same thing in every script. A name is chosen by whoever is
130
- * shouting, so it is untrusted text: capped here, and never rendered as
131
- * anything but text at the other end. */
132
- export const MAX_NAME = 40;
133
-
134
- /** Longest a manifest may be. Fifty accounts, each an email, an org id and a
135
- * few numbers, comes to roughly 12 KB; this is an order of magnitude over
136
- * that and still nothing to allocate by accident. */
137
- export const MAX_MANIFEST_BYTES = 128 * 1024;
138
-
139
- // ── identity ────────────────────────────────────────────────────────────────
140
-
141
- /**
142
- * What a deck calls itself on the wire, given its long-term public key.
143
- *
144
- * A hash of the key rather than the key: it is short enough to read out over a
145
- * phone, it is stable across restarts, and — the part that matters — it is the
146
- * only identity claim in a beacon that cannot be forged, because a deck that
147
- * cannot do the handshake cannot use somebody else's fingerprint for anything.
148
- *
149
- * Twelve hex characters, in threes. 48 bits is far past what an accident
150
- * reaches on one network, and a person can compare four groups at a glance
151
- * where they cannot compare sixty-four characters. Syncthing and LocalSend both
152
- * make the same trade at a similar length.
153
- */
154
- export function fingerprint(publicKeyRaw) {
155
- const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
156
- return hex.replace(/(.{3})(?=.)/g, "$1-");
157
- }
158
-
159
- /**
160
- * The name a deck shows, cleaned.
161
- *
162
- * Control characters go, because this string is written into a log and read in
163
- * a terminal as well as rendered in a page, and a name carrying an escape
164
- * sequence is a name that can move a cursor. Whitespace collapses so a peer
165
- * cannot pad itself into looking like two entries. Empty falls back to the
166
- * fingerprint, which is never empty and is the honest answer for a deck that
167
- * did not say who it was.
168
- */
169
- export function cleanName(raw, fallback = "unnamed deck") {
170
- if (typeof raw !== "string") return fallback;
171
- // Escape sequences and NUL, spelled by code point rather than by literal:
172
- // this string reaches a terminal log as well as a page, and a name carrying
173
- // an ANSI escape can move a cursor or repaint a line. A literal control
174
- // character in the source would be invisible to whoever reads this next.
175
- const stripped = raw
176
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
177
- .replace(/\s+/g, " ")
178
- .trim();
179
- return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
180
- }
181
-
182
- // ── this deck's own key ─────────────────────────────────────────────────────
183
-
184
- /**
185
- * Turn a passphrase into the key everything else hangs off.
186
- *
187
- * scrypt rather than a plain hash, and the parameters are the point: this is a
188
- * passphrase a person typed, on a network where anybody can capture the
189
- * handshake and grind at it offline. N=2^15 costs about 100ms and 32 MB per
190
- * guess here, which is nothing once per deck start and is the difference
191
- * between a weak passphrase falling in seconds and falling in weeks.
192
- *
193
- * The salt is FIXED and that is deliberate, not an oversight. A per-deck salt
194
- * would mean two decks with the same passphrase deriving different keys, which
195
- * is the one thing this must never do. What a salt buys — that one rainbow
196
- * table cannot cover every deployment — is bought instead by the passphrase
197
- * being generated at 128 bits by default; a table against a random 128-bit
198
- * secret is not a thing that exists.
199
- */
200
- /**
201
- * A deck's own long-term identity.
202
- *
203
- * X25519, because the only thing it is ever used for is agreeing a session key
204
- * with a peer — never a signature — and node has it built in. The private half
205
- * lives in prefs.json, which is written 0600 for exactly this reason; the
206
- * public half is what a peer pins, and its fingerprint is what a person
207
- * compares.
208
- */
209
- export function newKeypair() {
210
- const { publicKey, privateKey } = generateKeyPairSync("x25519");
211
- return {
212
- secret: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
213
- pub: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
214
- };
215
- }
216
-
217
- /**
218
- * The keypair on disk, or a fresh one when there is none or it is unusable.
219
- *
220
- * A corrupt secret is not an error anybody can act on mid-session, and refusing
221
- * to start would take the whole feature away over one bad string — so it is
222
- * replaced. The cost of replacing it is stated rather than hidden: this deck
223
- * gets a new fingerprint, so every peer that had pinned the old one sees a
224
- * stranger and asks its owner to accept again.
225
- */
226
- export function identityFrom(secret) {
227
- if (typeof secret === "string" && secret !== "") {
228
- try {
229
- const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
230
- const pub = createPublicKey(priv).export({ type: "spki", format: "der" });
231
- return { secret, pub: pub.toString("base64"), fp: fingerprint(pub), fresh: false };
232
- } catch { /* unusable; fall through and make one */ }
233
- }
234
- const made = newKeypair();
235
- return { ...made, fp: fingerprint(Buffer.from(made.pub, "base64")), fresh: true };
236
- }
237
-
238
- /**
239
- * The key two decks use for one connection, and for nothing else.
240
- *
241
- * X25519 to a shared secret, then HKDF over the transcript — both fingerprints
242
- * and both challenges, in a fixed order — so the key is bound to THIS exchange.
243
- * A recording of an old one derives a different key and proves nothing.
244
- *
245
- * PER CONNECTION, never stored. The old design sealed credentials under one
246
- * long-lived group key, which meant a single recorded transfer stayed readable
247
- * to anybody who ever learned the passphrase. This gives forward secrecy for
248
- * free: the ephemeral halves are gone when the socket is.
249
- */
250
- export function sessionKey(secret, peerPub, transcript) {
251
- const priv = createPrivateKey({ key: Buffer.from(secret, "base64"), format: "der", type: "pkcs8" });
252
- const theirs = createPublicKey({ key: Buffer.from(peerPub, "base64"), format: "der", type: "spki" });
253
- const shared = diffieHellman({ privateKey: priv, publicKey: theirs });
254
- return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0),
255
- Buffer.from(`ccdeck-lan-v${PROTOCOL}|${transcript}`, "utf8"), 32));
256
- }
257
-
258
- /**
259
- * The string both sides bind their session key to.
260
- *
261
- * One definition, used by the caller and the listener, because a transcript the
262
- * two build differently is a handshake that never agrees and a bug that only
263
- * appears between two machines. Caller first, always, so the order does not
264
- * depend on which end is asking.
265
- */
266
- export function handshakeTranscript(callerFp, listenerFp, callerChallenge, listenerChallenge) {
267
- return `${callerFp}|${listenerFp}|${callerChallenge}|${listenerChallenge}`;
268
- }
269
-
270
- /**
271
- * A public key somebody sent, or null.
272
- *
273
- * It arrives from the network before anything has been agreed, so it is checked
274
- * for being an X25519 public key at all rather than trusted to be one — a
275
- * string that is not gets a refusal here instead of a throw three frames later.
276
- */
277
- export function readPub(raw) {
278
- if (typeof raw !== "string" || raw.length < 40 || raw.length > 128) return null;
279
- try {
280
- const der = Buffer.from(raw, "base64");
281
- createPublicKey({ key: der, format: "der", type: "spki" });
282
- return { pub: raw, fp: fingerprint(der) };
283
- } catch { return null; }
284
- }
285
-
286
- // ── the invite ──────────────────────────────────────────────────────────────
287
- //
288
- // ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
289
- // afternoon. Two people, two machines, a typed address, and `handshake timed
290
- // out` — because the address on screen was the LAN one and the only route
291
- // between them was a VPN. Neither of them could have known which of the two to
292
- // use; the deck could not know either, and printing both made it their problem.
293
- //
294
- // So the invite carries EVERY address this deck has, and the deck that receives
295
- // it tries them in turn. Nobody has to know which one is routable, because the
296
- // only machine that can find out is the one doing the reaching.
297
- //
298
- // AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
299
- // proves it holds the invite is not a stranger asking to be let in — it is
300
- // somebody the owner of this machine handed a token to. So it is paired on
301
- // arrival and nobody presses accept. That also closes the gap trust-on-first-use
302
- // left open: the first contact is verified rather than believed.
303
- //
304
- // THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
305
- // can pair with this deck until it expires. It is a door key with a timer, not
306
- // an identifier — which is why it is short-lived, single-purpose, and replaced
307
- // the moment it is used.
308
-
309
- /** How long an invite is good for. Long enough to paste into a chat and have
310
- * somebody read it, short enough that one left in a channel is not a way in
311
- * tomorrow. */
312
- export const INVITE_MS = 10 * 60 * 1000;
313
-
314
- /** The most addresses an invite may carry. A machine with a VPN, a second card
315
- * and a container bridge has three or four; ten is far past honest and keeps a
316
- * hand-built token from being a way to make this deck dial a list. */
317
- export const MAX_INVITE_ADDRS = 10;
318
-
319
- /** What the token starts with, so a reader can tell at a glance what they have
320
- * been sent and a wrong paste is refused before it is parsed. */
321
- export const INVITE_PREFIX = "ccdeck1.";
322
-
323
- const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
324
- const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
325
-
326
- /**
327
- * A code somebody could read out loud if they had to.
328
- *
329
- * Six digits, from rejection sampling rather than modulo — the same argument
330
- * the word list made and for the same reason: a bias here is a bias in the one
331
- * number that decides whether a stranger can pair.
332
- */
333
- export function inviteCode(rand = randomBytes) {
334
- let out = "";
335
- while (out.length < 6) {
336
- for (const b of rand(12)) {
337
- if (b >= 250) continue; // 250 = 25 * 10, the largest clean multiple
338
- out += String(b % 10);
339
- if (out.length === 6) break;
340
- }
341
- }
342
- return out;
343
- }
344
-
345
- /** Mint one. `addrs` are already `host:port` strings, because which addresses
346
- * this machine has is not a question this file can answer. */
347
- export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
348
- const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
349
- if (!list.length) return null;
350
- const expiresAt = now + INVITE_MS;
351
- const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt };
352
- return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
353
- }
354
-
355
- /**
356
- * Read one somebody pasted, or null.
357
- *
358
- * REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
359
- * then the length, then the decode, then the shape, then the clock. A token is
360
- * text a person pasted out of a chat window, so it is as untrusted as anything
361
- * that arrives on a socket — and every failure here is the same answer to the
362
- * reader, which is "that is not an invite".
363
- */
364
- export function readInvite(raw, now = Date.now()) {
365
- if (typeof raw !== "string") return null;
366
- const text = raw.trim();
367
- if (!text.startsWith(INVITE_PREFIX)) return null;
368
- if (text.length > 2048) return null;
369
- let body = null;
370
- try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
371
- catch { return null; }
372
- if (!body || typeof body !== "object" || Array.isArray(body)) return null;
373
- if (body.v !== PROTOCOL) return null;
374
- if (typeof body.c !== "string" || !/^[0-9]{6}$/.test(body.c)) return null;
375
- if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
376
- // Expired is its own answer and the caller says so — "that invite has run
377
- // out, ask for a new one" is a different instruction from "that is not an
378
- // invite", and a reader who cannot tell them apart retypes the same thing.
379
- const expired = body.x <= now;
380
- const addrs = (Array.isArray(body.a) ? body.a : [])
381
- .filter(a => typeof a === "string")
382
- .slice(0, MAX_INVITE_ADDRS)
383
- .map(a => ({ raw: a, at: a.lastIndexOf(":") }))
384
- .filter(p => p.at > 0)
385
- .map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
386
- .filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
387
- if (!addrs.length) return null;
388
- return { addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired };
389
- }
390
-
391
- /**
392
- * The proof that a caller is holding the invite, over the same transcript the
393
- * session key is bound to.
394
- *
395
- * Not the code itself on the wire. A code that travelled in the clear would be
396
- * replayable by anybody who watched one exchange, and this is the value that
397
- * decides whether a deck is paired without anybody pressing anything.
398
- */
399
- export function inviteProof(code, transcript) {
400
- return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
401
- }
402
-
403
- // ── the beacon ──────────────────────────────────────────────────────────────
404
-
405
- /**
406
- * What a deck shouts, which anybody on the network can hear.
407
- *
408
- * NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
409
- * receives, so what is in it is what a stranger learns: that a ccdeck is here,
410
- * what it calls itself, and a hash of its public key. Not which Anthropic
411
- * accounts exist on the machine, not how many, and nothing that could be ground
412
- * at offline, because there is nothing in it derived from a secret.
413
- *
414
- * IT NO LONGER CARRIES A GROUP TAG, and that is the shape of the whole feature
415
- * changing rather than a field going away. The tag let a deck drop a packet
416
- * from outside its group before any handshake existed to attack, which is a
417
- * real property and was worth having — but it only worked when both people
418
- * held the same passphrase, and a passphrase neither of them can see is exactly
419
- * what nobody could get right. What replaces it is later and cheaper: a beacon
420
- * from a deck this one does not trust becomes a row somebody can accept, and
421
- * nothing at all happens until they do.
422
- */
423
- /**
424
- * WHICH MACHINE THIS IS, as opposed to which PROCESS or which KEY.
425
- *
426
- * Reported by somebody looking at a colleague's screen: "I appear three or four
427
- * times". Every one of those rows was honest — a deck's identity is its key, a
428
- * second deck sharing a config directory is told to take a fresh one
429
- * (`id-clash`), and a deck heard yesterday stays listed for a day — so a
430
- * machine that has run the deck a few times becomes a column of itself on
431
- * everybody else's panel, under one hostname, offering to pair with each.
432
- *
433
- * A key cannot answer "same machine?" and was never meant to. This can: it is
434
- * derived rather than stored, so two processes on one computer agree without
435
- * coordinating and a first run needs nothing written; and it is HASHED, because
436
- * a hostname and a home directory carry a person's name and this goes out in
437
- * the clear to everyone on the network, thirty seconds apart, forever.
438
- *
439
- * The home directory is in it so that a copied `~/.claude` — which is how two
440
- * real machines end up holding one key, and the reason `id-clash` exists —
441
- * still reads as two machines when the hostnames differ, which they do.
442
- */
443
- export function hostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
444
- return createHash("sha256").update(`${hostname}\u0000${home}`).digest("hex").slice(0, 12);
445
- }
446
-
447
- export function beaconPayload({ name, fp, port, instance, host }) {
448
- return {
449
- m: MAGIC,
450
- v: PROTOCOL,
451
- n: cleanName(name),
452
- f: fp,
453
- p: port,
454
- // Randomised at start. Two beacons from one fingerprint with different
455
- // instance ids mean the deck restarted between them, which is the signal to
456
- // drop whatever session state was held for it rather than trying to resume
457
- // into a process that no longer exists. Syncthing's field, same job.
458
- i: instance,
459
- // Which MACHINE, so several processes on one computer are one row rather
460
- // than one row each. Optional on the wire: a deck older than this sends no
461
- // `h`, and a reader that requires one would stop seeing every deck already
462
- // installed.
463
- ...(host ? { h: host } : {}),
464
- };
465
- }
466
-
467
- /**
468
- * Read a packet off the wire, refusing before understanding.
469
- *
470
- * The order is the whole point and it is the CVE-2020-26164 lesson: length
471
- * first, because that costs nothing; then magic, because that is one string
472
- * compare; then a parse; then the fields. Nothing here allocates anything
473
- * sized by the packet, and a packet that fails at any step produces `null`
474
- * rather than a reason — a listener that explained itself to whoever is
475
- * probing it would be a listener helping them.
476
- */
477
- export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
478
- if (!buf || buf.length === 0 || buf.length > maxBytes) return null;
479
- // One byte before a parse. The payload is JSON, so anything not starting with
480
- // `{` is somebody else's traffic on the port and costs nothing to refuse —
481
- // which on a busy network is most of what arrives.
482
- if (buf[0] !== 0x7b) return null;
483
- let raw;
484
- try { raw = JSON.parse(buf.toString("utf8")); }
485
- catch { return null; }
486
- if (!raw || typeof raw !== "object") return null;
487
- if (raw.m !== MAGIC) return null;
488
- if (raw.v !== PROTOCOL) return null;
489
- // The TYPE as well as the value. `Number("4319")` is a valid port and this
490
- // used to take it, which is leniency of exactly the kind this whole reader
491
- // exists to refuse: a field arriving as a type nobody sends is a field built
492
- // by hand, and the only safe answer to a hand-built packet is no.
493
- if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
494
- const port = raw.p;
495
- if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
496
- if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
497
- // Optional, and refused rather than tolerated when it is malformed: a field
498
- // that decides which rows collapse into one is a field worth being strict
499
- // about. Absent is fine and means "a deck older than this".
500
- if (raw.h !== undefined && (typeof raw.h !== "string" || !/^[0-9a-f]{6,32}$/.test(raw.h))) return null;
501
- return { name: cleanName(raw.n, raw.f), fp: raw.f, port, instance: raw.i, host: raw.h };
502
- }
503
-
504
- /**
505
- * Whether a beacon is one this deck should act on.
506
- *
507
- * Three separate refusals and they mean different things, which is why this
508
- * returns a reason rather than a boolean — the panel says "found a deck that is
509
- * not in your group" where it would otherwise say nothing at all, and "not in
510
- * your group" is the single most useful sentence when somebody has mistyped the
511
- * passphrase on one machine.
512
- *
513
- * Self-recognition is by fingerprint, not by address: a deck hears its own
514
- * broadcast on every interface it owns, and filtering by address would need a
515
- * list of them that changes when a VPN comes up.
516
- */
517
- export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted } = {}) {
518
- if (!beacon) return "unreadable";
519
- // ANOTHER DECK ON THIS COMPUTER. Not this process — a different key, honestly
520
- // its own — and still nothing to pair with: both read one claude-swap store,
521
- // so neither holds a login the other could heal. It used to be filtered by
522
- // the address the packet came from, which is true and needs the list of this
523
- // machine's addresses to be current; the machine's own id needs nothing and
524
- // is the same answer.
525
- if (selfHost && beacon.host && beacon.host === selfHost && beacon.fp !== selfFp) return "self";
526
- if (beacon.fp === selfFp) {
527
- // OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
528
- // directory hold the same key — and so does the second machine when
529
- // somebody copies their ~/.claude across, which people do. Both then file
530
- // every one of the other's beacons as "that is me" and the two are
531
- // permanently invisible to each other, with nothing on screen to say why.
532
- //
533
- // `instance` is what separates the cases: it is fresh per process, so our
534
- // own packet carries the instance we are running and another deck's cannot.
535
- // Told apart here rather than healed here — this function decides, and
536
- // taking a new key is the engine's to do.
537
- //
538
- // AND ONE MACHINE'S OWN TWIN IS NOT A CLASH, which is the whole of a defect
539
- // reported as "Fiodor pressed yes ten times and it keeps asking". Several
540
- // decks on one computer share a config directory, so they start with one
541
- // key; each read the others' beacons as somebody wearing its name, each
542
- // took a fresh key, each wrote that key to the file the others read — and
543
- // the loop never settles. Every new key is a new deck to everybody else on
544
- // the network, so one machine produced a fresh pairing request every few
545
- // seconds, forever, and accepting one accomplished nothing because the deck
546
- // that asked no longer existed by the time the answer arrived.
547
- //
548
- // Two processes on one computer sharing one key is not a problem to heal.
549
- // They read one claude-swap store; there is nothing for either to send the
550
- // other, they never dial each other, and to the rest of the network they
551
- // are one deck — which is exactly what they are. The clash worth healing is
552
- // the OTHER one: a `~/.claude` copied to a second machine, where two real
553
- // decks would otherwise be permanently invisible to each other.
554
- if (selfInstance && beacon.instance !== selfInstance) {
555
- return selfHost && beacon.host === selfHost ? "self" : "id-clash";
556
- }
557
- return "self";
558
- }
559
- // A DECK WE HAVE BEEN TOLD TO TRUST, or one somebody may choose to. There is
560
- // no third answer any more: the group tag used to sort strangers from peers
561
- // before a handshake existed, and now every stranger is a row with a name and
562
- // an address that a person can accept or ignore. Nothing is asked of an
563
- // unknown deck and nothing is offered to it.
564
- return Array.isArray(trusted) && trusted.some(t => t.fp === beacon.fp) ? "peer" : "stranger";
565
- }
566
-
567
- /** Whether this fingerprint is one somebody has already accepted, and what was
568
- * recorded about it — the pinned public key above all, which is what makes a
569
- * second connection from the same fingerprint checkable rather than merely
570
- * claimed. */
571
- export function trustedPeer(trusted, fp) {
572
- return (Array.isArray(trusted) ? trusted : []).find(t => t && t.fp === fp) ?? null;
573
- }
574
-
575
- /**
576
- * Add a deck to the trusted list, or update what is known about one.
577
- *
578
- * THE PINNED KEY NEVER CHANGES UNDER US. A second entry claiming a fingerprint
579
- * we already hold with a different public key is not an update, it is a
580
- * different deck wearing the name — and 48 bits of fingerprint is far past
581
- * accident, so it is somebody trying. The old entry stands and the caller is
582
- * told nothing changed.
583
- */
584
- export function addTrusted(trusted, entry) {
585
- const list = Array.isArray(trusted) ? trusted : [];
586
- if (!entry || typeof entry.fp !== "string" || typeof entry.pub !== "string") return { list, added: false };
587
- const had = trustedPeer(list, entry.fp);
588
- if (had) {
589
- if (had.pub !== entry.pub) return { list, added: false };
590
- return {
591
- list: list.map(t => (t.fp === entry.fp ? { ...t, name: entry.name ?? t.name } : t)),
592
- added: false,
593
- };
594
- }
595
- // WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
596
- // was already trusted keeps whatever date it had, and one pinned before this
597
- // field existed keeps having none rather than being given today's.
598
- const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
599
- return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
600
- }
601
-
602
- /** Take one back out. Unpairing stops what has not happened yet and takes back
603
- * nothing that has — the same sentence the panel says about a shared login,
604
- * and true here for the same reason. */
605
- export function dropTrusted(trusted, fp) {
606
- return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
607
- }
608
-
609
- /**
610
- * The decks worth offering to pair with, out of everything that has ever been
611
- * heard.
612
- *
613
- * THIS LIST WAS A WALL. Every deck ever heard stayed in it forever, and a deck
614
- * that restarts takes a NEW key — so a machine started and stopped seven times
615
- * was seven rows, all with the same name, all at the same address, none of them
616
- * reachable any more. The dialog it filled was unreadable, which is exactly
617
- * what somebody reported.
618
- *
619
- * Three rules, in order:
620
- *
621
- * PRESENT ONLY. A deck that has not been heard for a couple of announce
622
- * intervals is not somewhere you can pair right now, so it is not offered. This
623
- * is the same window the peer table uses, for the same reason.
624
- *
625
- * ONE ROW PER MACHINE. A person reading this sees a name and an address, and
626
- * two rows carrying the same pair are the same machine to them whatever the
627
- * fingerprints say. The freshest wins, because it is the one still running.
628
- *
629
- * NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
630
- * looking for; a list longer than a screen is a list nobody reads.
631
- */
632
- export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mine = [] } = {}) {
633
- // NOT THIS MACHINE. A deck's own beacon is filtered by fingerprint, which is
634
- // right and is not enough: a second deck on the same computer is a different
635
- // process with a different key, so it passes that check honestly and then
636
- // shows up in the list under this machine's own hostname, at this machine's
637
- // own address, offering to pair with itself.
638
- //
639
- // It was reported from a screenshot — "why myself appear here in list" — and
640
- // the answer is that the address is the one thing that cannot lie: a beacon
641
- // arriving FROM an address this machine holds came from this machine. Pairing
642
- // with it would also buy nothing, because both decks read one claude-swap
643
- // store and there is nothing for either to heal.
644
- const own = new Set(Array.isArray(mine) ? mine : []);
645
- const live = [...(strangers ?? [])]
646
- .filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
647
- .filter(p => !own.has(p.addr))
648
- .sort((a, b) => b.at - a.at);
649
- // ONE ROW PER MACHINE. `live` is newest first, so the row that survives is the
650
- // one that spoke most recently — which is the process somebody is actually
651
- // running, rather than a key its computer took and abandoned an hour ago.
652
- //
653
- // The machine's own id when the far deck sends one, and the old name@address
654
- // pair when it does not: a deck older than the `h` field is still collapsed
655
- // as well as it can be rather than not at all.
656
- const seen = new Set();
657
- const out = [];
658
- for (const p of live) {
659
- const key = p.host ? `host:${p.host}` : `${p.name}@${p.addr}`;
660
- if (seen.has(key)) continue;
661
- seen.add(key);
662
- out.push(p);
663
- }
664
- return { shown: out.slice(0, limit), more: Math.max(0, out.length - limit) };
665
- }
666
-
667
- // ── the peer table ──────────────────────────────────────────────────────────
668
-
669
- /**
670
- * Fold one heard beacon into what is known, and say whether anything changed.
671
- *
672
- * PEERS ARE REMEMBERED, NOT MERELY SEEN. A deck that is switched off should
673
- * stay in the list with "last seen yesterday" rather than vanishing, because
674
- * the question the list answers is "who is in my group" and the answer to that
675
- * does not change when a laptop closes. Presence is a field, not membership.
676
- *
677
- * `changed` is returned rather than inferred by the caller so a beacon that
678
- * says nothing new — which is most of them, one every thirty seconds per peer
679
- * forever — costs no render and no write.
680
- */
681
- export function notePeer(peers, beacon, addr, now) {
682
- const prev = peers.get(beacon.fp);
683
- const next = {
684
- fp: beacon.fp,
685
- name: beacon.name,
686
- addr,
687
- port: beacon.port,
688
- instance: beacon.instance,
689
- host: beacon.host,
690
- firstSeen: prev?.firstSeen ?? now,
691
- lastSeen: now,
692
- // Kept across a name change so the panel can say "was Laptop-birou", which
693
- // is what stops a renamed deck reading as a new one that appeared.
694
- prevName: prev && prev.name !== beacon.name ? prev.name : prev?.prevName,
695
- // A manual peer stays manual once found by beacon, so removing it from the
696
- // list means removing the address the user typed rather than waiting for a
697
- // packet that will re-add it.
698
- manual: prev?.manual ?? false,
699
- };
700
- const changed = !prev
701
- || prev.name !== next.name
702
- || prev.addr !== next.addr
703
- || prev.port !== next.port
704
- || prev.instance !== next.instance;
705
- peers.set(beacon.fp, next);
706
- return { peer: next, changed, restarted: !!prev && prev.instance !== next.instance };
707
- }
708
-
709
- /** Whether a peer counts as here right now. Separate from being in the list at
710
- * all — see notePeer. */
711
- export function isPresent(peer, now) {
712
- return peer.lastSeen != null && now - peer.lastSeen < PRESENT_MS;
713
- }
714
-
715
- /** How long a deck stays in the list after its last beacon. See stillListed. */
716
- export const FORGET_MS = 24 * 60 * 60_000;
717
-
718
- /**
719
- * Whether a peer still belongs in the list at all.
720
- *
721
- * The list answers "who is in my group", and that does not change when a laptop
722
- * closes — so a deck heard this morning is still there tonight with the time
723
- * beside it. A day later it is not news that it once existed.
724
- *
725
- * WITHOUT THIS the list is a graveyard, and that was measured rather than
726
- * imagined: on a machine where decks had been restarted a few times, every
727
- * peer's list held a row per restart, each reporting ECONNREFUSED once a minute
728
- * against a port nothing had listened on for an hour. A stable deck id stopped
729
- * new rows appearing; this is what clears the ones that are genuinely gone.
730
- *
731
- * A TYPED ADDRESS IS EXEMPT, and that is the whole reason this is a rule rather
732
- * than a comparison inlined at the call site: an address somebody typed is a
733
- * decision they made, and a deck that has been off for a week is exactly the
734
- * case they typed it for. Only the user takes those off.
735
- */
736
- export function stillListed(peer, now, forgetMs = FORGET_MS) {
737
- if (peer?.manual) return true;
738
- return peer?.lastSeen != null && now - peer.lastSeen < forgetMs;
739
- }
740
-
741
- /**
742
- * The list as the panel shows it: present decks first, then by name.
743
- *
744
- * Not by last-seen within each group, which was the obvious ordering and is
745
- * wrong: a list that reorders itself every thirty seconds as beacons land in
746
- * whatever order the network delivers them is a list nobody can point at.
747
- * Name is stable, and the presence split is the only thing that should move a
748
- * row.
749
- */
750
- export function peerRows(peers, now) {
751
- return [...peers.values()]
752
- .map(p => ({ ...p, present: isPresent(p, now) }))
753
- .sort((a, b) => (Number(b.present) - Number(a.present))
754
- || a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
755
- || a.fp.localeCompare(b.fp));
756
- }
757
-
758
- // ── the handshake ───────────────────────────────────────────────────────────
759
-
760
- /**
761
- * The proof a deck offers to show it holds the group passphrase.
762
- *
763
- * Challenge-response over the derived key rather than sending anything derived
764
- * from the passphrase directly, so a recording of one exchange is worth nothing
765
- * for the next: the challenge is fresh random from the side being convinced.
766
- *
767
- * BOTH SIDES PROVE. The obvious version has the caller prove itself to the
768
- * listener, which stops a stranger reading a manifest and stops nothing else —
769
- * a stranger can still stand up a listener, wait for a real deck to connect,
770
- * and be handed one. So the response covers a challenge from each side and the
771
- * initiator checks the answer with the same care.
772
- *
773
- * The transcript is in the MAC, not just the challenges. Without it the same
774
- * proof is valid on a different port, for a different peer, in a different
775
- * direction — three distinct ways for a recording to be replayed somewhere it
776
- * was not made.
777
- */
778
- export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
779
- return createHmac("sha256", key)
780
- .update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
781
- .digest("hex");
782
- }
783
-
784
- /** Whether a proof is the one expected, compared in constant time for the
785
- * reason `beaconVerdict` gives. Length-checked first because timingSafeEqual
786
- * throws on a mismatch, and a throw here is a crash rather than a refusal. */
787
- export function proofOk(expected, offered) {
788
- if (typeof offered !== "string" || offered.length !== expected.length) return false;
789
- return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
790
- }
791
-
792
- /**
793
- * A fresh challenge for the sensitive operation, separate from the session.
794
- *
795
- * LocalSend's lesson, and the reason this exists rather than the transfer
796
- * riding on the handshake: a session proves who connected, at the moment they
797
- * connected. A credential leaving the machine is a different question, asked
798
- * later, sometimes much later — a long-lived connection that was authenticated
799
- * an hour ago is not a statement about now. So the transfer carries its own
800
- * round trip, over the same key, naming the account being asked for.
801
- */
802
- export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
803
- return createHmac("sha256", key)
804
- .update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
805
- .digest("hex");
806
- }
807
-
808
- // ── the payload ─────────────────────────────────────────────────────────────
809
-
810
- /**
811
- * Wrap a share blob so only the group can read it.
812
- *
813
- * AES-256-GCM over the group key with a random 12-byte nonce. The alternative
814
- * — per-peer keys from the X25519 exchange — is better cryptography and worse
815
- * for this: it would make the encryption depend on a session that the transfer
816
- * check above deliberately does not trust, and the thing being protected is
817
- * already group-wide by definition. Anybody who can decrypt this is somebody
818
- * the passphrase already admits.
819
- *
820
- * The additional data binds the ciphertext to the two decks and the account, so
821
- * a blob captured on one exchange cannot be replayed into another as if it were
822
- * about something else.
823
- */
824
- export function seal(key, plaintext, aad) {
825
- const iv = randomBytes(12);
826
- const c = createCipheriv("aes-256-gcm", key, iv);
827
- c.setAAD(Buffer.from(aad, "utf8"));
828
- const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
829
- return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
830
- }
831
-
832
- /** The other half. Returns null rather than throwing on every failure — a
833
- * wrong tag, a wrong key, a truncated body and a hand-built packet all mean
834
- * the same thing to the caller, which is "do not use this". */
835
- export function open(key, { iv, tag, body }, aad) {
836
- try {
837
- const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
838
- d.setAAD(Buffer.from(aad, "utf8"));
839
- d.setAuthTag(Buffer.from(tag, "base64"));
840
- return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
841
- } catch {
842
- return null;
843
- }
844
- }
845
-
846
- // ── which copy wins ─────────────────────────────────────────────────────────
847
-
848
- /**
849
- * The identity of one account, which is NOT its slot number.
850
- *
851
- * claude-swap keys on `(email, organizationUuid)` — same email under two orgs
852
- * is two accounts on purpose — and assigns slots as max+1 per store, so the
853
- * account that is 4 here is 2 there. Anything keyed on the number would swap
854
- * the wrong pair the first time two stores had grown in a different order.
855
- */
856
- export function accountKey(email, orgUuid) {
857
- return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
858
- }
859
-
860
- /**
861
- * What to do about one account, given what I have and what a peer has.
862
- *
863
- * TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
864
- *
865
- * "add" I do not have this account at all.
866
- * "heal" I have it and claude-swap has quarantined it — refresh token dead,
867
- * verified by an invalid_grant from Anthropic rather than guessed
868
- * from an expiry field — and the peer's copy is alive.
869
- * null Anything else, which is most of the time.
870
- *
871
- * A third outcome was designed and dropped, and the reason is worth keeping
872
- * because it looks like a feature being given up. It was "replace": take a
873
- * peer's copy when it is strictly newer than mine, so the freshest copy wins
874
- * everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
875
- * means the deck opening a credential — something it does not do, claude-swap
876
- * owns that — and on macOS that credential is in the Keychain, which a
877
- * background process cannot reliably read at all.
878
- *
879
- * What settled it is not the obstacle. It is that a working credential replaced
880
- * by a newer working credential changes nothing today. It would only matter if
881
- * mine were about to die — and a login dies from not being used, so if I am not
882
- * using it I do not care, and if I am using it the refresh keeps it alive. The
883
- * whole value is in the account that is already dead.
884
- *
885
- * So this never returns an outcome that needs `cswap import --force`, which
886
- * makes claude-swap's own rule the entire safety property: a plain import skips
887
- * an account that is present and healthy, and replaces exactly one that is
888
- * quarantined. A peer cannot overwrite a credential of mine that works, because
889
- * nothing here ever asks for that.
890
- */
891
- export function syncAction(mine, theirs) {
892
- if (!theirs || !theirs.alive) return null;
893
- if (!mine) return "add";
894
- return mine.alive ? null : "heal";
895
- }
896
-
897
- /**
898
- * Everything to do this round, over one peer's manifest.
899
- *
900
- * Sorted by account key rather than left in manifest order, so two decks
901
- * reconciling the same pair of stores do the same work in the same sequence —
902
- * which is what makes a failure halfway through repeatable rather than a
903
- * different half each time.
904
- */
905
- export function plan(local, remote) {
906
- const mine = new Map(local.map(a => [a.key, a]));
907
- const out = [];
908
- for (const theirs of remote) {
909
- const action = syncAction(mine.get(theirs.key), theirs);
910
- if (action) out.push({ key: theirs.key, email: theirs.email, action });
911
- }
912
- return out.sort((a, b) => a.key.localeCompare(b.key));
913
- }
914
-
915
- /**
916
- * What this deck publishes about its own accounts — to the group, and only to
917
- * the group.
918
- *
919
- * Three fields, and `alive` is the only one that is a judgement: it is
920
- * claude-swap's own verdict on this machine's copy, not a guess. An account
921
- * this deck cannot use is worth nothing to a peer, so saying so plainly is what
922
- * stops a peer asking for it.
923
- *
924
- * Emails are in it, in the clear inside the encrypted channel. That is a
925
- * deliberate line: a manifest only ever reaches a deck that has already proved
926
- * it holds the passphrase, and the panel has to name the account it is offering
927
- * to heal. Hashing the email would buy nothing against that reader and would
928
- * cost the one thing the row needs to say.
929
- *
930
- * `shared` is the user's list. An account absent from it is absent from the
931
- * manifest entirely — not listed as withheld, which would tell the group that
932
- * an account exists and is being kept back, and that is itself the fact being
933
- * kept back.
934
- */
935
- export function manifestFor(accounts, shared) {
936
- const want = new Set(shared);
937
- return accounts
938
- .filter(a => want.has(a.key))
939
- .map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
940
- .sort((a, b) => a.key.localeCompare(b.key));
941
- }