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
|
@@ -0,0 +1,620 @@
|
|
|
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
|
+
// NO PER-PEER PAIRING. A code typed on both machines is what KDE Connect and
|
|
69
|
+
// Syncthing do, and it is the right shape when the two devices are a phone and
|
|
70
|
+
// a laptop meeting once. It is the wrong shape for a fleet: every new deck
|
|
71
|
+
// means a trip to every existing one. One group passphrase, typed once per
|
|
72
|
+
// deck, is the same trust decision made once instead of n² times.
|
|
73
|
+
//
|
|
74
|
+
// NO DEPENDENCY. node:crypto has X25519, HKDF, scrypt, AES-256-GCM and
|
|
75
|
+
// timingSafeEqual, all of which this needs and none of which it should be
|
|
76
|
+
// implementing. The one PAKE package on npm was last published in 2022, has 37
|
|
77
|
+
// downloads a week and four dependencies of its own; running unmaintained
|
|
78
|
+
// cryptography to protect live credentials is worse than the plain construction
|
|
79
|
+
// below.
|
|
80
|
+
import {
|
|
81
|
+
createCipheriv, createDecipheriv, createHash, createHmac,
|
|
82
|
+
randomBytes, scryptSync, timingSafeEqual,
|
|
83
|
+
} from "node:crypto";
|
|
84
|
+
|
|
85
|
+
/** Marks a packet as ours before anything reads a field of it. Four bytes of
|
|
86
|
+
* ASCII rather than a number, so a stray packet on the port is recognisable
|
|
87
|
+
* in a capture as "something else's" rather than as a corrupt one of ours. */
|
|
88
|
+
export const MAGIC = "CCDK";
|
|
89
|
+
|
|
90
|
+
/** The wire format. Bumped when a field changes meaning, never when one is
|
|
91
|
+
* added — a reader that does not know a field ignores it, which is what makes
|
|
92
|
+
* a deck a version behind still discoverable. */
|
|
93
|
+
export const PROTOCOL = 1;
|
|
94
|
+
|
|
95
|
+
/** The most a beacon may be. A beacon is a name, a port and two hashes; the
|
|
96
|
+
* worst realistic case is under 300 bytes. Refusing at 512 before parsing is
|
|
97
|
+
* the cheapest possible answer to a packet built to be expensive to read. */
|
|
98
|
+
export const MAX_BEACON_BYTES = 512;
|
|
99
|
+
|
|
100
|
+
/** How often a deck announces itself, and how long a peer is remembered as
|
|
101
|
+
* present after its last one.
|
|
102
|
+
*
|
|
103
|
+
* 30 seconds is Syncthing's floor and it is chosen for the same reason: a
|
|
104
|
+
* quiet network should not carry more of this than it has to, and a deck that
|
|
105
|
+
* went away is not urgent news. `PRESENT_MS` is three intervals, so two lost
|
|
106
|
+
* packets do not make a live deck flicker out of the list. */
|
|
107
|
+
export const ANNOUNCE_MS = 30_000;
|
|
108
|
+
export const PRESENT_MS = ANNOUNCE_MS * 3 + 5_000;
|
|
109
|
+
|
|
110
|
+
/** Longest a display name may be, in characters rather than bytes so the limit
|
|
111
|
+
* means the same thing in every script. A name is chosen by whoever is
|
|
112
|
+
* shouting, so it is untrusted text: capped here, and never rendered as
|
|
113
|
+
* anything but text at the other end. */
|
|
114
|
+
export const MAX_NAME = 40;
|
|
115
|
+
|
|
116
|
+
/** Longest a manifest may be. Fifty accounts, each an email, an org id and a
|
|
117
|
+
* few numbers, comes to roughly 12 KB; this is an order of magnitude over
|
|
118
|
+
* that and still nothing to allocate by accident. */
|
|
119
|
+
export const MAX_MANIFEST_BYTES = 128 * 1024;
|
|
120
|
+
|
|
121
|
+
// ── identity ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What a deck calls itself on the wire, given its long-term public key.
|
|
125
|
+
*
|
|
126
|
+
* A hash of the key rather than the key: it is short enough to read out over a
|
|
127
|
+
* phone, it is stable across restarts, and — the part that matters — it is the
|
|
128
|
+
* only identity claim in a beacon that cannot be forged, because a deck that
|
|
129
|
+
* cannot do the handshake cannot use somebody else's fingerprint for anything.
|
|
130
|
+
*
|
|
131
|
+
* Twelve hex characters, in threes. 48 bits is far past what an accident
|
|
132
|
+
* reaches on one network, and a person can compare four groups at a glance
|
|
133
|
+
* where they cannot compare sixty-four characters. Syncthing and LocalSend both
|
|
134
|
+
* make the same trade at a similar length.
|
|
135
|
+
*/
|
|
136
|
+
export function fingerprint(publicKeyRaw) {
|
|
137
|
+
const hex = createHash("sha256").update(publicKeyRaw).digest("hex").slice(0, 12);
|
|
138
|
+
return hex.replace(/(.{3})(?=.)/g, "$1-");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The name a deck shows, cleaned.
|
|
143
|
+
*
|
|
144
|
+
* Control characters go, because this string is written into a log and read in
|
|
145
|
+
* a terminal as well as rendered in a page, and a name carrying an escape
|
|
146
|
+
* sequence is a name that can move a cursor. Whitespace collapses so a peer
|
|
147
|
+
* cannot pad itself into looking like two entries. Empty falls back to the
|
|
148
|
+
* fingerprint, which is never empty and is the honest answer for a deck that
|
|
149
|
+
* did not say who it was.
|
|
150
|
+
*/
|
|
151
|
+
export function cleanName(raw, fallback = "unnamed deck") {
|
|
152
|
+
if (typeof raw !== "string") return fallback;
|
|
153
|
+
// Escape sequences and NUL, spelled by code point rather than by literal:
|
|
154
|
+
// this string reaches a terminal log as well as a page, and a name carrying
|
|
155
|
+
// an ANSI escape can move a cursor or repaint a line. A literal control
|
|
156
|
+
// character in the source would be invisible to whoever reads this next.
|
|
157
|
+
const stripped = raw
|
|
158
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
159
|
+
.replace(/\s+/g, " ")
|
|
160
|
+
.trim();
|
|
161
|
+
return stripped ? [...stripped].slice(0, MAX_NAME).join("") : fallback;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── the group secret ────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turn a passphrase into the key everything else hangs off.
|
|
168
|
+
*
|
|
169
|
+
* scrypt rather than a plain hash, and the parameters are the point: this is a
|
|
170
|
+
* passphrase a person typed, on a network where anybody can capture the
|
|
171
|
+
* handshake and grind at it offline. N=2^15 costs about 100ms and 32 MB per
|
|
172
|
+
* guess here, which is nothing once per deck start and is the difference
|
|
173
|
+
* between a weak passphrase falling in seconds and falling in weeks.
|
|
174
|
+
*
|
|
175
|
+
* The salt is FIXED and that is deliberate, not an oversight. A per-deck salt
|
|
176
|
+
* would mean two decks with the same passphrase deriving different keys, which
|
|
177
|
+
* is the one thing this must never do. What a salt buys — that one rainbow
|
|
178
|
+
* table cannot cover every deployment — is bought instead by the passphrase
|
|
179
|
+
* being generated at 128 bits by default; a table against a random 128-bit
|
|
180
|
+
* secret is not a thing that exists.
|
|
181
|
+
*/
|
|
182
|
+
export const GROUP_SALT = "ccdeck-lan-group-v1";
|
|
183
|
+
export const SCRYPT_PARAMS = Object.freeze({ N: 1 << 15, r: 8, p: 1, maxmem: 64 << 20 });
|
|
184
|
+
|
|
185
|
+
export function groupKey(passphrase) {
|
|
186
|
+
if (typeof passphrase !== "string" || passphrase === "") return null;
|
|
187
|
+
return scryptSync(passphrase.normalize("NFKC"), GROUP_SALT, 32, { ...SCRYPT_PARAMS });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A passphrase the deck makes up, so most groups never have a weak one.
|
|
192
|
+
*
|
|
193
|
+
* Words rather than characters, because this gets read aloud across a room and
|
|
194
|
+
* typed on a phone: `amber-canyon-forty-drift` survives that trip where twenty
|
|
195
|
+
* random characters do not. Four words from a 2048-word list is 44 bits, which
|
|
196
|
+
* is thin against an offline grind — so the number is six, for 66 bits, which
|
|
197
|
+
* at scrypt's cost above is beyond reach.
|
|
198
|
+
*
|
|
199
|
+
* The list is short and deliberately boring: no words that differ only by a
|
|
200
|
+
* letter, nothing that sounds like another word over a phone.
|
|
201
|
+
*/
|
|
202
|
+
export const WORDS = Object.freeze([
|
|
203
|
+
"amber", "anchor", "arrow", "basin", "beacon", "birch", "bridge", "bronze",
|
|
204
|
+
"canyon", "cedar", "cinder", "clover", "cobalt", "copper", "coral", "cotton",
|
|
205
|
+
"crater", "crimson", "dawn", "delta", "drift", "ember", "falcon", "fern",
|
|
206
|
+
"flint", "forest", "garnet", "glacier", "granite", "harbor", "hazel", "hollow",
|
|
207
|
+
"indigo", "island", "ivory", "jasper", "juniper", "kettle", "lagoon", "lantern",
|
|
208
|
+
"ledger", "lichen", "linen", "lumber", "marble", "meadow", "mesa", "mineral",
|
|
209
|
+
"monsoon", "mosaic", "nectar", "nickel", "nimbus", "oasis", "onyx", "opal",
|
|
210
|
+
"orchard", "otter", "paddle", "pebble", "pewter", "pigment", "pillar", "pinion",
|
|
211
|
+
"plateau", "prairie", "quarry", "quartz", "ravine", "ribbon", "ridge", "rustic",
|
|
212
|
+
"saffron", "sandbar", "sapphire", "scarlet", "shadow", "shale", "sierra", "silver",
|
|
213
|
+
"solstice", "spruce", "summit", "sunset", "talon", "tandem", "thicket", "thunder",
|
|
214
|
+
"timber", "tundra", "umber", "valley", "velvet", "verdant", "walnut", "willow",
|
|
215
|
+
"window", "winter", "zenith", "zephyr",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
export function suggestPassphrase(words = 6, rand = randomBytes) {
|
|
219
|
+
const out = [];
|
|
220
|
+
// Rejection sampling, so the words are uniform: `% WORDS.length` on a byte
|
|
221
|
+
// would make the first 56 words likelier than the rest, which is a real bias
|
|
222
|
+
// in the one number here that is supposed to be a security claim.
|
|
223
|
+
const limit = Math.floor(256 / WORDS.length) * WORDS.length;
|
|
224
|
+
while (out.length < words) {
|
|
225
|
+
for (const b of rand(words * 2)) {
|
|
226
|
+
if (b >= limit) continue;
|
|
227
|
+
out.push(WORDS[b % WORDS.length]);
|
|
228
|
+
if (out.length === words) break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out.join("-");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── the beacon ──────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* What a deck shouts, which anybody on the network can hear.
|
|
238
|
+
*
|
|
239
|
+
* NO ACCOUNTS AND NO SECRET. This is the packet a stranger on the same wifi
|
|
240
|
+
* receives, so what is in it is what a stranger learns: that a ccdeck is here,
|
|
241
|
+
* what it calls itself, and two hashes. Not which Anthropic accounts exist on
|
|
242
|
+
* the machine, not how many, not the group passphrase, and nothing derived
|
|
243
|
+
* from the passphrase that could be ground at offline.
|
|
244
|
+
*
|
|
245
|
+
* `group` IS derived from the passphrase, and that needs saying plainly: it is
|
|
246
|
+
* HMAC(groupKey, "beacon-group") — a value that only lets two decks in the same
|
|
247
|
+
* group recognise each other. An eavesdropper sees a 64-bit tag; grinding it
|
|
248
|
+
* back to the passphrase means grinding scrypt, which is exactly the cost that
|
|
249
|
+
* function was chosen for. What it buys is that a deck with the wrong
|
|
250
|
+
* passphrase is not asked a single question — the packet is dropped by
|
|
251
|
+
* comparison, before any handshake exists to attack.
|
|
252
|
+
*/
|
|
253
|
+
export function beaconPayload({ name, fp, port, group, instance }) {
|
|
254
|
+
return {
|
|
255
|
+
m: MAGIC,
|
|
256
|
+
v: PROTOCOL,
|
|
257
|
+
n: cleanName(name),
|
|
258
|
+
f: fp,
|
|
259
|
+
p: port,
|
|
260
|
+
g: group,
|
|
261
|
+
// Randomised at start. Two beacons from one fingerprint with different
|
|
262
|
+
// instance ids mean the deck restarted between them, which is the signal to
|
|
263
|
+
// drop whatever session state was held for it rather than trying to resume
|
|
264
|
+
// into a process that no longer exists. Syncthing's field, same job.
|
|
265
|
+
i: instance,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The tag that says "same group as me", without saying what the group is. */
|
|
270
|
+
export function groupTag(key) {
|
|
271
|
+
return createHmac("sha256", key).update("beacon-group").digest("hex").slice(0, 16);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Read a packet off the wire, refusing before understanding.
|
|
276
|
+
*
|
|
277
|
+
* The order is the whole point and it is the CVE-2020-26164 lesson: length
|
|
278
|
+
* first, because that costs nothing; then magic, because that is one string
|
|
279
|
+
* compare; then a parse; then the fields. Nothing here allocates anything
|
|
280
|
+
* sized by the packet, and a packet that fails at any step produces `null`
|
|
281
|
+
* rather than a reason — a listener that explained itself to whoever is
|
|
282
|
+
* probing it would be a listener helping them.
|
|
283
|
+
*/
|
|
284
|
+
export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
285
|
+
if (!buf || buf.length === 0 || buf.length > maxBytes) return null;
|
|
286
|
+
// One byte before a parse. The payload is JSON, so anything not starting with
|
|
287
|
+
// `{` is somebody else's traffic on the port and costs nothing to refuse —
|
|
288
|
+
// which on a busy network is most of what arrives.
|
|
289
|
+
if (buf[0] !== 0x7b) return null;
|
|
290
|
+
let raw;
|
|
291
|
+
try { raw = JSON.parse(buf.toString("utf8")); }
|
|
292
|
+
catch { return null; }
|
|
293
|
+
if (!raw || typeof raw !== "object") return null;
|
|
294
|
+
if (raw.m !== MAGIC) return null;
|
|
295
|
+
if (raw.v !== PROTOCOL) return null;
|
|
296
|
+
// The TYPE as well as the value. `Number("4319")` is a valid port and this
|
|
297
|
+
// used to take it, which is leniency of exactly the kind this whole reader
|
|
298
|
+
// exists to refuse: a field arriving as a type nobody sends is a field built
|
|
299
|
+
// by hand, and the only safe answer to a hand-built packet is no.
|
|
300
|
+
if (typeof raw.p !== "number" || !Number.isInteger(raw.p) || raw.p < 1 || raw.p > 65_535) return null;
|
|
301
|
+
const port = raw.p;
|
|
302
|
+
if (typeof raw.f !== "string" || !/^[0-9a-f]{3}(-[0-9a-f]{3}){3}$/.test(raw.f)) return null;
|
|
303
|
+
if (typeof raw.g !== "string" || !/^[0-9a-f]{16}$/.test(raw.g)) return null;
|
|
304
|
+
if (typeof raw.i !== "string" || !/^[0-9a-f]{8,32}$/.test(raw.i)) return null;
|
|
305
|
+
return { name: cleanName(raw.n, raw.f), fp: raw.f, port, group: raw.g, instance: raw.i };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Whether a beacon is one this deck should act on.
|
|
310
|
+
*
|
|
311
|
+
* Three separate refusals and they mean different things, which is why this
|
|
312
|
+
* returns a reason rather than a boolean — the panel says "found a deck that is
|
|
313
|
+
* not in your group" where it would otherwise say nothing at all, and "not in
|
|
314
|
+
* your group" is the single most useful sentence when somebody has mistyped the
|
|
315
|
+
* passphrase on one machine.
|
|
316
|
+
*
|
|
317
|
+
* Self-recognition is by fingerprint, not by address: a deck hears its own
|
|
318
|
+
* broadcast on every interface it owns, and filtering by address would need a
|
|
319
|
+
* list of them that changes when a VPN comes up.
|
|
320
|
+
*/
|
|
321
|
+
export function beaconVerdict(beacon, { selfFp, selfGroup, selfInstance }) {
|
|
322
|
+
if (!beacon) return "unreadable";
|
|
323
|
+
if (beacon.fp === selfFp) {
|
|
324
|
+
// OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. The id is stored in prefs, so
|
|
325
|
+
// two decks sharing a config directory hold the same one — and so does the
|
|
326
|
+
// second machine when somebody copies their ~/.claude across, which people
|
|
327
|
+
// do. Both then file every one of the other's beacons as "that is me" and
|
|
328
|
+
// the two are permanently invisible to each other, with nothing on screen
|
|
329
|
+
// to say why.
|
|
330
|
+
//
|
|
331
|
+
// `instance` is what separates the cases: it is fresh per process, so our
|
|
332
|
+
// own packet carries the instance we are running and another deck's cannot.
|
|
333
|
+
// Told apart here rather than healed here — this function decides, and
|
|
334
|
+
// regenerating an id is the engine's to do.
|
|
335
|
+
return selfInstance && beacon.instance !== selfInstance ? "id-clash" : "self";
|
|
336
|
+
}
|
|
337
|
+
if (!selfGroup) return "no-group";
|
|
338
|
+
// Constant-time, because this compares a value derived from a secret against
|
|
339
|
+
// one an attacker chooses, once per packet, forever. A byte-at-a-time compare
|
|
340
|
+
// here is a genuine oracle: the network lets them send as many as they like.
|
|
341
|
+
const a = Buffer.from(beacon.group, "hex");
|
|
342
|
+
const b = Buffer.from(selfGroup, "hex");
|
|
343
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return "other-group";
|
|
344
|
+
return "peer";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── the peer table ──────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Fold one heard beacon into what is known, and say whether anything changed.
|
|
351
|
+
*
|
|
352
|
+
* PEERS ARE REMEMBERED, NOT MERELY SEEN. A deck that is switched off should
|
|
353
|
+
* stay in the list with "last seen yesterday" rather than vanishing, because
|
|
354
|
+
* the question the list answers is "who is in my group" and the answer to that
|
|
355
|
+
* does not change when a laptop closes. Presence is a field, not membership.
|
|
356
|
+
*
|
|
357
|
+
* `changed` is returned rather than inferred by the caller so a beacon that
|
|
358
|
+
* says nothing new — which is most of them, one every thirty seconds per peer
|
|
359
|
+
* forever — costs no render and no write.
|
|
360
|
+
*/
|
|
361
|
+
export function notePeer(peers, beacon, addr, now) {
|
|
362
|
+
const prev = peers.get(beacon.fp);
|
|
363
|
+
const next = {
|
|
364
|
+
fp: beacon.fp,
|
|
365
|
+
name: beacon.name,
|
|
366
|
+
addr,
|
|
367
|
+
port: beacon.port,
|
|
368
|
+
instance: beacon.instance,
|
|
369
|
+
firstSeen: prev?.firstSeen ?? now,
|
|
370
|
+
lastSeen: now,
|
|
371
|
+
// Kept across a name change so the panel can say "was Laptop-birou", which
|
|
372
|
+
// is what stops a renamed deck reading as a new one that appeared.
|
|
373
|
+
prevName: prev && prev.name !== beacon.name ? prev.name : prev?.prevName,
|
|
374
|
+
// A manual peer stays manual once found by beacon, so removing it from the
|
|
375
|
+
// list means removing the address the user typed rather than waiting for a
|
|
376
|
+
// packet that will re-add it.
|
|
377
|
+
manual: prev?.manual ?? false,
|
|
378
|
+
};
|
|
379
|
+
const changed = !prev
|
|
380
|
+
|| prev.name !== next.name
|
|
381
|
+
|| prev.addr !== next.addr
|
|
382
|
+
|| prev.port !== next.port
|
|
383
|
+
|| prev.instance !== next.instance;
|
|
384
|
+
peers.set(beacon.fp, next);
|
|
385
|
+
return { peer: next, changed, restarted: !!prev && prev.instance !== next.instance };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Whether a peer counts as here right now. Separate from being in the list at
|
|
389
|
+
* all — see notePeer. */
|
|
390
|
+
export function isPresent(peer, now) {
|
|
391
|
+
return peer.lastSeen != null && now - peer.lastSeen < PRESENT_MS;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** How long a deck stays in the list after its last beacon. See stillListed. */
|
|
395
|
+
export const FORGET_MS = 24 * 60 * 60_000;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Whether a peer still belongs in the list at all.
|
|
399
|
+
*
|
|
400
|
+
* The list answers "who is in my group", and that does not change when a laptop
|
|
401
|
+
* closes — so a deck heard this morning is still there tonight with the time
|
|
402
|
+
* beside it. A day later it is not news that it once existed.
|
|
403
|
+
*
|
|
404
|
+
* WITHOUT THIS the list is a graveyard, and that was measured rather than
|
|
405
|
+
* imagined: on a machine where decks had been restarted a few times, every
|
|
406
|
+
* peer's list held a row per restart, each reporting ECONNREFUSED once a minute
|
|
407
|
+
* against a port nothing had listened on for an hour. A stable deck id stopped
|
|
408
|
+
* new rows appearing; this is what clears the ones that are genuinely gone.
|
|
409
|
+
*
|
|
410
|
+
* A TYPED ADDRESS IS EXEMPT, and that is the whole reason this is a rule rather
|
|
411
|
+
* than a comparison inlined at the call site: an address somebody typed is a
|
|
412
|
+
* decision they made, and a deck that has been off for a week is exactly the
|
|
413
|
+
* case they typed it for. Only the user takes those off.
|
|
414
|
+
*/
|
|
415
|
+
export function stillListed(peer, now, forgetMs = FORGET_MS) {
|
|
416
|
+
if (peer?.manual) return true;
|
|
417
|
+
return peer?.lastSeen != null && now - peer.lastSeen < forgetMs;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The list as the panel shows it: present decks first, then by name.
|
|
422
|
+
*
|
|
423
|
+
* Not by last-seen within each group, which was the obvious ordering and is
|
|
424
|
+
* wrong: a list that reorders itself every thirty seconds as beacons land in
|
|
425
|
+
* whatever order the network delivers them is a list nobody can point at.
|
|
426
|
+
* Name is stable, and the presence split is the only thing that should move a
|
|
427
|
+
* row.
|
|
428
|
+
*/
|
|
429
|
+
export function peerRows(peers, now) {
|
|
430
|
+
return [...peers.values()]
|
|
431
|
+
.map(p => ({ ...p, present: isPresent(p, now) }))
|
|
432
|
+
.sort((a, b) => (Number(b.present) - Number(a.present))
|
|
433
|
+
|| a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
434
|
+
|| a.fp.localeCompare(b.fp));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── the handshake ───────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The proof a deck offers to show it holds the group passphrase.
|
|
441
|
+
*
|
|
442
|
+
* Challenge-response over the derived key rather than sending anything derived
|
|
443
|
+
* from the passphrase directly, so a recording of one exchange is worth nothing
|
|
444
|
+
* for the next: the challenge is fresh random from the side being convinced.
|
|
445
|
+
*
|
|
446
|
+
* BOTH SIDES PROVE. The obvious version has the caller prove itself to the
|
|
447
|
+
* listener, which stops a stranger reading a manifest and stops nothing else —
|
|
448
|
+
* a stranger can still stand up a listener, wait for a real deck to connect,
|
|
449
|
+
* and be handed one. So the response covers a challenge from each side and the
|
|
450
|
+
* initiator checks the answer with the same care.
|
|
451
|
+
*
|
|
452
|
+
* The transcript is in the MAC, not just the challenges. Without it the same
|
|
453
|
+
* proof is valid on a different port, for a different peer, in a different
|
|
454
|
+
* direction — three distinct ways for a recording to be replayed somewhere it
|
|
455
|
+
* was not made.
|
|
456
|
+
*/
|
|
457
|
+
export function proof(key, { challenge, peerChallenge, fromFp, toFp, direction }) {
|
|
458
|
+
return createHmac("sha256", key)
|
|
459
|
+
.update(`ccdeck-lan-v${PROTOCOL}|${direction}|${fromFp}|${toFp}|${challenge}|${peerChallenge}`)
|
|
460
|
+
.digest("hex");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Whether a proof is the one expected, compared in constant time for the
|
|
464
|
+
* reason `beaconVerdict` gives. Length-checked first because timingSafeEqual
|
|
465
|
+
* throws on a mismatch, and a throw here is a crash rather than a refusal. */
|
|
466
|
+
export function proofOk(expected, offered) {
|
|
467
|
+
if (typeof offered !== "string" || offered.length !== expected.length) return false;
|
|
468
|
+
return timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(offered, "utf8"));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A fresh challenge for the sensitive operation, separate from the session.
|
|
473
|
+
*
|
|
474
|
+
* LocalSend's lesson, and the reason this exists rather than the transfer
|
|
475
|
+
* riding on the handshake: a session proves who connected, at the moment they
|
|
476
|
+
* connected. A credential leaving the machine is a different question, asked
|
|
477
|
+
* later, sometimes much later — a long-lived connection that was authenticated
|
|
478
|
+
* an hour ago is not a statement about now. So the transfer carries its own
|
|
479
|
+
* round trip, over the same key, naming the account being asked for.
|
|
480
|
+
*/
|
|
481
|
+
export function transferChallenge(key, { nonce, accountKey, fromFp, toFp }) {
|
|
482
|
+
return createHmac("sha256", key)
|
|
483
|
+
.update(`ccdeck-lan-transfer-v${PROTOCOL}|${fromFp}|${toFp}|${accountKey}|${nonce}`)
|
|
484
|
+
.digest("hex");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── the payload ─────────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Wrap a share blob so only the group can read it.
|
|
491
|
+
*
|
|
492
|
+
* AES-256-GCM over the group key with a random 12-byte nonce. The alternative
|
|
493
|
+
* — per-peer keys from the X25519 exchange — is better cryptography and worse
|
|
494
|
+
* for this: it would make the encryption depend on a session that the transfer
|
|
495
|
+
* check above deliberately does not trust, and the thing being protected is
|
|
496
|
+
* already group-wide by definition. Anybody who can decrypt this is somebody
|
|
497
|
+
* the passphrase already admits.
|
|
498
|
+
*
|
|
499
|
+
* The additional data binds the ciphertext to the two decks and the account, so
|
|
500
|
+
* a blob captured on one exchange cannot be replayed into another as if it were
|
|
501
|
+
* about something else.
|
|
502
|
+
*/
|
|
503
|
+
export function seal(key, plaintext, aad) {
|
|
504
|
+
const iv = randomBytes(12);
|
|
505
|
+
const c = createCipheriv("aes-256-gcm", key, iv);
|
|
506
|
+
c.setAAD(Buffer.from(aad, "utf8"));
|
|
507
|
+
const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
|
|
508
|
+
return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), body: body.toString("base64") };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The other half. Returns null rather than throwing on every failure — a
|
|
512
|
+
* wrong tag, a wrong key, a truncated body and a hand-built packet all mean
|
|
513
|
+
* the same thing to the caller, which is "do not use this". */
|
|
514
|
+
export function open(key, { iv, tag, body }, aad) {
|
|
515
|
+
try {
|
|
516
|
+
const d = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
|
517
|
+
d.setAAD(Buffer.from(aad, "utf8"));
|
|
518
|
+
d.setAuthTag(Buffer.from(tag, "base64"));
|
|
519
|
+
return Buffer.concat([d.update(Buffer.from(body, "base64")), d.final()]).toString("utf8");
|
|
520
|
+
} catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── which copy wins ─────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The identity of one account, which is NOT its slot number.
|
|
529
|
+
*
|
|
530
|
+
* claude-swap keys on `(email, organizationUuid)` — same email under two orgs
|
|
531
|
+
* is two accounts on purpose — and assigns slots as max+1 per store, so the
|
|
532
|
+
* account that is 4 here is 2 there. Anything keyed on the number would swap
|
|
533
|
+
* the wrong pair the first time two stores had grown in a different order.
|
|
534
|
+
*/
|
|
535
|
+
export function accountKey(email, orgUuid) {
|
|
536
|
+
return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* What to do about one account, given what I have and what a peer has.
|
|
541
|
+
*
|
|
542
|
+
* TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
|
|
543
|
+
*
|
|
544
|
+
* "add" I do not have this account at all.
|
|
545
|
+
* "heal" I have it and claude-swap has quarantined it — refresh token dead,
|
|
546
|
+
* verified by an invalid_grant from Anthropic rather than guessed
|
|
547
|
+
* from an expiry field — and the peer's copy is alive.
|
|
548
|
+
* null Anything else, which is most of the time.
|
|
549
|
+
*
|
|
550
|
+
* A third outcome was designed and dropped, and the reason is worth keeping
|
|
551
|
+
* because it looks like a feature being given up. It was "replace": take a
|
|
552
|
+
* peer's copy when it is strictly newer than mine, so the freshest copy wins
|
|
553
|
+
* everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
|
|
554
|
+
* means the deck opening a credential — something it does not do, claude-swap
|
|
555
|
+
* owns that — and on macOS that credential is in the Keychain, which a
|
|
556
|
+
* background process cannot reliably read at all.
|
|
557
|
+
*
|
|
558
|
+
* What settled it is not the obstacle. It is that a working credential replaced
|
|
559
|
+
* by a newer working credential changes nothing today. It would only matter if
|
|
560
|
+
* mine were about to die — and a login dies from not being used, so if I am not
|
|
561
|
+
* using it I do not care, and if I am using it the refresh keeps it alive. The
|
|
562
|
+
* whole value is in the account that is already dead.
|
|
563
|
+
*
|
|
564
|
+
* So this never returns an outcome that needs `cswap import --force`, which
|
|
565
|
+
* makes claude-swap's own rule the entire safety property: a plain import skips
|
|
566
|
+
* an account that is present and healthy, and replaces exactly one that is
|
|
567
|
+
* quarantined. A peer cannot overwrite a credential of mine that works, because
|
|
568
|
+
* nothing here ever asks for that.
|
|
569
|
+
*/
|
|
570
|
+
export function syncAction(mine, theirs) {
|
|
571
|
+
if (!theirs || !theirs.alive) return null;
|
|
572
|
+
if (!mine) return "add";
|
|
573
|
+
return mine.alive ? null : "heal";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Everything to do this round, over one peer's manifest.
|
|
578
|
+
*
|
|
579
|
+
* Sorted by account key rather than left in manifest order, so two decks
|
|
580
|
+
* reconciling the same pair of stores do the same work in the same sequence —
|
|
581
|
+
* which is what makes a failure halfway through repeatable rather than a
|
|
582
|
+
* different half each time.
|
|
583
|
+
*/
|
|
584
|
+
export function plan(local, remote) {
|
|
585
|
+
const mine = new Map(local.map(a => [a.key, a]));
|
|
586
|
+
const out = [];
|
|
587
|
+
for (const theirs of remote) {
|
|
588
|
+
const action = syncAction(mine.get(theirs.key), theirs);
|
|
589
|
+
if (action) out.push({ key: theirs.key, email: theirs.email, action });
|
|
590
|
+
}
|
|
591
|
+
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* What this deck publishes about its own accounts — to the group, and only to
|
|
596
|
+
* the group.
|
|
597
|
+
*
|
|
598
|
+
* Three fields, and `alive` is the only one that is a judgement: it is
|
|
599
|
+
* claude-swap's own verdict on this machine's copy, not a guess. An account
|
|
600
|
+
* this deck cannot use is worth nothing to a peer, so saying so plainly is what
|
|
601
|
+
* stops a peer asking for it.
|
|
602
|
+
*
|
|
603
|
+
* Emails are in it, in the clear inside the encrypted channel. That is a
|
|
604
|
+
* deliberate line: a manifest only ever reaches a deck that has already proved
|
|
605
|
+
* it holds the passphrase, and the panel has to name the account it is offering
|
|
606
|
+
* to heal. Hashing the email would buy nothing against that reader and would
|
|
607
|
+
* cost the one thing the row needs to say.
|
|
608
|
+
*
|
|
609
|
+
* `shared` is the user's list. An account absent from it is absent from the
|
|
610
|
+
* manifest entirely — not listed as withheld, which would tell the group that
|
|
611
|
+
* an account exists and is being kept back, and that is itself the fact being
|
|
612
|
+
* kept back.
|
|
613
|
+
*/
|
|
614
|
+
export function manifestFor(accounts, shared) {
|
|
615
|
+
const want = new Set(shared);
|
|
616
|
+
return accounts
|
|
617
|
+
.filter(a => want.has(a.key))
|
|
618
|
+
.map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
|
|
619
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
620
|
+
}
|
|
@@ -818,7 +818,7 @@ let procInFlightDetail = false;
|
|
|
818
818
|
/**
|
|
819
819
|
* How old a finished reading may be and still answer a caller.
|
|
820
820
|
*
|
|
821
|
-
* Well under
|
|
821
|
+
* Well under MachinePanel's PROC_POLL_MS of 4000, so the panel that this exists
|
|
822
822
|
* for never once gets a cached list; long enough that a second tab, a second
|
|
823
823
|
* browser, or anything else arriving between two of those polls is handed the
|
|
824
824
|
* list the first tab is already looking at rather than starting its own child.
|
|
@@ -1510,6 +1510,11 @@ function seriesFor(group) {
|
|
|
1510
1510
|
}
|
|
1511
1511
|
}
|
|
1512
1512
|
return labels.map(label => ({
|
|
1513
|
+
// The stable name of this reading, which the display label is not: `Swap`
|
|
1514
|
+
// is `Commit` on Windows, and anything joining on what the eye sees
|
|
1515
|
+
// breaks on the one platform nobody re-reads this on. It is the key the
|
|
1516
|
+
// ring is already recorded under, published rather than invented.
|
|
1517
|
+
key: `thermal:${label}`,
|
|
1513
1518
|
label,
|
|
1514
1519
|
unit: label === THROTTLE_LABEL ? "%" : "C",
|
|
1515
1520
|
top: 100,
|
|
@@ -1533,12 +1538,12 @@ function seriesFor(group) {
|
|
|
1533
1538
|
// different machine from twelve at 20.
|
|
1534
1539
|
//
|
|
1535
1540
|
// No bands, deliberately, and the reason is written at the top of
|
|
1536
|
-
//
|
|
1541
|
+
// MachinePanel: a CPU at 90% is the machine doing the work you asked for. An
|
|
1537
1542
|
// indicator that alarms during the normal case teaches you to stop reading
|
|
1538
1543
|
// it.
|
|
1539
1544
|
return [
|
|
1540
|
-
{ label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
|
|
1541
|
-
{ label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
|
|
1545
|
+
{ key: "cpu:all", label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
|
|
1546
|
+
{ key: "cpu:busiest", label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
|
|
1542
1547
|
].filter(s => s.points.length);
|
|
1543
1548
|
}
|
|
1544
1549
|
|
|
@@ -1548,8 +1553,8 @@ function seriesFor(group) {
|
|
|
1548
1553
|
// One band, not two. A `critAt` of 100 draws a rule along the top of a
|
|
1549
1554
|
// chart whose scale ends at 100 — it is the ceiling, drawn again in red,
|
|
1550
1555
|
// and it says nothing the edge did not.
|
|
1551
|
-
{ label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
|
|
1552
|
-
{ label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
|
|
1556
|
+
{ key: "mem:physical", label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
|
|
1557
|
+
{ key: "mem:swap", label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
|
|
1553
1558
|
].filter(s => s.points.length);
|
|
1554
1559
|
}
|
|
1555
1560
|
|
|
@@ -1560,6 +1565,7 @@ function seriesFor(group) {
|
|
|
1560
1565
|
const points = at("load:1m");
|
|
1561
1566
|
if (!points.length) return [];
|
|
1562
1567
|
return [{
|
|
1568
|
+
key: "load:1m",
|
|
1563
1569
|
label: "Queued work",
|
|
1564
1570
|
unit: "",
|
|
1565
1571
|
top: loadTop(points, coreCount),
|