ccdeck 3.28.1 → 3.29.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.
@@ -36,6 +36,10 @@ import { looksLikeTunnel } from "./route-via.mjs";
36
36
  const REFUSALS = Object.freeze({
37
37
  pending: "waiting for the other deck to accept this one",
38
38
  declined: "that deck said no",
39
+ "invite only": "that deck pairs only by invite",
40
+ // Sent back to a caller that said it was not asking, so the far end's answer
41
+ // is about the caller's own setting — the same words its own round uses.
42
+ "not asking": "this deck pairs only by invite",
39
43
  impostor: "that deck has this one pinned under a different key",
40
44
  "bad proof": "the other deck refused this one's proof",
41
45
  });
@@ -291,6 +295,9 @@ export function createBeacon({
291
295
  let told = null;
292
296
  let rebind = null;
293
297
  let stopped = true;
298
+ // Socket binds finish asynchronously. A stopped start must not attach its
299
+ // late socket to a subsequent start or leave an outbound socket open.
300
+ let startGeneration = 0;
294
301
  /** When this deck last answered a deck it had not heard, so answering cannot
295
302
  * become a storm, and which decks it has already answered — without the
296
303
  * second, a deck that is never accepted is answered again on every packet
@@ -302,6 +309,7 @@ export function createBeacon({
302
309
 
303
310
  const announce = (also = []) => {
304
311
  if (!sock && !out) return;
312
+ const announcedIn = startGeneration;
305
313
  // EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
306
314
  //
307
315
  // This used to send only to 255.255.255.255, on the argument that the
@@ -335,6 +343,9 @@ export function createBeacon({
335
343
  const failed = [];
336
344
  for (const to of targets) {
337
345
  from.send(payload(), DISCOVERY_PORT, to, err => {
346
+ // A send may finish after this socket has closed and a new LAN
347
+ // session has started. Its result says nothing about the new session.
348
+ if (stopped || announcedIn !== startGeneration) return;
338
349
  if (err) failed.push(`${to} (${err.code ?? err.message})`);
339
350
  // REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
340
351
  // the ordinary state of a machine with a VPN up, and a panel that said
@@ -358,6 +369,9 @@ export function createBeacon({
358
369
  };
359
370
 
360
371
  const onMessage = (msg, rinfo) => {
372
+ // A queued UDP callback can run after close(). It belongs to the stopped
373
+ // discovery session and must not repopulate peers or invite strangers.
374
+ if (stopped) return;
361
375
  // Everything about whether to care lives in lan-sync.mjs. This hands it
362
376
  // the bytes and the address and does what it is told.
363
377
  if (msg.length > MAX_BEACON_BYTES) return;
@@ -421,16 +435,24 @@ export function createBeacon({
421
435
 
422
436
  /** One try at the discovery port: the listening socket, or the error that
423
437
  * kept it. A bind that fails never calls back; it arrives as an error. */
424
- const listen = () => new Promise(resolve => {
438
+ const listen = (startedIn) => new Promise(resolve => {
425
439
  let s;
426
440
  try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
427
441
  let bound = false;
428
442
  s.on("error", err => {
429
- if (bound) { onError?.("socket", err); return; }
443
+ if (bound) {
444
+ if (!stopped && startedIn === startGeneration) onError?.("socket", err);
445
+ return;
446
+ }
430
447
  try { s.close(); } catch { /* never opened */ }
431
448
  resolve({ err });
432
449
  });
433
- s.on("message", onMessage);
450
+ // close() can leave a message callback queued. After restart, stopped is
451
+ // false again, so the socket's own generation must also be checked.
452
+ s.on("message", (msg, rinfo) => {
453
+ if (startedIn !== startGeneration) return;
454
+ onMessage(msg, rinfo);
455
+ });
434
456
  s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
435
457
  bound = true;
436
458
  try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
@@ -438,9 +460,10 @@ export function createBeacon({
438
460
  });
439
461
  });
440
462
 
441
- const tryListen = async () => {
442
- const got = await listen();
443
- if (stopped) { try { got.sock?.close(); } catch { /* gone */ } return; }
463
+ const tryListen = async (startedIn) => {
464
+ if (stopped || startedIn !== startGeneration) return;
465
+ const got = await listen(startedIn);
466
+ if (stopped || startedIn !== startGeneration) { try { got.sock?.close(); } catch { /* gone */ } return; }
444
467
  if (got.sock) {
445
468
  sock = got.sock;
446
469
  hearing = true;
@@ -451,7 +474,7 @@ export function createBeacon({
451
474
  hearing = false;
452
475
  deafError = got.err;
453
476
  tell(false);
454
- rebind = setTimeout(() => { rebind = null; void tryListen(); }, rebindMs);
477
+ rebind = setTimeout(() => { rebind = null; void tryListen(startedIn); }, rebindMs);
455
478
  rebind.unref?.();
456
479
  };
457
480
 
@@ -487,11 +510,17 @@ export function createBeacon({
487
510
  });
488
511
 
489
512
  const start = async () => {
513
+ const startedIn = ++startGeneration;
490
514
  stopped = false;
491
515
  told = null;
492
- await tryListen();
493
- out = await openOut();
494
- if (stopped) return;
516
+ await tryListen(startedIn);
517
+ if (stopped || startedIn !== startGeneration) return;
518
+ const opened = await openOut();
519
+ if (stopped || startedIn !== startGeneration) {
520
+ try { opened?.close(); } catch { /* already closed */ }
521
+ return;
522
+ }
523
+ out = opened;
495
524
  // Immediately, not on the next tick. Syncthing's rule: a deck that just
496
525
  // came up should appear now rather than up to thirty seconds later, which
497
526
  // is the difference between "it works" and "it seems broken" for anybody
@@ -512,6 +541,7 @@ export function createBeacon({
512
541
  tunneled: () => tunneled,
513
542
  stop() {
514
543
  stopped = true;
544
+ startGeneration++;
515
545
  if (timer) clearInterval(timer);
516
546
  timer = null;
517
547
  if (rebind) clearTimeout(rebind);
@@ -605,6 +635,10 @@ export function createSyncServer({
605
635
  * silent wait — they are the same frame otherwise, and only one of them ever
606
636
  * comes right by waiting. */
607
637
  declined = () => false,
638
+ /** Does this deck pair only by invite? Then a deck it has not met, holding no
639
+ * invite, is told so instead of "pending": nobody here will ever be shown a
640
+ * request to accept, and a caller left waiting on one waits for good. */
641
+ inviteOnly = () => false,
608
642
  /** The invite this deck is currently offering, or null. A caller that proves
609
643
  * it holds the code is somebody the owner handed a token to, so it is paired
610
644
  * on arrival rather than queued behind a press. */
@@ -645,6 +679,7 @@ export function createSyncServer({
645
679
  ephemeral = true,
646
680
  } = {}) {
647
681
  let server = null;
682
+ let cancelStart = null;
648
683
  const live = new Set();
649
684
 
650
685
  /** Which host a socket came from, in one spelling. Node reports an IPv4 peer
@@ -690,6 +725,9 @@ export function createSyncServer({
690
725
  let peerPub = null;
691
726
  let peerName = "";
692
727
  let peerPort = null;
728
+ // Whether the caller said it is NOT asking to pair — an invite-only deck
729
+ // reaching an address it already had. See the unknown-deck branch below.
730
+ let peerNoAsk = false;
693
731
  let key = null;
694
732
  // Carrying this deck's marks — that it seals, and that it mixes a key pair
695
733
  // of its own into the key — inside the one field the handshake already
@@ -815,6 +853,7 @@ export function createSyncServer({
815
853
  // of a fixed 288px column and left a complete, plausible sentence.
816
854
  peerName = cleanName(msg.name, "");
817
855
  peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
856
+ peerNoAsk = msg.ask === false;
818
857
  // MIXED WHEN BOTH CHALLENGES SAY SO, and only then: the two strings
819
858
  // the proofs bind decide it, never whether a field turned up. Once
820
859
  // both say so, a hello with no usable key is refused rather than
@@ -925,6 +964,19 @@ export function createSyncServer({
925
964
  // do — the deck that asked is told, so its own panel can stop saying
926
965
  // "waiting" about a question that has been answered.
927
966
  if (declined(peerFp)) return refuse("declined");
967
+ // AN INVITE-ONLY DECK, and this caller brought none (a caller that
968
+ // did was paired above). Answered rather than queued: the engine
969
+ // records no request in this mode, so "pending" would leave the other
970
+ // deck's panel saying "waiting for them to say yes" about a question
971
+ // nobody here will ever see. After "declined", which is the more
972
+ // specific answer about this one deck.
973
+ if (inviteOnly()) return refuse("invite only");
974
+ // A CALLER THAT IS NOT ASKING. An invite-only deck still dials the
975
+ // addresses it already had, because an invite-paired deck is one of
976
+ // them; one that turns out not to know it must not become a request
977
+ // here — with the accept switch on, that request would have pinned a
978
+ // deck whose owner said it pairs only by invite.
979
+ if (peerNoAsk) return refuse("not asking");
928
980
 
929
981
  // A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
930
982
  // a port scan, and it told us a name and an address a person can
@@ -1001,23 +1053,51 @@ export function createSyncServer({
1001
1053
  start: () => new Promise((resolve, reject) => {
1002
1054
  const wanted = Number.isInteger(prefer) && prefer > 0 && prefer < 65_536 ? prefer : 0;
1003
1055
  let retried = wanted === 0;
1004
- server = net.createServer(onConnection);
1005
- server.on("error", err => {
1056
+ const listener = net.createServer(onConnection);
1057
+ server = listener;
1058
+ let pending = true;
1059
+ const finish = (done, result) => {
1060
+ if (!pending) return;
1061
+ pending = false;
1062
+ if (cancelStart === cancel) cancelStart = null;
1063
+ done(result);
1064
+ };
1065
+ // A close before the listening callback otherwise leaves start() pending
1066
+ // forever. Null tells the engine that this startup was cancelled.
1067
+ let cancelled = false;
1068
+ const cancel = () => { cancelled = true; finish(resolve, null); };
1069
+ // A bind that lands after the cancel is closed by its own callback. With
1070
+ // a host, `listen` binds after a dns.lookup that current Node drops on
1071
+ // close(); a runtime that still binds would leave a listener nobody owns.
1072
+ const bound = () => {
1073
+ if (cancelled) { try { listener.close(); } catch { /* already closed */ } return; }
1074
+ finish(resolve, listener.address()?.port ?? null);
1075
+ };
1076
+ cancelStart = cancel;
1077
+ listener.on("error", err => {
1078
+ // A listener can also fail after its initial bind succeeded. Continue
1079
+ // reporting those errors while it is the active server.
1080
+ if (!pending) {
1081
+ if (server === listener) onError?.("listen", err);
1082
+ return;
1083
+ }
1006
1084
  if (!retried) {
1007
1085
  // Somebody else has it — another deck on this machine, or something
1008
1086
  // unrelated. The pin is not worth failing to start over.
1009
1087
  retried = true;
1010
1088
  onError?.("listen", err);
1011
- try { server.listen(0, host, () => resolve(server.address().port)); } catch { reject(err); }
1089
+ try { listener.listen(0, host, bound); } catch { finish(reject, err); }
1012
1090
  return;
1013
1091
  }
1014
1092
  onError?.("listen", err);
1015
- reject(err);
1093
+ finish(reject, err);
1016
1094
  });
1017
- server.listen(wanted, host, () => resolve(server.address().port));
1095
+ try { listener.listen(wanted, host, bound); }
1096
+ catch (err) { finish(reject, err); }
1018
1097
  }),
1019
1098
  port: () => server?.address()?.port ?? null,
1020
1099
  stop() {
1100
+ cancelStart?.();
1021
1101
  for (const s of live) s.destroy();
1022
1102
  live.clear();
1023
1103
  try { server?.close(); } catch { /* not listening */ }
@@ -1038,6 +1118,11 @@ export function createSyncServer({
1038
1118
  */
1039
1119
  export function connectToPeer({
1040
1120
  host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
1121
+ /** False when this deck is reaching an address it already had WITHOUT asking
1122
+ * to pair — an invite-only deck's round. Sent only when false, so every other
1123
+ * hello is byte-for-byte what it was, and a deck that predates the field
1124
+ * reads past it the way it reads past `epk`. */
1125
+ ask = true,
1041
1126
  /** The public key we pinned for this deck the first time, or null for a deck
1042
1127
  * we are meeting — an address somebody typed. */
1043
1128
  expectPub = null,
@@ -1089,6 +1174,7 @@ export function connectToPeer({
1089
1174
  // reads past a field it does not know, and one of this version answers.
1090
1175
  sendFrame(sock, {
1091
1176
  t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
1177
+ ...(ask === false ? { ask: false } : {}),
1092
1178
  });
1093
1179
  });
1094
1180
 
@@ -1332,6 +1332,47 @@ export function accountKey(email, orgUuid) {
1332
1332
  return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
1333
1333
  }
1334
1334
 
1335
+ // ── why a login did not move ────────────────────────────────────────────────
1336
+
1337
+ /**
1338
+ * The reason a sending deck gives for a login it holds and cannot read: on a
1339
+ * Mac, claude-swap could not open the Keychain from the session this deck runs
1340
+ * in. A fixed code, never the CLI's words — export's stdout IS the credential,
1341
+ * and nothing it printed is any business of the deck asking.
1342
+ */
1343
+ export const SENDER_UNREADABLE = "keychain_unavailable";
1344
+
1345
+ /** Every reason `serve` answers a `want` with. */
1346
+ const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, "error"]);
1347
+
1348
+ /**
1349
+ * A peer's refusal, as this deck records it. The panel prints these, so a peer
1350
+ * must not be able to put words in its mouth — least of all one of the HERE
1351
+ * codes, which are sentences about this machine. Anything outside the closed
1352
+ * set is "refused", which is all it ever proved.
1353
+ */
1354
+ export function peerWhy(why) {
1355
+ return typeof why === "string" && WIRE_REFUSALS.has(why) ? why : "refused";
1356
+ }
1357
+
1358
+ /**
1359
+ * What this deck found wrong with a login it received. Produced by the local
1360
+ * adapters only and never read off a frame — see peerWhy — so a code here is
1361
+ * always about THIS machine, and on an arrived row it is a warning rather than
1362
+ * a failure: the credential landed and something about this machine stops it
1363
+ * being used.
1364
+ */
1365
+ export const HERE = Object.freeze({
1366
+ // claude-swap cannot open this Mac's Keychain from the deck's session.
1367
+ unreadable: "unreadable_here",
1368
+ // It landed, and claude-swap still holds no login for it.
1369
+ noLogin: "no_credentials_here",
1370
+ // It landed, and the login it carried was rejected.
1371
+ expired: "relogin_required_here",
1372
+ // It landed, and claude-swap could not be asked whether it is readable.
1373
+ unverified: "unverified_here",
1374
+ });
1375
+
1335
1376
  /**
1336
1377
  * What to do about one account, given what I have and what a peer has.
1337
1378
  *
@@ -1364,7 +1405,7 @@ export function accountKey(email, orgUuid) {
1364
1405
  * nothing here ever asks for that.
1365
1406
  */
1366
1407
  export function syncAction(mine, theirs) {
1367
- if (!theirs || !theirs.alive) return null;
1408
+ if (!theirs || !theirs.alive || theirs.shareable === false) return null;
1368
1409
  if (!mine) return "add";
1369
1410
  return mine.alive ? null : "heal";
1370
1411
  }
@@ -1378,7 +1419,10 @@ export function syncAction(mine, theirs) {
1378
1419
  * different half each time.
1379
1420
  */
1380
1421
  export function plan(local, remote) {
1381
- const mine = new Map(local.map(a => [a.key, a]));
1422
+ // Through onePerKey, not array order: a Map built straight from the rows
1423
+ // keeps the LAST one for a key, so an expired second slot hid a live first
1424
+ // one and every round "healed" a login that works here.
1425
+ const mine = new Map(onePerKey(local).map(a => [a.key, a]));
1382
1426
  const out = [];
1383
1427
  for (const theirs of remote) {
1384
1428
  const action = syncAction(mine.get(theirs.key), theirs);
@@ -1387,14 +1431,43 @@ export function plan(local, remote) {
1387
1431
  return out.sort((a, b) => a.key.localeCompare(b.key));
1388
1432
  }
1389
1433
 
1434
+ /**
1435
+ * One row per identity, and THE rule for which one when claude-swap holds two
1436
+ * slots for the same login: a live copy beats an expired one, then one this
1437
+ * process can read beats one it cannot, then the earlier slot.
1438
+ *
1439
+ * ONE RULE, FOUR PLACES. The manifest this deck sends, the peer manifest it
1440
+ * reads, the export it answers a `want` with, and plan's view of this deck's
1441
+ * own copy all pick through this, so the slot a peer was told about is the
1442
+ * slot it is then handed, and the copy this deck advertises as working is the
1443
+ * one it plans from. Each used to take array order on its own and agreed only
1444
+ * by coincidence — and two slots for one identity became two imports of it in
1445
+ * a single round.
1446
+ */
1447
+ export function onePerKey(rows) {
1448
+ const rank = a => (a.alive ? 2 : 0) + (a.readable === false ? 0 : 1);
1449
+ const best = new Map();
1450
+ for (const a of rows) {
1451
+ const had = best.get(a.key);
1452
+ if (!had || rank(a) > rank(had)) best.set(a.key, a);
1453
+ }
1454
+ return [...best.values()];
1455
+ }
1456
+
1457
+ /** This deck's own slot for one identity, by onePerKey's rule, or null. */
1458
+ export function slotFor(accounts, key) {
1459
+ return onePerKey(accounts.filter(a => a.key === key))[0] ?? null;
1460
+ }
1461
+
1390
1462
  /**
1391
1463
  * What this deck publishes about its own accounts — to the group, and only to
1392
1464
  * the group.
1393
1465
  *
1394
- * Three fields, and `alive` is the only one that is a judgement: it is
1395
- * claude-swap's own verdict on this machine's copy, not a guess. An account
1396
- * this deck cannot use is worth nothing to a peer, so saying so plainly is what
1397
- * stops a peer asking for it.
1466
+ * `alive` says whether the stored copy is still valid. `shareable: false` is
1467
+ * the separate, temporary condition where this process cannot read that valid
1468
+ * copy (for example a locked macOS Keychain). Keeping those facts separate is
1469
+ * what stops another deck treating a healthy-but-inaccessible copy as expired
1470
+ * and repeatedly trying to heal it.
1398
1471
  *
1399
1472
  * Emails are in it, and the manifest is sealed on its way. This said "in the
1400
1473
  * clear inside the encrypted channel" until #810, and there was no such
@@ -1417,9 +1490,13 @@ export function plan(local, remote) {
1417
1490
  */
1418
1491
  export function manifestFor(accounts, shared) {
1419
1492
  const want = new Set(shared);
1420
- return accounts
1421
- .filter(a => want.has(a.key))
1422
- .map(a => ({ key: a.key, email: a.email, alive: !!a.alive }))
1493
+ return onePerKey(accounts.filter(a => want.has(a.key)))
1494
+ .map(a => ({
1495
+ key: a.key,
1496
+ email: a.email,
1497
+ alive: !!a.alive,
1498
+ ...(a.readable === false ? { shareable: false } : {}),
1499
+ }))
1423
1500
  .sort((a, b) => a.key.localeCompare(b.key));
1424
1501
  }
1425
1502