agent-dag 3.13.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-CqAgutJT.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +40 -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/src/server/system-metrics.mjs +12 -6
- package/dist/web/assets/index-DgmJovM0.css +0 -1
- package/dist/web/assets/index-DnhBLSJz.js +0 -235
package/src/server/index.mjs
CHANGED
|
@@ -16,7 +16,9 @@ import { claudeConfigDir } from "./claude-dir.mjs";
|
|
|
16
16
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
17
17
|
import { PRODUCT } from "./brand.mjs";
|
|
18
18
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
|
-
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
19
|
+
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
20
|
+
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
21
|
+
import { suggestPassphrase } from "./lan-sync.mjs";
|
|
20
22
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
21
23
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
22
24
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3169,7 +3171,10 @@ function redactDeckToken(raw) {
|
|
|
3169
3171
|
* same freshness every other cross-deck setting has.
|
|
3170
3172
|
*/
|
|
3171
3173
|
let _prefs = { ...PREF_DEFAULTS };
|
|
3172
|
-
|
|
3174
|
+
// And LAN sync starts from the same read, so a deck that had it on comes back
|
|
3175
|
+
// with it on. `applyLanPrefs` is defined below — this runs after module
|
|
3176
|
+
// evaluation, which is what makes the forward reference fine.
|
|
3177
|
+
readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
|
|
3173
3178
|
|
|
3174
3179
|
const blockNotifier = createBlockNotifier({
|
|
3175
3180
|
notify: osNotify,
|
|
@@ -3197,7 +3202,11 @@ const blockNotifier = createBlockNotifier({
|
|
|
3197
3202
|
function prefsPayload() {
|
|
3198
3203
|
return {
|
|
3199
3204
|
ok: true,
|
|
3200
|
-
|
|
3205
|
+
// `publicPrefs`, never `_prefs`. This route answers anything that can reach
|
|
3206
|
+
// the loopback port, and the LAN group passphrase is in the stored object —
|
|
3207
|
+
// one field, and sending it here would put it in front of every page on the
|
|
3208
|
+
// machine. What goes out is whether one is set.
|
|
3209
|
+
prefs: publicPrefs(_prefs),
|
|
3201
3210
|
notificationsAllowed: notificationsOn(_prefs),
|
|
3202
3211
|
notificationsVetoed: notificationsVetoed(),
|
|
3203
3212
|
};
|
|
@@ -3214,9 +3223,113 @@ async function handlePrefsWrite(req, res) {
|
|
|
3214
3223
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3215
3224
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3216
3225
|
_prefs = await writePrefs(body);
|
|
3226
|
+
// The engine reads its settings from here rather than holding its own copy,
|
|
3227
|
+
// so turning the switch off in the panel really does stop the sockets rather
|
|
3228
|
+
// than only changing what the panel says.
|
|
3229
|
+
await applyLanPrefs();
|
|
3217
3230
|
return send(res, 200, prefsPayload());
|
|
3218
3231
|
}
|
|
3219
3232
|
|
|
3233
|
+
// ── LAN sync ────────────────────────────────────────────────────────────────
|
|
3234
|
+
//
|
|
3235
|
+
// The engine is built once and told the settings; it opens and closes its own
|
|
3236
|
+
// sockets as those change. Nothing here touches a credential — see
|
|
3237
|
+
// lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
|
|
3238
|
+
const lanEngine = createEngine({
|
|
3239
|
+
readAccounts: async () => {
|
|
3240
|
+
const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
|
|
3241
|
+
return fetchClaudeAccounts();
|
|
3242
|
+
},
|
|
3243
|
+
exportAccount: async num => {
|
|
3244
|
+
const { shareAccounts } = await import("./cswap-admin.mjs");
|
|
3245
|
+
const out = await shareAccounts([String(num)]);
|
|
3246
|
+
return out?.ok ? out.blob : null;
|
|
3247
|
+
},
|
|
3248
|
+
importAccount: async blob => {
|
|
3249
|
+
const { importAccount } = await import("./cswap-admin.mjs");
|
|
3250
|
+
// NO `force`, ever, and this is the line where that promise is kept. A
|
|
3251
|
+
// plain import adds an account that is missing and replaces exactly one
|
|
3252
|
+
// claude-swap has quarantined; it SKIPS one that is present and healthy.
|
|
3253
|
+
// So a peer cannot overwrite a working credential of this deck's even by
|
|
3254
|
+
// lying about its own, because the flag that would allow it is not passed.
|
|
3255
|
+
const out = await importAccount(blob);
|
|
3256
|
+
return !!out?.ok;
|
|
3257
|
+
},
|
|
3258
|
+
// The deck's own public id, kept so a restart is the same deck rather than a
|
|
3259
|
+
// new row in every peer's list. Written once, on the first start that has
|
|
3260
|
+
// none — not a secret, and in every beacon this deck sends.
|
|
3261
|
+
onIdentity: async id => {
|
|
3262
|
+
try {
|
|
3263
|
+
_prefs = await writePrefs({ lan: { deckId: id } });
|
|
3264
|
+
// AND PUT IT TO WORK. Writing it alone was not enough: a clash was
|
|
3265
|
+
// detected, a new id was stored, and both decks kept broadcasting the old
|
|
3266
|
+
// one — so they stayed invisible to each other with a correct file on
|
|
3267
|
+
// disk. `apply` restarts only when the id it is holding differs from the
|
|
3268
|
+
// one it is given, so this settles after one pass rather than looping.
|
|
3269
|
+
await applyLanPrefs();
|
|
3270
|
+
} catch { /* the next start picks it up; a shared id is the cost until then */ }
|
|
3271
|
+
},
|
|
3272
|
+
onError: (what, err) => {
|
|
3273
|
+
// Reported, never thrown. A machine with no route, a firewall that refuses
|
|
3274
|
+
// the bind, an interface that comes and goes with a VPN — none of them is a
|
|
3275
|
+
// reason for the deck to fall over.
|
|
3276
|
+
if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
|
|
3277
|
+
},
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
/** Push whatever is in prefs at the engine. Called at boot and after every
|
|
3281
|
+
* write, so there is one source of truth and it is the file. */
|
|
3282
|
+
async function applyLanPrefs() {
|
|
3283
|
+
const lan = _prefs?.lan ?? {};
|
|
3284
|
+
try {
|
|
3285
|
+
await lanEngine.apply({
|
|
3286
|
+
enabled: !!lan.enabled,
|
|
3287
|
+
name: lan.name || defaultName(),
|
|
3288
|
+
passphrase: lan.passphrase || "",
|
|
3289
|
+
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
3290
|
+
deckId: lan.deckId || "",
|
|
3291
|
+
});
|
|
3292
|
+
// Wholesale, so removing an address in the panel really stops it being
|
|
3293
|
+
// dialled rather than only taking the row away.
|
|
3294
|
+
lanEngine.setPeers(lan.manual);
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/** What the panel draws: the switch, this deck's own name and address, and who
|
|
3301
|
+
* else is in the group. No passphrase, for the reason prefsPayload gives. */
|
|
3302
|
+
function handleLanStatus(req, res) {
|
|
3303
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/**
|
|
3307
|
+
* A strong group passphrase, for the field to open with.
|
|
3308
|
+
*
|
|
3309
|
+
* THE WHOLE FEATURE'S SECURITY IS THIS STRING — groupKey scrypts it and
|
|
3310
|
+
* everything else hangs off that — and until this route existed the field
|
|
3311
|
+
* opened empty under a placeholder reading "the same words on every deck",
|
|
3312
|
+
* which is an invitation to type something two people can both remember.
|
|
3313
|
+
* suggestPassphrase has been written, documented and tested since the first
|
|
3314
|
+
* commit of the feature and was reachable from nothing.
|
|
3315
|
+
*
|
|
3316
|
+
* Generated per request rather than folded into the status route, because the
|
|
3317
|
+
* status route is polled every five seconds and a suggestion that changed
|
|
3318
|
+
* under somebody's fingers while they read it out loud would be worse than no
|
|
3319
|
+
* suggestion at all.
|
|
3320
|
+
*/
|
|
3321
|
+
function handleLanSuggest(req, res) {
|
|
3322
|
+
return send(res, 200, { ok: true, passphrase: suggestPassphrase() });
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** Ask every peer now rather than at the next tick — the button beside the
|
|
3326
|
+
* list, for somebody who has just fixed a login on the other machine and does
|
|
3327
|
+
* not want to wait a minute to see it arrive. */
|
|
3328
|
+
async function handleLanSync(req, res) {
|
|
3329
|
+
const done = await lanEngine.round();
|
|
3330
|
+
return send(res, 200, { ok: true, done, ...lanEngine.status() });
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3220
3333
|
function pushEvent(raw, source, opts = {}) {
|
|
3221
3334
|
// First, before anything below can see it: the deck's own credential does not
|
|
3222
3335
|
// belong in a store that is served without one. Every entry point to the
|
|
@@ -5444,6 +5557,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5444
5557
|
// Machine state, not session state: sampled on the server's own timer and
|
|
5445
5558
|
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
5446
5559
|
if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
|
|
5560
|
+
if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
|
|
5561
|
+
if (req.method === "GET" && url.pathname === "/api/lan/passphrase") return handleLanSuggest(req, res);
|
|
5562
|
+
if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
|
|
5447
5563
|
if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
|
|
5448
5564
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
5449
5565
|
// On demand only — the process list costs a subprocess on every platform,
|
|
@@ -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
|
+
}
|