agent-dag 3.19.0 → 3.20.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/README.md +63 -1
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-DVj_M279.css +1 -0
- package/dist/web/assets/index-Dr-bVaHM.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/args.mjs +27 -0
- package/src/server/claude-accounts.mjs +173 -6
- package/src/server/cswap-admin.mjs +17 -0
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +37 -7
- package/src/server/deck-probe.mjs +111 -0
- package/src/server/detach.mjs +244 -0
- package/src/server/global-install.mjs +67 -0
- package/src/server/index.mjs +125 -86
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-engine.mjs +53 -3
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/login-service.mjs +473 -0
- package/src/server/running-deck.mjs +234 -0
- package/src/server/self-update.mjs +5 -2
- package/src/server/stop-deck.mjs +171 -0
- package/src/server/supervisor.mjs +90 -0
- package/src/server/term.mjs +35 -2
- package/dist/web/assets/index-CE-8EKi6.css +0 -1
- package/dist/web/assets/index-DTbVC1d9.js +0 -266
package/src/server/index.mjs
CHANGED
|
@@ -10,7 +10,17 @@ import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
|
|
|
10
10
|
import { homedir, networkInterfaces } from "node:os";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
// Moved out to a leaf so that a one-shot `ccdeck --stop`, the boot-path module
|
|
15
|
+
// that finds a running deck, and the tests that pin the handshake against
|
|
16
|
+
// hook/hook.js can all ask these questions without importing this file and
|
|
17
|
+
// arming everything in it. Re-exported below under the names they have always
|
|
18
|
+
// had — see src/server/deck-probe.mjs.
|
|
19
|
+
import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs";
|
|
20
|
+
// The three this file's own callers and tests already name. `sameProof` and the
|
|
21
|
+
// challenge deadline stay private to the leaf: they were private here too, and
|
|
22
|
+
// re-exporting them would be inventing API on the way out of a move.
|
|
23
|
+
export { challengeDeck, challengeProof, isProcessAlive };
|
|
14
24
|
import { promisify } from "node:util";
|
|
15
25
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
16
26
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
@@ -2036,10 +2046,6 @@ let codexWorkspace = "";
|
|
|
2036
2046
|
// believing a stale "yes" is at most a few unwritten lines, against a directory
|
|
2037
2047
|
// listing plus an HTTP round trip on every tick of every deck forever.
|
|
2038
2048
|
const DECK_PROOF_TTL_MS = 5000;
|
|
2039
|
-
// The same deadline hook.js gives a challenge, and for the same reason: a
|
|
2040
|
-
// bodyless GET to a loopback port is sub-millisecond when a deck is there and an
|
|
2041
|
-
// instant ECONNREFUSED when nothing is.
|
|
2042
|
-
const DECK_CHALLENGE_TIMEOUT_MS = 400;
|
|
2043
2049
|
// `${pid}:${port}:${token}` -> { at, ok }. Keyed on the record's own identity so
|
|
2044
2050
|
// a rewritten record — a deck that restarted onto the same port with a fresh
|
|
2045
2051
|
// token — is a new question rather than an inherited answer.
|
|
@@ -2105,57 +2111,6 @@ async function provesDeck(d) {
|
|
|
2105
2111
|
return ok;
|
|
2106
2112
|
}
|
|
2107
2113
|
|
|
2108
|
-
/**
|
|
2109
|
-
* One challenge round trip, resolving true only on a correct proof.
|
|
2110
|
-
*
|
|
2111
|
-
* The compare is constant-time for the reason hook.js's sameProof is: whatever
|
|
2112
|
-
* is on that port may not be a deck, and it must not be able to walk the
|
|
2113
|
-
* expected proof out of us one byte at a time by timing how long we take to hang
|
|
2114
|
-
* up. The nonce is fresh per call, so an answer overheard earlier is worth
|
|
2115
|
-
* nothing, and the token itself never leaves this process.
|
|
2116
|
-
*/
|
|
2117
|
-
function challengeDeck(port, token) {
|
|
2118
|
-
return new Promise(resolve => {
|
|
2119
|
-
let settled = false;
|
|
2120
|
-
const finish = ok => { if (settled) return; settled = true; resolve(ok); };
|
|
2121
|
-
const nonce = randomBytes(16).toString("hex");
|
|
2122
|
-
const want = challengeProof(token, nonce);
|
|
2123
|
-
const req = httpRequest({
|
|
2124
|
-
hostname: "127.0.0.1",
|
|
2125
|
-
port,
|
|
2126
|
-
path: `/api/hook-challenge?nonce=${nonce}`,
|
|
2127
|
-
method: "GET",
|
|
2128
|
-
timeout: DECK_CHALLENGE_TIMEOUT_MS,
|
|
2129
|
-
}, res => {
|
|
2130
|
-
if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
|
|
2131
|
-
let answer = "";
|
|
2132
|
-
res.setEncoding("utf8");
|
|
2133
|
-
res.on("data", c => {
|
|
2134
|
-
answer += c;
|
|
2135
|
-
// A deck answers in ~100 bytes. Anything pouring data at us is not one,
|
|
2136
|
-
// and must not be allowed to grow this buffer without bound.
|
|
2137
|
-
if (answer.length > 4096) { req.destroy(); finish(false); }
|
|
2138
|
-
});
|
|
2139
|
-
res.on("end", () => {
|
|
2140
|
-
if (settled) return;
|
|
2141
|
-
let proof;
|
|
2142
|
-
try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
|
|
2143
|
-
finish(sameProof(proof, want));
|
|
2144
|
-
});
|
|
2145
|
-
});
|
|
2146
|
-
req.on("error", () => finish(false));
|
|
2147
|
-
req.on("timeout", () => req.destroy());
|
|
2148
|
-
req.end();
|
|
2149
|
-
});
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
/** hook.js's sameProof, for the same reason it is constant-time there. */
|
|
2153
|
-
function sameProof(got, want) {
|
|
2154
|
-
if (typeof got !== "string") return false;
|
|
2155
|
-
const a = Buffer.from(got, "utf8");
|
|
2156
|
-
const b = Buffer.from(want, "utf8");
|
|
2157
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
2158
|
-
}
|
|
2159
2114
|
|
|
2160
2115
|
/**
|
|
2161
2116
|
* Every deck registered right now, as its own discovery record spells it — and
|
|
@@ -3246,15 +3201,67 @@ const lanEngine = createEngine({
|
|
|
3246
3201
|
const out = await shareAccounts([String(num)]);
|
|
3247
3202
|
return out?.ok ? out.blob : null;
|
|
3248
3203
|
},
|
|
3249
|
-
importAccount: async blob => {
|
|
3250
|
-
const { importAccount } = await import("./cswap-admin.mjs");
|
|
3204
|
+
importAccount: async (blob, step) => {
|
|
3205
|
+
const { importAccount, landed } = await import("./cswap-admin.mjs");
|
|
3251
3206
|
// NO `force`, ever, and this is the line where that promise is kept. A
|
|
3252
3207
|
// plain import adds an account that is missing and replaces exactly one
|
|
3253
3208
|
// claude-swap has quarantined; it SKIPS one that is present and healthy.
|
|
3254
3209
|
// So a peer cannot overwrite a working credential of this deck's even by
|
|
3255
3210
|
// lying about its own, because the flag that would allow it is not passed.
|
|
3256
3211
|
const out = await importAccount(blob);
|
|
3257
|
-
return
|
|
3212
|
+
if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
|
|
3213
|
+
// A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
|
|
3214
|
+
// exits ZERO when it declines an account it already holds — importAccount's
|
|
3215
|
+
// own comment says so and reports it as `added: false` — so a round that
|
|
3216
|
+
// changed nothing was counted as a successful repair and the panel said the
|
|
3217
|
+
// account had been fixed. Whoever read that then waited for numbers that
|
|
3218
|
+
// were never going to move.
|
|
3219
|
+
//
|
|
3220
|
+
// The decline is narrow and documented on claude-swap's side: a plain
|
|
3221
|
+
// import replaces a slot only when its usage row is quarantined as
|
|
3222
|
+
// refresh-token-dead, and is "never triggered by the live store's
|
|
3223
|
+
// `no credentials` state". So an account whose login expired heals over the
|
|
3224
|
+
// network, and one that has NO stored login does not — which is a true
|
|
3225
|
+
// sentence the panel can now print instead of a false one.
|
|
3226
|
+
if (out.added === true) return { ok: true };
|
|
3227
|
+
|
|
3228
|
+
// THE DECLINE, AND WHY IT MATTERS WHICH ONE IT IS.
|
|
3229
|
+
//
|
|
3230
|
+
// `cswap import` without --force declines for two opposite reasons and says
|
|
3231
|
+
// the same thing about both: the slot is present and HEALTHY, which is the
|
|
3232
|
+
// promise above working exactly as intended — or the slot is EMPTY, which
|
|
3233
|
+
// is the one case pairing exists for and the one a plain import will never
|
|
3234
|
+
// touch. claude-swap is explicit about the second: a plain import replaces
|
|
3235
|
+
// a slot "iff its usage row is quarantined as refresh-token-dead", and is
|
|
3236
|
+
// "never triggered by the live store's `no credentials` state".
|
|
3237
|
+
//
|
|
3238
|
+
// So `login expired` healed over the network and `no stored login` did not,
|
|
3239
|
+
// and the account somebody most needed repaired was the one the feature
|
|
3240
|
+
// could not repair.
|
|
3241
|
+
//
|
|
3242
|
+
// ASKED NOW, NOT READ FROM THE CACHE. The cached verdicts are up to ten
|
|
3243
|
+
// minutes old, which is right for a label and wrong for a decision that
|
|
3244
|
+
// writes a credential: somebody who signed in two minutes ago still reads
|
|
3245
|
+
// as `no_credentials` there, and acting on that would replace the login
|
|
3246
|
+
// they had just created.
|
|
3247
|
+
const { verdictNow } = await import("./claude-accounts.mjs");
|
|
3248
|
+
const [email, org] = String(step?.key ?? "").split("@@");
|
|
3249
|
+
const now = await verdictNow(email, org ?? "");
|
|
3250
|
+
if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
|
|
3251
|
+
|
|
3252
|
+
// FILLING AN EMPTY SLOT, WHICH IS NOT OVERWRITING A WORKING ONE — and the
|
|
3253
|
+
// promise above survives word for word. A peer cannot reach this: the
|
|
3254
|
+
// verdict comes from THIS machine's claude-swap, about THIS machine's
|
|
3255
|
+
// store, and nothing a peer sends can make a slot report that it holds
|
|
3256
|
+
// nothing. Narrowed to the one account with `only`, so a bundle carrying
|
|
3257
|
+
// several cannot ride in behind it.
|
|
3258
|
+
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
|
|
3259
|
+
if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
|
|
3260
|
+
// `added` counts `imported` alone, and a forced replace is `healed` — see
|
|
3261
|
+
// landed, which is the difference between a repair and a repair reported as
|
|
3262
|
+
// a failure.
|
|
3263
|
+
if (!landed(forced.results)) return { ok: false, why: "claude-swap kept the slot it already has" };
|
|
3264
|
+
return { ok: true, filled: true };
|
|
3258
3265
|
},
|
|
3259
3266
|
// The deck's own long-term key, kept so a restart is the same deck rather
|
|
3260
3267
|
// than a stranger to everybody who has paired with it. Written once, on the
|
|
@@ -3315,6 +3322,8 @@ async function applyLanPrefs() {
|
|
|
3315
3322
|
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
3316
3323
|
trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
|
|
3317
3324
|
port: lan.port || 0,
|
|
3325
|
+
autoAsk: lan.autoAsk !== false,
|
|
3326
|
+
autoAccept: lan.autoAccept !== false,
|
|
3318
3327
|
});
|
|
3319
3328
|
// Wholesale, so removing an address in the panel really stops it being
|
|
3320
3329
|
// dialled rather than only taking the row away.
|
|
@@ -4429,6 +4438,50 @@ export function markDeckReady() {
|
|
|
4429
4438
|
_deckReady = true;
|
|
4430
4439
|
}
|
|
4431
4440
|
|
|
4441
|
+
/**
|
|
4442
|
+
* POST /api/shutdown — end this deck, from `ccdeck --stop`.
|
|
4443
|
+
*
|
|
4444
|
+
* THE TOKEN AND NOTHING ELSE. Every other mutating route accepts either the
|
|
4445
|
+
* token or the deck's own page (isAuthorizedMutation), and this one must not:
|
|
4446
|
+
* there is no button for it, so a page asking to end the deck is a page doing
|
|
4447
|
+
* something no part of this product asks it to do. Refusing the browser half
|
|
4448
|
+
* costs nothing and removes the whole class.
|
|
4449
|
+
*
|
|
4450
|
+
* WHY A ROUTE AND NOT A SIGNAL. Windows has none. `process.kill(pid, "SIGTERM")`
|
|
4451
|
+
* there is TerminateProcess: the deck stops mid-instruction, its discovery file
|
|
4452
|
+
* is left for the next boot to sweep, and its LAN beacon never says goodbye — so
|
|
4453
|
+
* every paired colleague watches it time out instead of seeing it leave. One
|
|
4454
|
+
* loopback POST behaves identically on all three platforms and ends in the
|
|
4455
|
+
* deck's own shutdown(), which closes the listener, unlinks the registration and
|
|
4456
|
+
* stops the beacon. The pid ladder still exists in `--stop`, as the fallback for
|
|
4457
|
+
* a deck too wedged to answer this.
|
|
4458
|
+
*
|
|
4459
|
+
* ANSWERED BEFORE ANYTHING IS TORN DOWN. shutdown() calls
|
|
4460
|
+
* server.closeAllConnections(), which would cut this very socket — so the
|
|
4461
|
+
* teardown is hung off the response having left rather than run beside it, and
|
|
4462
|
+
* the caller gets a 200 instead of a dropped connection it has to interpret.
|
|
4463
|
+
*/
|
|
4464
|
+
async function handleStop(req, res) {
|
|
4465
|
+
if (!presentsDeckToken(req?.headers ?? {})) return send(res, 401, { ok: false, reason: "unauthenticated" });
|
|
4466
|
+
if (!_onStop) return send(res, 501, { ok: false, reason: "no_launcher" });
|
|
4467
|
+
if (_stopping) return send(res, 200, { ok: true, pid: process.pid, already: true });
|
|
4468
|
+
_stopping = true;
|
|
4469
|
+
let left = false;
|
|
4470
|
+
const leave = () => {
|
|
4471
|
+
if (left) return;
|
|
4472
|
+
left = true;
|
|
4473
|
+
// A throw here must still end the process: the caller has already been told
|
|
4474
|
+
// this deck is going, and a deck that answered "ok" and stayed up is worse
|
|
4475
|
+
// than one that never answered.
|
|
4476
|
+
try { _onStop(); } catch { process.exit(0); }
|
|
4477
|
+
};
|
|
4478
|
+
// 'finish' is the response handed to the OS; 'close' covers the caller that
|
|
4479
|
+
// hung up before it got there. Either one means nothing is left to flush.
|
|
4480
|
+
res.once("finish", leave);
|
|
4481
|
+
res.once("close", leave);
|
|
4482
|
+
send(res, 200, { ok: true, pid: process.pid });
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4432
4485
|
async function handleQuota(req, res) {
|
|
4433
4486
|
const { fetchClaudeQuota } = await import(
|
|
4434
4487
|
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
@@ -5035,17 +5088,6 @@ const HOOK_TOKEN = randomBytes(32).toString("hex");
|
|
|
5035
5088
|
/** The token this deck expects to be challenged on. Written by writeDiscovery. */
|
|
5036
5089
|
export function hookToken() { return HOOK_TOKEN; }
|
|
5037
5090
|
|
|
5038
|
-
/**
|
|
5039
|
-
* The proof of knowing `token`, for a nonce the challenger chose.
|
|
5040
|
-
*
|
|
5041
|
-
* hook/hook.js spells this out a second time — it is installed outside the
|
|
5042
|
-
* package and cannot import from here — and a test pins the two against each
|
|
5043
|
-
* other. Changing one without the other silently blinds the deck.
|
|
5044
|
-
*/
|
|
5045
|
-
export function challengeProof(token, nonce) {
|
|
5046
|
-
return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
|
|
5047
|
-
}
|
|
5048
|
-
|
|
5049
5091
|
// GET /api/hook-challenge?nonce=… — answer a hook's challenge.
|
|
5050
5092
|
//
|
|
5051
5093
|
// The nonce is the caller's, so the answer proves knowledge of the token
|
|
@@ -5070,21 +5112,6 @@ function handleHookChallenge(_req, res, url) {
|
|
|
5070
5112
|
send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
|
|
5071
5113
|
}
|
|
5072
5114
|
|
|
5073
|
-
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
5074
|
-
//
|
|
5075
|
-
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
5076
|
-
// answers EPERM for a process this account may not signal. On Windows
|
|
5077
|
-
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
5078
|
-
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
5079
|
-
// another account, read as DEAD to every probe in this repo. What followed was
|
|
5080
|
-
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
5081
|
-
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
5082
|
-
// claiming it was receiving events it had stopped receiving.
|
|
5083
|
-
function isProcessAlive(pid) {
|
|
5084
|
-
try { process.kill(pid, 0); return true; }
|
|
5085
|
-
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
5086
|
-
}
|
|
5087
|
-
|
|
5088
5115
|
async function sweepStaleDiscovery() {
|
|
5089
5116
|
// Same directory the installer writes and the hooks read — see claude-dir.mjs.
|
|
5090
5117
|
const dir = join(claudeConfigDir(), "agent-dag");
|
|
@@ -5590,6 +5617,15 @@ export function listenFailure(err, { host = "", port = 0, platform = process.pla
|
|
|
5590
5617
|
// launcher), /api/restart answers 501 and the UI hides the control rather than
|
|
5591
5618
|
// offering a button that does nothing.
|
|
5592
5619
|
let _onRestart = null;
|
|
5620
|
+
// How this process ends itself, handed down by bin/deck.js for the same reason
|
|
5621
|
+
// _onRestart is: the server does not own the process lifecycle and must be told
|
|
5622
|
+
// how to leave. Absent when nothing handed one down — running the module
|
|
5623
|
+
// directly, or an embedder — and /api/shutdown then answers 501 rather than
|
|
5624
|
+
// pretending.
|
|
5625
|
+
let _onStop = null;
|
|
5626
|
+
// A stop is in flight. Two sockets asking at once must not both start one, and
|
|
5627
|
+
// the response's 'finish' and 'close' can both fire for the same request.
|
|
5628
|
+
let _stopping = false;
|
|
5593
5629
|
// A restart is in flight. Several browser tabs watching the same deck will each
|
|
5594
5630
|
// ask; the second ask must not re-enter the shutdown.
|
|
5595
5631
|
let _restarting = false;
|
|
@@ -5599,8 +5635,10 @@ let _restarting = false;
|
|
|
5599
5635
|
// is reachable in and cannot answer for on its own.
|
|
5600
5636
|
let _deckReady = false;
|
|
5601
5637
|
|
|
5602
|
-
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
|
|
5638
|
+
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null } = {}) {
|
|
5603
5639
|
_onRestart = typeof onRestart === "function" ? onRestart : null;
|
|
5640
|
+
_onStop = typeof onStop === "function" ? onStop : null;
|
|
5641
|
+
_stopping = false;
|
|
5604
5642
|
_canRestart = _onRestart != null && persist != null;
|
|
5605
5643
|
// A new listener is a new boot, whatever a previous one had got as far as
|
|
5606
5644
|
// reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
|
|
@@ -5687,6 +5725,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5687
5725
|
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
5688
5726
|
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
5689
5727
|
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
5728
|
+
if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
|
|
5690
5729
|
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
5691
5730
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
5692
5731
|
// Machine state, not session state: sampled on the server's own timer and
|
package/src/server/installer.mjs
CHANGED
|
@@ -709,7 +709,7 @@ async function ensureDiscoveryDir() {
|
|
|
709
709
|
* the target already had — a first registration, or one left by an earlier run
|
|
710
710
|
* under a recycled pid, would otherwise keep whatever the umask handed it.
|
|
711
711
|
*/
|
|
712
|
-
export async function writeDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
712
|
+
export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
|
|
713
713
|
await ensureDiscoveryDir();
|
|
714
714
|
const file = discoveryPath();
|
|
715
715
|
const data = {
|
|
@@ -744,6 +744,27 @@ export async function writeDiscovery({ port, workspace, token, persist = null, c
|
|
|
744
744
|
// doing the work must be left out of the election rather than win it. An
|
|
745
745
|
// older deck has no such field, so it is excluded by construction.
|
|
746
746
|
watch: true,
|
|
747
|
+
// Is the Claude side of this deck switched on? Same shape as `codex` above
|
|
748
|
+
// and there for a second reader: a bare `ccdeck` that finds this deck
|
|
749
|
+
// running attaches to it instead of starting a rival, and it may only do
|
|
750
|
+
// that when the deck on the port is the deck it would itself have built. A
|
|
751
|
+
// `--no-claude` deck has no hooks, no accounts panel and no switcher, and
|
|
752
|
+
// until this field existed it was indistinguishable from one that has all
|
|
753
|
+
// three. An older deck has no such field, so it is excluded by
|
|
754
|
+
// construction — see runningDeck.
|
|
755
|
+
claude: claude !== false,
|
|
756
|
+
// What this deck IS, so a launcher that attaches can say whether the deck
|
|
757
|
+
// it found is the version the user just asked for. Never a decision: a
|
|
758
|
+
// rival deck on a random port is worse than an older deck said out loud.
|
|
759
|
+
version: typeof version === "string" ? version : "",
|
|
760
|
+
// The supervisor above this worker, or null when nothing is supervising.
|
|
761
|
+
//
|
|
762
|
+
// Only `ccdeck --stop` reads it, and only on the path where the polite
|
|
763
|
+
// request failed. A worker killed on its own leaves its supervisor alive,
|
|
764
|
+
// and a supervisor that puts crashed workers back would answer that kill by
|
|
765
|
+
// starting the deck again — so the ladder has to end the parent first and
|
|
766
|
+
// the child second, which it cannot do without being told who the parent is.
|
|
767
|
+
parent: Number.isInteger(parent) ? parent : null,
|
|
747
768
|
startedAt: new Date().toISOString(),
|
|
748
769
|
};
|
|
749
770
|
await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
|
|
@@ -771,13 +792,14 @@ export function discoveryPath() {
|
|
|
771
792
|
* is cheap (one small read), so the deck checks rather than assumes.
|
|
772
793
|
*
|
|
773
794
|
* A file this process wrote is left alone, mode included. Anything else — no
|
|
774
|
-
* file, unreadable, another pid, a stale port, token, events log or
|
|
775
|
-
* setting
|
|
795
|
+
* file, unreadable, another pid, a stale port, token, events log, Codex or
|
|
796
|
+
* Claude setting, or a version left by the deck this process replaced — is
|
|
797
|
+
* replaced. Every field another deck decides by is compared, the
|
|
776
798
|
* log path included: leave one out and a record missing it would pass as ours
|
|
777
799
|
* forever, which for the log path means no deck can tell which of them share a
|
|
778
800
|
* file and they all write their own copy of every event again.
|
|
779
801
|
*/
|
|
780
|
-
export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
802
|
+
export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
|
|
781
803
|
const file = discoveryPath();
|
|
782
804
|
try {
|
|
783
805
|
const d = JSON.parse(stripBom(await readFile(file, "utf8")));
|
|
@@ -787,11 +809,14 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
|
|
|
787
809
|
&& (d.workspace ?? "") === (workspace ?? "")
|
|
788
810
|
&& (d.token ?? "") === (token ?? "")
|
|
789
811
|
&& (d.persist ?? null) === persistField(persist)
|
|
790
|
-
&& d.codex === (codex !== false)
|
|
812
|
+
&& d.codex === (codex !== false)
|
|
813
|
+
&& d.claude === (claude !== false)
|
|
814
|
+
&& (d.version ?? "") === (typeof version === "string" ? version : "")
|
|
815
|
+
&& (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
|
|
791
816
|
return { file, rewritten: false };
|
|
792
817
|
}
|
|
793
818
|
} catch { /* missing, unreadable or corrupt — rewritten below */ }
|
|
794
|
-
await writeDiscovery({ port, workspace, token, persist, codex });
|
|
819
|
+
await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
|
|
795
820
|
return { file, rewritten: true };
|
|
796
821
|
}
|
|
797
822
|
|
|
@@ -822,7 +847,7 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
|
|
|
822
847
|
* `run()` never rejects (every failure is a state), so awaiting this cannot
|
|
823
848
|
* throw and cannot outlast one bounded check.
|
|
824
849
|
*/
|
|
825
|
-
export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
|
|
850
|
+
export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
|
|
826
851
|
// null until the first outcome, which therefore always differs and is always
|
|
827
852
|
// reported — the caller learns where it stands before anything else happens.
|
|
828
853
|
let healthy = null;
|
|
@@ -830,7 +855,7 @@ export function keepDiscovery({ port, workspace, token, persist = null, codex =
|
|
|
830
855
|
const run = async () => {
|
|
831
856
|
let state;
|
|
832
857
|
try {
|
|
833
|
-
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex });
|
|
858
|
+
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
|
|
834
859
|
state = { ok: true, rewritten, file: discoveryPath(), error: null };
|
|
835
860
|
} catch (err) {
|
|
836
861
|
state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
|
|
@@ -119,7 +119,10 @@ export function createEngine({
|
|
|
119
119
|
*/
|
|
120
120
|
createSocket,
|
|
121
121
|
} = {}) {
|
|
122
|
-
let cfg = {
|
|
122
|
+
let cfg = {
|
|
123
|
+
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
124
|
+
autoAsk: true, autoAccept: true,
|
|
125
|
+
};
|
|
123
126
|
let identity = null;
|
|
124
127
|
let beacon = null;
|
|
125
128
|
let server = null;
|
|
@@ -341,8 +344,16 @@ export function createEngine({
|
|
|
341
344
|
if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
|
|
342
345
|
const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
|
|
343
346
|
if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
|
|
344
|
-
|
|
345
|
-
|
|
347
|
+
// A verdict rather than a boolean, because "refused" and "kept the
|
|
348
|
+
// slot it already has" are different things to tell somebody and the
|
|
349
|
+
// second one used to be reported as success. A bare `true` is still
|
|
350
|
+
// accepted: the suite drives this with one.
|
|
351
|
+
// The step goes down with the blob: the wiring has to know WHICH account
|
|
352
|
+
// it is placing before it may treat a decline as an empty slot rather
|
|
353
|
+
// than as a healthy one.
|
|
354
|
+
const got = await importAccount(blob, step);
|
|
355
|
+
const ok = got === true || got?.ok === true;
|
|
356
|
+
done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
|
|
346
357
|
}
|
|
347
358
|
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
|
|
348
359
|
if (done.length) onChange?.();
|
|
@@ -396,8 +407,23 @@ export function createEngine({
|
|
|
396
407
|
|
|
397
408
|
return {
|
|
398
409
|
async apply(next) {
|
|
410
|
+
/** The engine itself, for the callbacks handed to the socket below: they
|
|
411
|
+
* outlive this call and `this` is not theirs to keep. */
|
|
412
|
+
const self = this;
|
|
399
413
|
const was = cfg;
|
|
400
414
|
cfg = { ...cfg, ...next };
|
|
415
|
+
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
416
|
+
// this on with two rows sitting in the panel means those two as much as
|
|
417
|
+
// the next one, and leaving them queued behind a setting called
|
|
418
|
+
// "automatic" is the switch not doing what it says.
|
|
419
|
+
if (!was.autoAccept && cfg.autoAccept) {
|
|
420
|
+
for (const fp of [...pending.keys()]) this.accept(fp);
|
|
421
|
+
}
|
|
422
|
+
// The same for the other direction: switching `ask` on with four machines
|
|
423
|
+
// already listed asks those four.
|
|
424
|
+
if (!was.autoAsk && cfg.autoAsk) {
|
|
425
|
+
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !p.pub) this.accept(fp);
|
|
426
|
+
}
|
|
401
427
|
const restart = !was.enabled !== !cfg.enabled
|
|
402
428
|
|| was.secret !== cfg.secret
|
|
403
429
|
|| was.name !== cfg.name;
|
|
@@ -451,6 +477,16 @@ export function createEngine({
|
|
|
451
477
|
onPending: entry => {
|
|
452
478
|
const had = pending.get(entry.fp);
|
|
453
479
|
pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
|
|
480
|
+
// SAY YES FOR SOMEBODY WHO SAID TO. It is the accept button and
|
|
481
|
+
// nothing else: the same pin, from the same key this handshake just
|
|
482
|
+
// proved, so the deck is trusted on its next attempt a few seconds
|
|
483
|
+
// later exactly as it would be if a person had pressed it. Nothing
|
|
484
|
+
// about the wire changes — this connection is still refused, because
|
|
485
|
+
// trust is read fresh per connection.
|
|
486
|
+
//
|
|
487
|
+
// A deck already told no does NOT come back this way: lan-socket
|
|
488
|
+
// refuses it before this is ever called.
|
|
489
|
+
if (cfg.autoAccept) { self.accept(entry.fp); return; }
|
|
454
490
|
if (!had) onChange?.();
|
|
455
491
|
},
|
|
456
492
|
});
|
|
@@ -477,6 +513,16 @@ export function createEngine({
|
|
|
477
513
|
// one of them drew a row offering to pair with the same machine.
|
|
478
514
|
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
479
515
|
strangers.set(entry.fp, entry);
|
|
516
|
+
// ASK IT, which is what the `ask` verb on its row does and nothing
|
|
517
|
+
// more: the address goes on the dial list and the next round sends a
|
|
518
|
+
// request that somebody over there still has to answer. A beacon
|
|
519
|
+
// carries a fingerprint and no key, so nothing is pinned here — see
|
|
520
|
+
// accept, which is deliberate about the difference.
|
|
521
|
+
//
|
|
522
|
+
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
523
|
+
// would be thirty seconds of asking; and never one this deck's owner
|
|
524
|
+
// already turned away.
|
|
525
|
+
if (cfg.autoAsk && !had && !declined.has(entry.fp)) { self.accept(entry.fp); return; }
|
|
480
526
|
// Only a deck that is new to us is news. A beacon every thirty
|
|
481
527
|
// seconds from one already on the list is not a reason to redraw.
|
|
482
528
|
if (!had) onChange?.();
|
|
@@ -712,6 +758,10 @@ export function createEngine({
|
|
|
712
758
|
// which on a deck that has just started is the honest answer.
|
|
713
759
|
checkedAt: roundAt,
|
|
714
760
|
name: cfg.name,
|
|
761
|
+
// Whether this deck asks on its own, and whether a request that
|
|
762
|
+
// arrives is answered here or answered for you.
|
|
763
|
+
autoAsk: !!cfg.autoAsk,
|
|
764
|
+
autoAccept: !!cfg.autoAccept,
|
|
715
765
|
fp: identity?.fp ?? null,
|
|
716
766
|
// The address and port a person on another subnet types into the other
|
|
717
767
|
// deck's field. Null when this machine has no ordinary one, which the
|
|
@@ -22,6 +22,57 @@
|
|
|
22
22
|
// all — and it is why self-recognition is by fingerprint rather than by
|
|
23
23
|
// address.
|
|
24
24
|
import dgram from "node:dgram";
|
|
25
|
+
import { networkInterfaces } from "node:os";
|
|
26
|
+
|
|
27
|
+
/** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
|
|
28
|
+
function quad(text) {
|
|
29
|
+
const parts = String(text ?? "").split(".");
|
|
30
|
+
if (parts.length !== 4) return null;
|
|
31
|
+
const out = parts.map(p => Number(p));
|
|
32
|
+
return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The address that reaches every host on one interface's own subnet.
|
|
37
|
+
*
|
|
38
|
+
* `address | ~netmask`, which is the definition. Null for a /32, because a
|
|
39
|
+
* point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
|
|
40
|
+
* broadcast equal to its own address, and sending a beacon to ourselves down a
|
|
41
|
+
* tunnel is a packet nobody wanted.
|
|
42
|
+
*/
|
|
43
|
+
export function directedBroadcast(address, netmask) {
|
|
44
|
+
const a = quad(address);
|
|
45
|
+
const m = quad(netmask);
|
|
46
|
+
if (!a || !m) return null;
|
|
47
|
+
if (m.every(o => o === 255)) return null;
|
|
48
|
+
return a.map((o, i) => o | (~m[i] & 255)).join(".");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a beacon has to go to be heard on this machine's networks.
|
|
53
|
+
*
|
|
54
|
+
* The limited broadcast first, because it is the one that works where a router
|
|
55
|
+
* or an access point filters the directed form, and because it is what every
|
|
56
|
+
* deck before this version sent — a machine that was fine stays fine. Then one
|
|
57
|
+
* per interface, which is what a multi-homed host actually needs: see announce.
|
|
58
|
+
*
|
|
59
|
+
* Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
|
|
60
|
+
* protocol is IPv4 broadcast by construction — there is no such thing as an
|
|
61
|
+
* IPv6 broadcast address.
|
|
62
|
+
*/
|
|
63
|
+
export function broadcastTargets(ifaces) {
|
|
64
|
+
const out = ["255.255.255.255"];
|
|
65
|
+
for (const list of Object.values(ifaces ?? {})) {
|
|
66
|
+
for (const ni of list ?? []) {
|
|
67
|
+
if (!ni || ni.internal) continue;
|
|
68
|
+
// Node 18 reports `family` as the string "IPv4"; older shapes used 4.
|
|
69
|
+
if (ni.family !== "IPv4" && ni.family !== 4) continue;
|
|
70
|
+
const to = directedBroadcast(ni.address, ni.netmask);
|
|
71
|
+
if (to && !out.includes(to)) out.push(to);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
25
76
|
import net from "node:net";
|
|
26
77
|
import { randomBytes } from "node:crypto";
|
|
27
78
|
import {
|
|
@@ -84,6 +135,11 @@ export function createBeacon({
|
|
|
84
135
|
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
85
136
|
// machine, which works because a broadcast comes back to its own host.
|
|
86
137
|
createSocket = opts => dgram.createSocket(opts),
|
|
138
|
+
// Injected for the same reason, and read PER ANNOUNCE rather than once: a
|
|
139
|
+
// laptop that joins a network, or brings a VPN up, grows an interface without
|
|
140
|
+
// restarting the deck, and a list captured at start would announce to the
|
|
141
|
+
// addresses it had at breakfast.
|
|
142
|
+
ifaces = () => networkInterfaces(),
|
|
87
143
|
} = {}) {
|
|
88
144
|
// Randomised per process. Two beacons from one fingerprint with different
|
|
89
145
|
// instance ids mean the deck restarted between them, which is the signal to
|
|
@@ -104,20 +160,47 @@ export function createBeacon({
|
|
|
104
160
|
let repliedAt = 0;
|
|
105
161
|
const answered = new Set();
|
|
106
162
|
|
|
107
|
-
|
|
163
|
+
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
|
|
108
164
|
|
|
109
165
|
const announce = () => {
|
|
110
166
|
if (!sock) return;
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
168
|
+
//
|
|
169
|
+
// This used to send only to 255.255.255.255, on the argument that the
|
|
170
|
+
// limited broadcast "needs nothing" while a subnet-directed one needs the
|
|
171
|
+
// netmask of whichever interface the packet leaves by. The argument is
|
|
172
|
+
// sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
|
|
173
|
+
// a VPN tunnel up, from a bare node process with no deck involved:
|
|
174
|
+
//
|
|
175
|
+
// send to 255.255.255.255 -> EHOSTUNREACH
|
|
176
|
+
// send to 192.168.1.255 -> sent ok
|
|
177
|
+
//
|
|
178
|
+
// On a multi-homed host the limited broadcast has no single interface to
|
|
179
|
+
// leave by, and macOS refuses it rather than choosing. The deck went on
|
|
180
|
+
// announcing into nothing for as long as that machine was up: it could still
|
|
181
|
+
// HEAR colleagues, because receiving is per-port and not per-address, so the
|
|
182
|
+
// symptom was one-sided and looked like everybody else's problem.
|
|
183
|
+
//
|
|
184
|
+
// So the limited form stays — it is the one that works where a directed
|
|
185
|
+
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
186
|
+
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
187
|
+
// costs one datagram; a missing one costs the whole feature.
|
|
188
|
+
const targets = broadcastTargets(ifaces());
|
|
189
|
+
let left = targets.length;
|
|
190
|
+
const failed = [];
|
|
191
|
+
for (const to of targets) {
|
|
192
|
+
sock.send(payload(), DISCOVERY_PORT, to, err => {
|
|
193
|
+
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
194
|
+
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
195
|
+
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
196
|
+
// so every thirty seconds would be crying wolf about a working deck.
|
|
197
|
+
// No address working at all is a deck nobody can discover, which is
|
|
198
|
+
// exactly what the panel is for.
|
|
199
|
+
if (--left === 0 && failed.length === targets.length) {
|
|
200
|
+
onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
121
204
|
};
|
|
122
205
|
|
|
123
206
|
const start = () => new Promise(resolve => {
|