ccdeck 3.26.1 → 3.26.4

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.
@@ -44,7 +44,7 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-CO8B_qBe.js"></script>
47
+ <script type="module" crossorigin src="/assets/index-CPHonXg4.js"></script>
48
48
  <link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
49
49
  </head>
50
50
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.26.1",
3
+ "version": "3.26.4",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,9 @@
45
45
  "3.23.3",
46
46
  "3.23.4",
47
47
  "3.26.0",
48
- "3.26.1"
48
+ "3.26.1",
49
+ "3.26.3",
50
+ "3.26.4"
49
51
  ],
50
52
  "3.25.0": [
51
53
  {
@@ -415,6 +415,48 @@ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
415
415
  return queued(() => save(mutate, home, deps));
416
416
  }
417
417
 
418
+ /**
419
+ * The two `mutate`s the three callers above hand updatePrefs, named so that the
420
+ * callers and prefs-update-1041.test.ts run the same function. The suite used to
421
+ * carry its own copy of each closure and test the copy (#1168), which stays
422
+ * green however the one in index.mjs is edited.
423
+ */
424
+
425
+ /** `lan.manual` with `entry` on the end, or no change when it is there already.
426
+ * What `onDial` and the accept route both write. */
427
+ export function withManualEntry(entry) {
428
+ return prev => {
429
+ const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
430
+ return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
431
+ };
432
+ }
433
+
434
+ /**
435
+ * `lan.shared` with `key` on the end, or no change when it is already there.
436
+ *
437
+ * The tick an account gets when it ARRIVES over the local network (#1188): the
438
+ * group already has that login, so a deck that was just given one is as useful
439
+ * to the next machine as the deck that gave it. Only on arrival — an untick
440
+ * afterwards is the person's answer and nothing re-ticks it, because an account
441
+ * this deck already holds is never added again.
442
+ */
443
+ export function withShared(key) {
444
+ return prev => {
445
+ const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
446
+ return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key] } };
447
+ };
448
+ }
449
+
450
+ /** `lan.aliases` with `fp` called `name`, or without `fp` when the name is
451
+ * empty. The alias route's write; the whole map, rebuilt from the one read. */
452
+ export function withAlias(fp, name) {
453
+ return prev => {
454
+ const next = { ...(prev?.lan?.aliases ?? {}) };
455
+ if (name) next[fp] = name; else delete next[fp];
456
+ return { lan: { aliases: next } };
457
+ };
458
+ }
459
+
418
460
  /** One read-modify-write, behind every other one. Both entry points go through
419
461
  * here so there is a single queue and a single merge. */
