agent-dag 3.14.0 → 3.15.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.
@@ -0,0 +1,359 @@
1
+ // The thing that actually heals an account: the beacon, the listener and
2
+ // claude-swap, wired together.
3
+ //
4
+ // The two files under this one hold everything that can be reasoned about
5
+ // without a network — lan-sync.mjs decides, lan-socket.mjs carries — and what
6
+ // is left here is the part that has to touch the store. It is deliberately the
7
+ // smallest of the three.
8
+ //
9
+ // WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
10
+ //
11
+ // 1. it hears a beacon from a deck with the same group tag
12
+ // 2. it dials that deck's sync port and both sides prove the passphrase
13
+ // 3. it asks for a manifest: which accounts, and does each one work THERE
14
+ // 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
15
+ // 5. it asks for that one account, with a fresh proof naming it
16
+ // 6. the peer runs `cswap export - --account N`, seals it, sends it
17
+ // 7. it opens the envelope and runs `cswap import -`
18
+ //
19
+ // Steps 6 and 7 are the only ones that touch a credential, and neither of them
20
+ // reads one: claude-swap does the reading and the writing, this passes an
21
+ // opaque blob between two of its commands. That is the same division the manual
22
+ // share already uses, which is why this needed no new credential handling at
23
+ // all.
24
+ //
25
+ // NOTHING HAPPENS ON A SCHEDULE THAT MOVES A CREDENTIAL. The manifest round is
26
+ // periodic and carries no credential; the transfer happens when a plan has
27
+ // something in it, which is when an account is actually broken. A deck whose
28
+ // accounts all work talks to its peers every minute and never asks for
29
+ // anything.
30
+ import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
+ import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
+ import { fingerprint, groupKey } from "./lan-sync.mjs";
33
+ import { generateKeyPairSync, randomBytes } from "node:crypto";
34
+ import { hostname, networkInterfaces } from "node:os";
35
+
36
+ /** How often a deck asks its peers what they have. A minute is far more often
37
+ * than a login dies, and it is what makes the panel's list feel live rather
38
+ * than something that updates when you press a button. */
39
+ export const SYNC_MS = 60_000;
40
+
41
+ /** How long one peer round may take before it is abandoned. A manifest is one
42
+ * round trip on a local network; anything past this is a peer that is not
43
+ * going to answer, and holding the attempt open would stall the next round. */
44
+ export const ROUND_MS = 10_000;
45
+
46
+
47
+ /**
48
+ * The name everybody knows this deck by.
49
+ *
50
+ * PERSISTED, and the first version was not. It regenerated on every start, on
51
+ * the argument that a stored key is one more secret to protect — and that
52
+ * argument was about the wrong thing, because this is not a key. Nothing is
53
+ * signed with it and nothing is decrypted with it; the group passphrase is what
54
+ * authenticates, and this is only an identifier, broadcast in the clear in
55
+ * every beacon. There is nothing here to steal.
56
+ *
57
+ * What regenerating it cost was measured rather than guessed: on a machine
58
+ * where decks had been restarted a few times, every peer's list held a row per
59
+ * restart — dozens of them, each reporting ECONNREFUSED every minute against a
60
+ * port nothing has listened on for an hour. Remembered forever and never
61
+ * recognised again is the worst of both, and a stable id is the whole fix.
62
+ */
63
+ export function newIdentity(stored) {
64
+ if (typeof stored === "string" && /^[0-9a-f]{12}$/.test(stored)) {
65
+ return { fp: stored.replace(/(.{3})(?=.)/g, "$1-"), id: stored };
66
+ }
67
+ const { publicKey } = generateKeyPairSync("x25519");
68
+ const raw = publicKey.export({ type: "spki", format: "der" });
69
+ const fp = fingerprint(raw);
70
+ return { fp, id: fp.replace(/-/g, "") };
71
+ }
72
+
73
+ /** What this machine calls itself when the user has not said. The hostname,
74
+ * because that is the word they already use for this machine everywhere else. */
75
+ export function defaultName() {
76
+ return hostname().replace(/\.local$/i, "") || "this machine";
77
+ }
78
+
79
+ /**
80
+ * EVERY address another deck might dial, for the panel to print.
81
+ *
82
+ * It returned the first one, and that was wrong the first time somebody
83
+ * checked: on this machine the first is 192.168.1.82 and the deck it needs to
84
+ * reach is on Tailscale at 100.67.32.58, so the panel would have offered an
85
+ * address that peer cannot route to and left them to work out why.
86
+ *
87
+ * WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
88
+ * answer — a VPN, a second NIC, a container bridge, all real and all at once.
89
+ * So it offers them all and the person picks: they are the only one who knows
90
+ * how the other machine sees this one, and a list of two is a smaller ask than
91
+ * a wrong answer.
92
+ *
93
+ * `internal` is node's word for loopback, and a link-local 169.254 address is a
94
+ * machine whose DHCP failed — reachable by nobody worth telling about.
95
+ */
96
+ export function localAddresses(faces = networkInterfaces()) {
97
+ const out = [];
98
+ for (const list of Object.values(faces ?? {})) {
99
+ for (const n of list ?? []) {
100
+ if (n.internal) continue;
101
+ if (n.family !== "IPv4" && n.family !== 4) continue;
102
+ if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
103
+ if (!out.includes(n.address)) out.push(n.address);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * One deck's LAN sync, from settings to a healed account.
111
+ *
112
+ * `deps` is every side effect: reading accounts, exporting one, importing one.
113
+ * Injected rather than imported so a test can run a whole round — two engines,
114
+ * two fake stores, one real socket pair — without claude-swap on the machine.
115
+ */
116
+ export function createEngine({
117
+ readAccounts, exportAccount, importAccount,
118
+ onChange, onError, onIdentity, now = Date.now,
119
+ } = {}) {
120
+ let cfg = { enabled: false, name: defaultName(), passphrase: "", shared: [], deckId: "" };
121
+ let identity = null;
122
+ let beacon = null;
123
+ let server = null;
124
+ let timer = null;
125
+ let key = null;
126
+ /** What the last round did, for the panel. Not a log: one line per peer, most
127
+ * recent only, because "what happened" is a question about now. */
128
+ const lastRound = new Map();
129
+
130
+ /** This deck's accounts in the shape the rules want. Read through the same
131
+ * function the panel uses, so a row can never be alive here and dead there. */
132
+ const localAccounts = async () => {
133
+ const got = await readAccounts();
134
+ return (got?.accounts ?? []).map(a => ({
135
+ key: accountKey(a.email, a.orgUuid),
136
+ email: a.email,
137
+ alive: a.alive === true,
138
+ num: a.num,
139
+ }));
140
+ };
141
+
142
+ /** Frames from a peer that has proved the passphrase. Nothing reaches this
143
+ * before that, which is the whole point of where the check sits. */
144
+ const serve = async (msg, ctx) => {
145
+ try {
146
+ if (msg.t === "manifest") {
147
+ const accounts = await localAccounts();
148
+ return ctx.send({ t: "manifest", accounts: manifestFor(accounts, cfg.shared) });
149
+ }
150
+ if (msg.t === "want") {
151
+ // A SECOND PROOF, for the one operation that moves a credential. The
152
+ // session says who connected; this says they are asking for this
153
+ // account, now. A long-lived connection authenticated an hour ago is
154
+ // not a statement about now.
155
+ const want = transferChallenge(key, {
156
+ nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
157
+ });
158
+ if (typeof msg.proof !== "string" || msg.proof !== want) {
159
+ return ctx.send({ t: "no", why: "proof" });
160
+ }
161
+ // Only what the user ticked, checked again here rather than trusted
162
+ // from the manifest we sent: the list can change between the two, and
163
+ // the answer that matters is the one at the moment of sending.
164
+ if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
165
+ const accounts = await localAccounts();
166
+ const mine = accounts.find(a => a.key === msg.key);
167
+ if (!mine || !mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
168
+ const blob = await exportAccount(mine.num);
169
+ if (!blob) return ctx.send({ t: "no", why: "export failed" });
170
+ const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
171
+ return ctx.send({ t: "have", key: msg.key, sealed: seal(key, blob, aad) });
172
+ }
173
+ } catch (err) {
174
+ onError?.("serve", err);
175
+ ctx.send({ t: "no", why: "error" });
176
+ }
177
+ };
178
+
179
+ /** Ask one peer what it has, and heal whatever it can heal. */
180
+ const roundWith = async peer => {
181
+ let conn = null;
182
+ try {
183
+ conn = await connectToPeer({ host: peer.addr, port: peer.port, fp: identity.fp, key, timeoutMs: ROUND_MS });
184
+ const ask = frame => new Promise((resolve, reject) => {
185
+ const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
186
+ bell.unref?.();
187
+ let buf = "";
188
+ const onData = chunk => {
189
+ buf += chunk;
190
+ const i = buf.indexOf("\n");
191
+ if (i === -1) return;
192
+ clearTimeout(bell);
193
+ conn.sock.off("data", onData);
194
+ try { resolve(JSON.parse(buf.slice(0, i))); } catch { reject(new Error("bad reply")); }
195
+ };
196
+ conn.sock.on("data", onData);
197
+ sendFrame(conn.sock, frame);
198
+ });
199
+
200
+ const theirs = await ask({ t: "manifest" });
201
+ if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
202
+ const mine = await localAccounts();
203
+ // Only accounts I have also ticked. Sharing is mutual by construction:
204
+ // a peer cannot push an account at me that I never agreed to hold.
205
+ // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
206
+ // Healing replaces a slot I already have, so it is only reasonable for an
207
+ // account I said I share. Adding is the case the owner asked for by name:
208
+ // an account that appears in the group appears everywhere in it, which is
209
+ // the whole of "I do not want to paste blobs any more". The group is
210
+ // passphrase-gated, so what can reach this is what somebody I trusted
211
+ // with that passphrase chose to offer.
212
+ const wanted = plan(mine, theirs.accounts)
213
+ .filter(step => step.action === "add" || cfg.shared.includes(step.key));
214
+ const done = [];
215
+ for (const step of wanted) {
216
+ const nonce = randomBytes(12).toString("hex");
217
+ const reply = await ask({
218
+ t: "want", key: step.key, nonce,
219
+ proof: transferChallenge(key, {
220
+ nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
221
+ }),
222
+ });
223
+ if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
224
+ const blob = open(key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
225
+ if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
226
+ const ok = await importAccount(blob);
227
+ done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
228
+ }
229
+ lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
230
+ if (done.length) onChange?.();
231
+ return done;
232
+ } catch (err) {
233
+ lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
234
+ return [];
235
+ } finally {
236
+ conn?.sock?.destroy();
237
+ }
238
+ };
239
+
240
+ /** Peers the user typed in, which the beacon will never find.
241
+ *
242
+ * Broadcast dies at the first router and is dropped by a switch that
243
+ * filters it, so a deck across a VPN or on another subnet is unreachable by
244
+ * discovery and perfectly reachable by address. The passphrase remains the
245
+ * only gate, so an address is not a way in — it is a way to be dialled.
246
+ *
247
+ * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
248
+ * what a deck says about itself after the handshake and this list has to
249
+ * exist before there has been one. */
250
+ const manual = new Map();
251
+
252
+ const round = async () => {
253
+ if (!beacon) return [];
254
+ const all = [];
255
+ // Heard first, typed second, and a typed one is skipped when the beacon
256
+ // already found that address: otherwise a deck that is both would be dialled
257
+ // twice a round and its work counted twice.
258
+ // The same rule the list uses. A deck that has been silent for a day is not
259
+ // dialled once a minute forever on the chance it comes back.
260
+ const heard = [...beacon.peers.values()].filter(p => stillListed(p, now()));
261
+ const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
262
+ for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
263
+ // Sequential rather than parallel. The store takes one mutation at a
264
+ // time anyway (cswap-admin's lock), and two peers healing the same
265
+ // account at once would race for a slot number claude-swap assigns as
266
+ // max+1 without a lock of its own.
267
+ all.push(...await roundWith(peer));
268
+ }
269
+ return all;
270
+ };
271
+
272
+ return {
273
+ async apply(next) {
274
+ const was = cfg;
275
+ cfg = { ...cfg, ...next };
276
+ const restart = !was.enabled !== !cfg.enabled
277
+ || was.passphrase !== cfg.passphrase
278
+ || was.name !== cfg.name
279
+ || was.deckId !== cfg.deckId;
280
+ if (!restart) return;
281
+ this.stop();
282
+ if (!cfg.enabled || !cfg.passphrase) return;
283
+ key = groupKey(cfg.passphrase);
284
+ identity = newIdentity(cfg.deckId);
285
+ // Hand the caller an id to keep when there was none, so the next start is
286
+ // the same deck rather than a new row in everybody's list.
287
+ if (identity.id !== cfg.deckId) onIdentity?.(identity.id);
288
+ server = createSyncServer({ fp: identity.fp, name: cfg.name, key, handlers: serve, onError });
289
+ const port = await server.start();
290
+ beacon = createBeacon({
291
+ port, name: cfg.name, fp: identity.fp, key,
292
+ onPeer: () => onChange?.(),
293
+ // Take a new name and keep it. Two decks with one id are invisible to
294
+ // each other forever otherwise, and the second one to notice moving is
295
+ // enough — whichever notices first, moves.
296
+ onIdClash: () => {
297
+ const fresh = newIdentity();
298
+ onIdentity?.(fresh.id);
299
+ onError?.("id-clash", new Error("another deck was using this one's id; taking a new one"));
300
+ },
301
+ onError, now,
302
+ });
303
+ await beacon.start();
304
+ timer = setInterval(() => { void round(); }, SYNC_MS);
305
+ timer.unref?.();
306
+ },
307
+ round,
308
+ /** Dial this address on every round from now on. Returns false for an
309
+ * address that is not one, rather than storing a row that can never
310
+ * connect and reports an error every minute forever. */
311
+ addPeer(addr, port) {
312
+ const p = Number(port);
313
+ if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
314
+ const host = addr.trim();
315
+ manual.set(`${host}:${p}`, { fp: `manual:${host}:${p}`, name: host, addr: host, port: p, manual: true });
316
+ return true;
317
+ },
318
+ removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
319
+ /** Replace the typed list wholesale, which is what a settings write means.
320
+ * Adding one at a time would leave a removed address still being dialled
321
+ * every minute until the next restart — the row would vanish from the
322
+ * panel while the socket kept opening, which is the worst of both. */
323
+ setPeers(entries) {
324
+ manual.clear();
325
+ for (const entry of Array.isArray(entries) ? entries : []) {
326
+ const at = String(entry).lastIndexOf(":");
327
+ if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
328
+ }
329
+ return manual.size;
330
+ },
331
+ status() {
332
+ return {
333
+ enabled: !!cfg.enabled,
334
+ running: !!beacon,
335
+ name: cfg.name,
336
+ fp: identity?.fp ?? null,
337
+ // The address and port a person on another subnet types into the other
338
+ // deck's field. Null when this machine has no ordinary one, which the
339
+ // panel says rather than printing a placeholder.
340
+ port: server?.port() ?? null,
341
+ addrs: beacon ? localAddresses() : [],
342
+ shared: [...cfg.shared],
343
+ // The rule is stillListed's, in lan-sync.mjs, where it can be tested.
344
+ peers: beacon ? [...beacon.peers.values(), ...manual.values()]
345
+ .filter(p => stillListed(p, now()))
346
+ .map(p => ({ ...p, last: lastRound.get(p.fp) ?? null })) : [],
347
+ };
348
+ },
349
+ stop() {
350
+ if (timer) clearInterval(timer);
351
+ timer = null;
352
+ beacon?.stop();
353
+ server?.stop();
354
+ beacon = null;
355
+ server = null;
356
+ key = null;
357
+ },
358
+ };
359
+ }