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.
- package/dist/web/assets/index-CbYzt04Y.js +248 -0
- package/dist/web/assets/{index-Xyt_GsRj.css → index-CqAgutJT.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/claude-accounts.mjs +13 -0
- package/src/server/deck-prefs.mjs +68 -4
- package/src/server/index.mjs +119 -3
- package/src/server/lan-engine.mjs +359 -0
- package/src/server/lan-socket.mjs +395 -0
- package/src/server/lan-sync.mjs +620 -0
- package/dist/web/assets/index-CinWEMD2.js +0 -244
|
@@ -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
|
+
}
|