420
462
  function queued(job) {
@@ -34,7 +34,7 @@ import { createOutputWatch } from "./output-watch.mjs";
34
34
  import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
35
35
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
36
36
  import { createPresence } from "./presence.mjs";
37
- import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
37
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
38
38
  import { createEngine, defaultName } from "./lan-engine.mjs";
39
39
  import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
40
40
  import { portHolder } from "./port-holder.mjs";
@@ -3962,6 +3962,19 @@ const lanEngine = createEngine({
3962
3962
  // listener had one is missing half of them — see forgetReach.
3963
3963
  forgetReach();
3964
3964
  },
3965
+ // An account arrived from a paired deck, and this deck now offers it too
3966
+ // (#1188). Written before the engine hears it, so a restart between the two
3967
+ // leaves the tick on disk rather than only in the running engine's copy.
3968
+ onShared: async key => {
3969
+ try {
3970
+ const before = _prefs;
3971
+ _prefs = await updatePrefs(withShared(key));
3972
+ if (_prefs?.lan?.shared === before?.lan?.shared) return; // it was already ticked
3973
+ await lanEngine.apply({ shared: _prefs.lan.shared });
3974
+ } catch (err) {
3975
+ console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
3976
+ }
3977
+ },
3965
3978
  // A deck was accepted or unpaired. Written straight through, because the
3966
3979
  // trusted list is the whole of who this deck will talk to and a list that
3967
3980
  // only existed in memory would drop every pairing on restart.
@@ -3982,10 +3995,7 @@ const lanEngine = createEngine({
3982
3995
  // is the WHOLE array, so a stale one wins. Pressing accept on a heard deck
3983
3996
  // at the moment an invite round fires this dropped the dialled address out
3984
3997
  // of `lan.manual`, which is the failure the comment above describes.
3985
- _prefs = await updatePrefs(prev => {
3986
- const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
3987
- return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
3988
- });
3998
+ _prefs = await updatePrefs(withManualEntry(entry));
3989
3999
  // AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
3990
4000
  // disk is now right, but `setPeers` replaces the list wholesale from
3991
4001
  // `_prefs` on every settings write — so a write that raced this one, and
@@ -4360,12 +4370,7 @@ async function handleLanPeer(req, res) {
4360
4370
  // Inside the job, like `onDial` — the same whole-array patch computed
4361
4371
  // from the same stale copy, and the same address lost when two of them
4362
4372
  // land in one turn.
4363
- try {
4364
- _prefs = await updatePrefs(prev => {
4365
- const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
4366
- return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
4367
- });
4368
- }
4373
+ try { _prefs = await updatePrefs(withManualEntry(entry)); }
4369
4374
  catch { /* it is dialled this session; the next accept re-adds it */ }
4370
4375
  }
4371
4376
  return send(res, 200, { ok: true, added, ...lanEngine.status() });
@@ -4391,11 +4396,7 @@ async function handleLanPeer(req, res) {
4391
4396
  case "alias": {
4392
4397
  if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
4393
4398
  const name = cleanAlias(body.name);
4394
- _prefs = await updatePrefs(prev => {
4395
- const next = { ...(prev?.lan?.aliases ?? {}) };
4396
- if (name) next[fp] = name; else delete next[fp];
4397
- return { lan: { aliases: next } };
4398
- });
4399
+ _prefs = await updatePrefs(withAlias(fp, name));
4399
4400
  await lanEngine.apply({ aliases: _prefs.lan.aliases });
4400
4401
  return send(res, 200, { ok: true, ...lanEngine.status() });
4401
4402
  }
@@ -6756,10 +6757,25 @@ export function requestUrl(rawUrl) {
6756
6757
  // So the Host check runs for every method now. What it asks is only the
6757
6758
  // rebinding question — did this request arrive addressed to a name that can
6758
6759
  // only ever be this machine — and it asks it of browser-shaped requests alone,
6759
- // meaning anything carrying an Origin or fetch metadata. A client sending
6760
- // neither is not a page and has no ambient authority to borrow: that is
6761
- // hook/hook.js, a plain Node http.request from the user's own machine, and it
6762
- // keeps reaching the deck under whatever name it used before.
6760
+ // meaning anything carrying an Origin, fetch metadata or a Referer. A client
6761
+ // sending none of them is not a page and has no ambient authority to borrow:
6762
+ // that is hook/hook.js, a plain Node http.request from the user's own machine,
6763
+ // and it keeps reaching the deck under whatever name it used before.
6764
+ //
6765
+ // THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
6766
+ // leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
6767
+ // sends no Sec-Fetch-Site — the premise isAuthorizedDataRead's fallback is
6768
+ // built on — so a rebound page on that browser sent `Host: attacker.example:
6769
+ // 4317` and `Referer: http://attacker.example:4317/` and nothing else, was not
6770
+ // browser-shaped by the test as it stood, and was answered. Measured against a
6771
+ // running deck before this line changed: /api/health (the absolute workspace
6772
+ // path), /api/hook-challenge (the proof oracle, which handleHookChallenge says
6773
+ // a rebound page cannot see), /api/system/processes and the page itself all
6774
+ // answered 200. The guarded reads held only because isAuthorizedDataRead
6775
+ // happens to test the Host before it reads the Referer. No client of this
6776
+ // server that is not a browser sends a Referer — hook.js, the desktop app and
6777
+ // bin/ send none — so nothing that reached the deck before is turned away by
6778
+ // this, and the deck's own page names a loopback Host whatever it sends.
6763
6779
  //
6764
6780
  // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
6765
6781
  // applies. `cross-site` on a read is an ordinary top-level navigation — a link
@@ -6767,9 +6783,10 @@ export function requestUrl(rawUrl) {
6767
6783
  // the deck's own UI on the deck's own origin, which is not an attack and used
6768
6784
  // to work. Rebinding does not need that test either: a rebound page's requests
6769
6785
  // report `same-origin`, and it is the Host that gives it away.
6770
- export function isTrustedRead({ origin, host, secFetchSite } = {}) {
6786
+ export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
6771
6787
  const browserShaped = (typeof origin === "string" && origin !== "")
6772
- || (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
6788
+ || (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
6789
+ || (typeof referer === "string" && referer.trim() !== "");
6773
6790
  if (!browserShaped) return true;
6774
6791
  return isLoopbackHost(host);
6775
6792
  }
@@ -7377,6 +7394,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7377
7394
  origin: req.headers.origin,
7378
7395
  host: req.headers.host,
7379
7396
  secFetchSite: req.headers["sec-fetch-site"],
7397
+ referer: req.headers.referer,
7380
7398
  })) {
7381
7399
  return send(res, 403, { error: "cross-site request blocked" });
7382
7400
  }
@@ -194,9 +194,26 @@ export function heardCurrent(raw, list) {
194
194
  * Injected rather than imported so a test can run a whole round — two engines,
195
195
  * two fake stores, one real socket pair — without claude-swap on the machine.
196
196
  */
197
+ /**
198
+ * Whether an account this round just placed is ticked for sharing here (#1188).
199
+ *
200
+ * ONLY AN ADD: a heal needed the tick to happen at all — roundWith asks for a
201
+ * heal only for an account this deck already shares — so there is nothing to
202
+ * add for one, and an account healed after somebody unticked it must not be
203
+ * ticked again behind them.
204
+ *
205
+ * ONLY FROM THE LOCAL NETWORK: the reasoning for the default is that the login
206
+ * came from the group and the group therefore has it, and a tailnet can reach
207
+ * further than one person's own machines. Sharing there stays a decision
208
+ * somebody makes rather than one an arrival makes for them.
209
+ */
210
+ export function ticksOnArrival(step, via) {
211
+ return !!step?.key && step.action === "add" && via !== "tailscale";
212
+ }
213
+
197
214
  export function createEngine({
198
215
  readAccounts, exportAccount, importAccount,
199
- onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
216
+ onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
200
217
  /**
201
218
  * The UDP socket the beacon shouts through, injectable for the same reason
202
219
  * lan-socket exposes it — and for one more that only showed up in use.
@@ -351,6 +368,13 @@ export function createEngine({
351
368
  * still be said to come over the tailnet or the local network — it has no
352
369
  * address of its own here, and without this its row could not say which. */
353
370
  const spokeFrom = new Map();
371
+ /** Addresses this deck added because a paired deck called in from them and
372
+ * nothing here dialled it — see learnCaller. Kept until a round proves the
373
+ * address answers: one that does is an ordinary dialled peer from then on
374
+ * and leaves this set; one that does not is a caller this deck cannot reach
375
+ * back (a strict NAT, a one-way path), and its row is taken away again so it
376
+ * reverts to "calls in" rather than failing every round. */
377
+ const calledBack = new Set();
354
378
  /**
355
379
  * When a connection from ANOTHER MACHINE last arrived on the sync listener.
356
380
  *
@@ -457,13 +481,59 @@ export function createEngine({
457
481
  * connection's — see sessionKey. `ctx.send` seals whatever it is handed when
458
482
  * both ends said they seal, so nothing below has to know which kind of deck
459
483
  * asked — see frameChannel. */
484
+ /** Does this deck already hold an address it dials for `fp`? A beacon row it
485
+ * still hears, or a typed/learned row that answered as that deck. When
486
+ * neither is true, the only way it ever reaches that deck is if the deck
487
+ * keeps calling — and a called deck is never pulled from. */
488
+ const dialsAlready = fp => {
489
+ if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
490
+ for (const [at, met] of learned) if (met?.fp === fp && manual.has(at)) return true;
491
+ return false;
492
+ };
493
+
494
+ /**
495
+ * A PAIRED DECK THAT CALLS IN, AND NOTHING HERE DIALS IT.
496
+ *
497
+ * Accounts move only toward the deck that dials — roundWith pulls, serve only
498
+ * answers — so a deck this one holds no address for can offer everything and
499
+ * this one takes nothing. It is the exact state a deck falls into when it
500
+ * cannot hear beacons (a firewall, or Tailscale holding the discovery port):
501
+ * every peer becomes one that only calls, and no account ever arrives.
502
+ *
503
+ * The call itself is the address. The peer connected FROM somewhere and said
504
+ * in its hello which port it LISTENS on, and that pair is dialable. Adding it
505
+ * makes the next round reach the caller and pull — the same dial-back that
506
+ * accepting a deck and joining by invite already do, extended to a peer that
507
+ * simply calls. In memory, like those two: a settings write clears it and the
508
+ * next call re-adds it, and nothing here writes an address to disk.
509
+ */
510
+ const learnCaller = ctx => {
511
+ const fp = ctx?.peerFp;
512
+ const at = ctx?.peerAddr;
513
+ const port = ctx?.peerPort;
514
+ if (!fp || !at || !port || !engine) return;
515
+ if (!trustedPeer(cfg.trusted, fp)) return;
516
+ if (dialsAlready(fp)) return;
517
+ // As a row the deck ADDED ITSELF, not one a person typed: capped like every
518
+ // other automatic row, and — through calledBack — taken away again if the
519
+ // address turns out not to answer. The caller is already trusted, so the
520
+ // round dials and pulls without a press; `typed` decides only the cap and
521
+ // the undo, never the trust. See roundWith.
522
+ if (engine.addPeer(at, port, { typed: false })) {
523
+ learned.set(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
524
+ calledBack.add(`${at}:${port}`);
525
+ onChange?.();
526
+ }
527
+ };
528
+
460
529
  const serve = async (msg, ctx) => {
461
530
  // Before the verbs, and for every one of them: something that proved it
462
531
  // holds a key this deck accepted is talking, now.
463
532
  if (ctx?.peerFp) {
464
533
  spokeAt.set(ctx.peerFp, now());
465
- const from = String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
534
+ const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
466
535
  if (from) spokeFrom.set(ctx.peerFp, from);
536
+ learnCaller(ctx);
467
537
  }
468
538
  try {
469
539
  if (msg.t === "manifest") {
@@ -562,6 +632,10 @@ export function createEngine({
562
632
  const waitingOnSomebody = () =>
563
633
  [...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
564
634
 
635
+ /** How this deck reached that peer. A beacon row says so; a typed address is
636
+ * read from the routing table, the way the peer list reads it. */
637
+ const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
638
+
565
639
  /** Ask one peer what it has, and heal whatever it can heal. */
566
640
  const roundWith = async peer => {
567
641
  let conn = null;
@@ -633,6 +707,10 @@ export function createEngine({
633
707
  // from then on, because "Constantin-PC" is what the person who typed the
634
708
  // address was trying to reach.
635
709
  learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
710
+ // A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
711
+ // until it proved the deck can reach it; from here it is dialled like any
712
+ // other and is no longer a candidate for the undo below. See learnCaller.
713
+ calledBack.delete(`${peer.addr}:${peer.port}`);
636
714
 
637
715
  // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
638
716
  // deck we have no pin for used to mean the person at this keyboard put
@@ -738,6 +816,22 @@ export function createEngine({
738
816
  // than as a healthy one.
739
817
  const got = await importAccount(blob, step);
740
818
  const ok = got === true || got?.ok === true;
819
+ // AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
820
+ // to tick it, and a group where one machine can heal the others and the
821
+ // others can heal nobody is the shape that costs them: the second
822
+ // machine to lose the same login has to go back to the first, which may
823
+ // be asleep or on another network. Nothing new is exposed — the login
824
+ // came FROM the group, so the group has it.
825
+ //
826
+ // ONLY AN ADD, and only from the local network. A heal already needed
827
+ // the tick to happen at all (the filter above), so there is nothing to
828
+ // add for one; and a tailnet reaches further than the person's own
829
+ // machines, which is a decision they make for themselves rather than
830
+ // one an arrival makes for them.
831
+ if (ok && ticksOnArrival(step, viaOf(peer))) {
832
+ try { await onShared?.(step.key); }
833
+ catch { /* the account is here; the tick is retried the next time one arrives */ }
834
+ }
741
835
  done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
742
836
  }
743
837
  lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
@@ -745,6 +839,20 @@ export function createEngine({
745
839
  return done;
746
840
  } catch (err) {
747
841
  lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
842
+ // A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
843
+ // from a paired deck's inbound call, and this round was the test of
844
+ // whether the call can be returned. It could not — a strict NAT, a
845
+ // one-way path — so the row is removed rather than left to fail every
846
+ // minute, and the peer goes back to "calls in". Its next call tries once
847
+ // more. A row that answered has already left calledBack above.
848
+ const at = `${peer.addr}:${peer.port}`;
849
+ if (calledBack.has(at)) {
850
+ calledBack.delete(at);
851
+ manual.delete(at);
852
+ learned.delete(at);
853
+ lastRound.delete(peer.fp);
854
+ onChange?.();
855
+ }
748
856
  return [];
749
857
  } finally {
750
858
  conn?.sock?.destroy();
@@ -31,25 +31,35 @@
31
31
  // suite can run it on any platform. That is the same division lan-sync.mjs and
32
32
  // lan-socket.mjs already keep, for the same reason.
33
33
 
34
- /** What the probe asks Windows for. Three reads, none of them privileged —
35
- * measured: `Get-NetConnectionProfile`, `Get-NetFirewallProfile` and
36
- * `Get-NetFirewallApplicationFilter` all answer for an ordinary user, while
37
- * `Get-NetFirewallPortFilter` returns "Access is denied". So the rule this
38
- * looks for is scoped to a PROGRAM rather than to a port, which is also the
39
- * only kind that keeps working: the sync listener's port is ephemeral and is
40
- * a different number after every restart. */
34
+ /** What the probe asks Windows for. `Get-NetConnectionProfile` and
35
+ * `Get-NetFirewallProfile` answer for an ordinary user; `Get-NetFirewallPortFilter`
36
+ * returns "Access is denied", which is why the rule this looks for is scoped
37
+ * to a PROGRAM rather than a port — also the only kind that keeps working,
38
+ * since the sync listener's port is ephemeral and different after every
39
+ * restart. `Get-NetFirewallApplicationFilter` answers on many machines but is
40
+ * denied on some domain-managed ones, so it is wrapped and its failure carried
41
+ * through as `rulesReadable=$false` rather than silently read as "no rule". */
41
42
  export const PROBE_PS = [
42
43
  "$ErrorActionPreference='SilentlyContinue'",
43
44
  "$nets = @(Get-NetConnectionProfile | ForEach-Object { [pscustomobject]@{ alias=$_.InterfaceAlias; category=[string]$_.NetworkCategory; v4=[string]$_.IPv4Connectivity } })",
44
45
  "$profiles = @(Get-NetFirewallProfile | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; enabled=[bool]$_.Enabled } })",
45
- "$rules = @(Get-NetFirewallApplicationFilter | Where-Object { $_.Program -eq $env:CCDECK_EXE } | ForEach-Object { $_ | Get-NetFirewallRule } | ForEach-Object { [pscustomobject]@{ direction=[string]$_.Direction; action=[string]$_.Action; enabled=[bool]$_.Enabled; profile=[string]$_.Profile } })",
46
+ // AN EMPTY LIST HAS TWO MEANINGS, AND THEY ARE OPPOSITE. `Get-NetFirewallApplicationFilter`
47
+ // answers for an ordinary user on some machines and throws "Access is denied"
48
+ // on others (a domain-managed box, measured). With errors silenced it came
49
+ // back empty either way — and empty was read as "no rule", so a machine whose
50
+ // rule the deck simply could not see was told, wrongly, that it had none, and
51
+ // handed a command that added one more every time it was run. Wrapped so the
52
+ // throw is caught and reported as `rulesReadable=$false`: the difference
53
+ // between "there is no rule" and "I was not allowed to look".
54
+ "$rulesReadable=$true",
55
+ "try { $rules = @(Get-NetFirewallApplicationFilter -ErrorAction Stop | Where-Object { $_.Program -eq $env:CCDECK_EXE } | ForEach-Object { $_ | Get-NetFirewallRule } | ForEach-Object { [pscustomobject]@{ direction=[string]$_.Direction; action=[string]$_.Action; enabled=[bool]$_.Enabled; profile=[string]$_.Profile } }) } catch { $rulesReadable=$false; $rules=@() }",
46
56
  // WHICH INTERFACE IS THE LAN, asked of the routing table rather than guessed
47
57
  // from a name. The beacon goes to 255.255.255.255, so the interface that
48
58
  // carries the broadcast IS the one this feature lives or dies on — and on a
49
59
  // machine with Tailscale beside wifi the two have different categories, so
50
60
  // picking the wrong one reports Private and clears a machine that is blocked.
51
61
  "$bcast = [string](Find-NetRoute -RemoteIPAddress 255.255.255.255 | Select-Object -First 1 -ExpandProperty InterfaceAlias)",
52
- "[pscustomobject]@{ nets=$nets; profiles=$profiles; rules=$rules; bcast=$bcast } | ConvertTo-Json -Depth 4 -Compress",
62
+ "[pscustomobject]@{ nets=$nets; profiles=$profiles; rules=$rules; rulesReadable=$rulesReadable; bcast=$bcast } | ConvertTo-Json -Depth 4 -Compress",
53
63
  ].join("; ");
54
64
 
55
65
  /**
@@ -88,6 +98,9 @@ export function readProbe(stdout) {
88
98
  enabled: r.enabled === true,
89
99
  profile: typeof r.profile === "string" ? r.profile : "",
90
100
  })),
101
+ // True unless the probe said it could not read the rules — and true for an
102
+ // older probe that did not report it, which kept working as it always did.
103
+ rulesReadable: raw.rulesReadable !== false,
91
104
  bcast: typeof raw.bcast === "string" ? raw.bcast.trim() : "",
92
105
  };
93
106
  }
@@ -479,6 +492,16 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
479
492
  if (ruleCovers(probe.rules, name)) {
480
493
  return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
481
494
  }
495
+ // COULD NOT READ THE RULES, SO CANNOT SAY THERE IS NONE. An empty list here
496
+ // is "Access is denied", not "no rule" (see PROBE_PS), so claiming a missing
497
+ // rule would be a guess dressed as a read — and the command that guess hands
498
+ // over adds a duplicate rule on a machine that may already be covered. On a
499
+ // Public network the fix is the category, which was read reliably, so that
500
+ // verdict still stands; anywhere else the honest answer is the measurement,
501
+ // which `refreshReach` falls back to when this returns null.
502
+ // Only an EMPTY list is ambiguous — a rule in hand was plainly readable,
503
+ // whatever the flag says.
504
+ if (!probe.rulesReadable && probe.rules.length === 0 && name !== "Public") return null;
482
505
  return {
483
506
  blocked: true,
484
507
  why: "no inbound rule",
@@ -967,6 +967,15 @@ export function createSyncServer({
967
967
  if (!frame) return refuse("a sealed frame did not open");
968
968
  handlers?.(frame, {
969
969
  sock, peerFp, key, sealed: !!chan,
970
+ // Where this deck dials the caller BACK. A deck that only ever calls in
971
+ // is one this deck holds no address for, so it could receive nothing —
972
+ // accounts move only toward the deck that dials (see roundWith). The
973
+ // address the caller connected from, and the port it said it listens
974
+ // on, are a dialable pair the engine can add so the next round reaches
975
+ // it. `peerAddr` is the source of this very connection; `peerPort` came
976
+ // from the hello, not the ephemeral source port.
977
+ peerAddr: from(sock),
978
+ peerPort,
970
979
  send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
971
980
  });
972
981
  }, refuse));