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.
@@ -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 { createHash, randomBytes, timingSafeEqual } from "node:crypto";
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 !!out?.ok;
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
@@ -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 Codex
775
- * setting — is replaced. Every field another deck decides by is compared, the
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 = { enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0 };
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
- const ok = await importAccount(blob);
345
- done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
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
- const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
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
- // 255.255.255.255 rather than a multicast group, and rather than the
112
- // subnet's own broadcast address. The subnet-directed form needs the
113
- // netmask of whichever interface the packet leaves by, which changes when a
114
- // VPN comes up; the limited broadcast needs nothing and is what Syncthing
115
- // sends for the same reason.
116
- sock.send(payload(), DISCOVERY_PORT, "255.255.255.255", err => {
117
- // ENETUNREACH and EACCES are what a machine with no network, or a
118
- // firewall, says. Both are states the panel reports rather than crashes.
119
- if (err) onError?.("announce", err);
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 => {