agent-dag 3.16.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{index-DPOZaaRr.css → index-BwFExw1D.css} +1 -1
- package/dist/web/assets/index-CSpWSOAU.js +260 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/index.mjs +55 -2
- package/src/server/lan-engine.mjs +82 -2
- package/src/server/lan-reach.mjs +256 -0
- package/src/server/lan-socket.mjs +12 -0
- package/src/server/lan-sync.mjs +14 -1
- package/dist/web/assets/index-C7_uf54q.js +0 -254
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-CSpWSOAU.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BwFExw1D.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch 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": {
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.17.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "🔔 Pairing is two presses",
|
|
37
|
+
"body": "When another deck asks to pair, the question now arrives **over the deck** rather than in a row three sections down a panel: its name, its address, the fingerprint to compare, **Accept** or **Decline**. Escape means later — the request stays in the panel until you answer it.\n\nThe rest is one list. Every deck you are paired with, every deck on the network, and every one you turned away, each a row with its state under the name and one verb at the end — and a deck that cannot be reached now reads differently from one that is fine. Saying no also reaches the other machine now, instead of leaving it waiting forever; you can take it back.\n\nThe dialog behind **setup** is down to the two things it is really about: what this deck is called, and which logins it offers."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
34
40
|
"3.15.0": [
|
|
35
41
|
{
|
|
36
42
|
"title": "🔗 Decks on one network heal each other's dead logins",
|
package/src/server/index.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { createServer, request as httpRequest } from "node:http";
|
|
|
7
7
|
import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
|
|
8
8
|
import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
|
|
9
9
|
import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
|
|
10
|
-
import { homedir } from "node:os";
|
|
10
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
13
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
@@ -18,6 +18,8 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
18
18
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
19
|
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
20
20
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
21
|
+
import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
|
|
22
|
+
import { run } from "./exec.mjs";
|
|
21
23
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
22
24
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
23
25
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3322,10 +3324,57 @@ async function applyLanPrefs() {
|
|
|
3322
3324
|
}
|
|
3323
3325
|
}
|
|
3324
3326
|
|
|
3327
|
+
// ── can other decks reach this one ──────────────────────────────────────────
|
|
3328
|
+
//
|
|
3329
|
+
// NEVER IN THE REQUEST. The panel polls this route, and the probe is a
|
|
3330
|
+
// PowerShell start-up — the better part of a second on a warm machine and
|
|
3331
|
+
// worse on a cold one. So the route answers with whatever the last probe said
|
|
3332
|
+
// and starts the next one behind it. The first poll after a deck starts
|
|
3333
|
+
// carries no verdict, which is correct rather than merely tolerable: at that
|
|
3334
|
+
// point the deck has not been listening long enough for an empty list to mean
|
|
3335
|
+
// anything either.
|
|
3336
|
+
//
|
|
3337
|
+
// STALE IS THE RIGHT DEFAULT HERE. What this measures — a network's category, a
|
|
3338
|
+
// firewall rule — changes when somebody changes it, which is a thing they do
|
|
3339
|
+
// while looking at the instructions this produced. Five minutes is far tighter
|
|
3340
|
+
// than that, and the panel's own reload picks up the change on the next poll.
|
|
3341
|
+
let reachSaid = null;
|
|
3342
|
+
let reachAt = 0;
|
|
3343
|
+
let reachBusy = false;
|
|
3344
|
+
const REACH_MS = 5 * 60_000;
|
|
3345
|
+
|
|
3346
|
+
function refreshReach() {
|
|
3347
|
+
if (reachBusy || process.platform !== "win32") return;
|
|
3348
|
+
if (reachAt && Date.now() - reachAt < REACH_MS) return;
|
|
3349
|
+
reachBusy = true;
|
|
3350
|
+
run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PROBE_PS], {
|
|
3351
|
+
// The program path goes through the environment rather than into the
|
|
3352
|
+
// script, so a path with a quote in it cannot close the string it sits in.
|
|
3353
|
+
env: { ...process.env, CCDECK_EXE: process.execPath },
|
|
3354
|
+
timeout: 25_000,
|
|
3355
|
+
}).then(r => {
|
|
3356
|
+
reachSaid = reachability({
|
|
3357
|
+
platform: process.platform,
|
|
3358
|
+
probe: r?.ok ? readProbe(r.stdout) : null,
|
|
3359
|
+
aliases: localAliases(networkInterfaces()),
|
|
3360
|
+
exePath: process.execPath,
|
|
3361
|
+
});
|
|
3362
|
+
}).catch(() => {
|
|
3363
|
+
// A probe that did not run says nothing, which is the same answer as a
|
|
3364
|
+
// machine this cannot speak about. It is never an error the panel shows:
|
|
3365
|
+
// nobody asked for it.
|
|
3366
|
+
reachSaid = null;
|
|
3367
|
+
}).finally(() => {
|
|
3368
|
+
reachAt = Date.now();
|
|
3369
|
+
reachBusy = false;
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3325
3373
|
/** What the panel draws: the switch, this deck's own name and address, and who
|
|
3326
3374
|
* else is in the group. No passphrase, for the reason prefsPayload gives. */
|
|
3327
3375
|
function handleLanStatus(req, res) {
|
|
3328
|
-
|
|
3376
|
+
refreshReach();
|
|
3377
|
+
return send(res, 200, { ok: true, ...lanEngine.status(), reach: reachSaid });
|
|
3329
3378
|
}
|
|
3330
3379
|
|
|
3331
3380
|
/**
|
|
@@ -3397,6 +3446,10 @@ async function handleLanPeer(req, res) {
|
|
|
3397
3446
|
}
|
|
3398
3447
|
case "dismiss":
|
|
3399
3448
|
return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
|
|
3449
|
+
// The undo for the one above. A deck that was told no stops asking; this is
|
|
3450
|
+
// how somebody who changed their mind lets it ask again.
|
|
3451
|
+
case "allow":
|
|
3452
|
+
return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
|
|
3400
3453
|
case "unpair":
|
|
3401
3454
|
return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
|
|
3402
3455
|
default:
|
|
@@ -121,12 +121,46 @@ export function createEngine({
|
|
|
121
121
|
*/
|
|
122
122
|
const pending = new Map();
|
|
123
123
|
const strangers = new Map();
|
|
124
|
+
/**
|
|
125
|
+
* Decks somebody here said no to.
|
|
126
|
+
*
|
|
127
|
+
* WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
|
|
128
|
+
* that keeps asking — it dials on its own timer, and every dial that finds no
|
|
129
|
+
* pin here becomes a fresh request. So `dismiss` took a row off a list that
|
|
130
|
+
* the next minute put back, and on the other machine the refusal was
|
|
131
|
+
* indistinguishable from a deck that had not been answered yet: both are the
|
|
132
|
+
* same `pending` refusal on the wire, and both drew "waiting for the other
|
|
133
|
+
* deck to accept this one" forever.
|
|
134
|
+
*
|
|
135
|
+
* A name kept here is therefore two answers at once. This deck stops asking
|
|
136
|
+
* its owner, and the deck that asked is TOLD — see refuse("declined") in
|
|
137
|
+
* lan-socket.mjs, which is the only way the far end can ever learn that the
|
|
138
|
+
* answer was no rather than not yet.
|
|
139
|
+
*
|
|
140
|
+
* In memory, like the two lists above, and reversible: `allow` takes a name
|
|
141
|
+
* out and the requests come back. Nothing about it is written down, because a
|
|
142
|
+
* refusal that outlives the process is a decision nobody can find to undo.
|
|
143
|
+
*/
|
|
144
|
+
const declined = new Map();
|
|
124
145
|
/** The invite this deck is offering, or null. One at a time: a deck showing
|
|
125
146
|
* two tokens is a deck whose owner cannot say which one they sent. */
|
|
126
147
|
let invite = null;
|
|
127
148
|
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
128
149
|
* recent only, because "what happened" is a question about now. */
|
|
129
150
|
const lastRound = new Map();
|
|
151
|
+
/**
|
|
152
|
+
* Why this deck is not listening, when it is switched on and is not.
|
|
153
|
+
*
|
|
154
|
+
* A second deck on one machine takes the first one's port and the bind fails;
|
|
155
|
+
* the switch stays on, the beacon never starts, and the panel drew
|
|
156
|
+
* `starting…` for as long as the process lived. A state that cannot resolve
|
|
157
|
+
* and does not say why is the worst thing an instrument can show — the reader
|
|
158
|
+
* waits, and waiting is the one thing that never fixes it.
|
|
159
|
+
*
|
|
160
|
+
* Only the failures that stop the service reach this. A round that could not
|
|
161
|
+
* reach one peer is that peer's row, not the deck's.
|
|
162
|
+
*/
|
|
163
|
+
let stalled = null;
|
|
130
164
|
|
|
131
165
|
/** This deck's accounts in the shape the rules want. Read through the same
|
|
132
166
|
* function the panel uses, so a row can never be alive here and dead there. */
|
|
@@ -361,13 +395,26 @@ export function createEngine({
|
|
|
361
395
|
onTrust?.(list);
|
|
362
396
|
onChange?.();
|
|
363
397
|
},
|
|
398
|
+
// Asked before the request is drawn, so a deck that was told no is
|
|
399
|
+
// told no again rather than becoming a row somebody has to answer
|
|
400
|
+
// twice. The socket sends the reason; this only knows the name.
|
|
401
|
+
declined: fp => declined.has(fp),
|
|
364
402
|
onPending: entry => {
|
|
365
403
|
const had = pending.get(entry.fp);
|
|
366
404
|
pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
|
|
367
405
|
if (!had) onChange?.();
|
|
368
406
|
},
|
|
369
407
|
});
|
|
370
|
-
|
|
408
|
+
let port;
|
|
409
|
+
try {
|
|
410
|
+
port = await server.start();
|
|
411
|
+
} catch (err) {
|
|
412
|
+
// Kept, so the panel can say it. Rethrown, because the caller's own
|
|
413
|
+
// catch is what leaves the engine stopped rather than half-started.
|
|
414
|
+
stalled = err?.message ?? String(err);
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
stalled = null;
|
|
371
418
|
if (port !== cfg.port) onPort?.(port);
|
|
372
419
|
beacon = createBeacon({
|
|
373
420
|
port, name: cfg.name, fp: identity.fp,
|
|
@@ -533,7 +580,28 @@ export function createEngine({
|
|
|
533
580
|
/** Say no, and stop being asked. The deck is dropped from both lists; if it
|
|
534
581
|
* connects again it is a new request, because refusing is not a block. */
|
|
535
582
|
dismiss(fp) {
|
|
583
|
+
// Whatever the row said, kept — the panel draws a declined deck by name
|
|
584
|
+
// and address, and after the delete below there is nowhere else to read
|
|
585
|
+
// them from.
|
|
586
|
+
const was = pending.get(fp) ?? strangers.get(fp) ?? null;
|
|
536
587
|
const had = pending.delete(fp) || strangers.delete(fp);
|
|
588
|
+
if (had) {
|
|
589
|
+
declined.set(fp, {
|
|
590
|
+
fp,
|
|
591
|
+
name: was?.name ?? fp,
|
|
592
|
+
addr: was?.addr ?? "",
|
|
593
|
+
port: was?.port ?? 0,
|
|
594
|
+
at: now(),
|
|
595
|
+
});
|
|
596
|
+
onChange?.();
|
|
597
|
+
}
|
|
598
|
+
return had;
|
|
599
|
+
},
|
|
600
|
+
/** Change your mind. The name comes off the declined list and the next time
|
|
601
|
+
* that deck dials, it is a request again — which it will, on its own, so
|
|
602
|
+
* there is nothing else to press. */
|
|
603
|
+
allow(fp) {
|
|
604
|
+
const had = declined.delete(fp);
|
|
537
605
|
if (had) onChange?.();
|
|
538
606
|
return had;
|
|
539
607
|
},
|
|
@@ -575,6 +643,9 @@ export function createEngine({
|
|
|
575
643
|
return {
|
|
576
644
|
enabled: !!cfg.enabled,
|
|
577
645
|
running: !!beacon,
|
|
646
|
+
// Said only while it is true, and it is only ever true of a deck that
|
|
647
|
+
// is switched on and has no listener.
|
|
648
|
+
stalled: cfg.enabled && !beacon ? stalled : null,
|
|
578
649
|
name: cfg.name,
|
|
579
650
|
fp: identity?.fp ?? null,
|
|
580
651
|
// The address and port a person on another subnet types into the other
|
|
@@ -597,9 +668,16 @@ export function createEngine({
|
|
|
597
668
|
// per machine, newest first — see pairable, which is where the rule
|
|
598
669
|
// that keeps this from becoming a wall of ghosts lives.
|
|
599
670
|
strangers: (() => {
|
|
600
|
-
|
|
671
|
+
// A deck that was told no is not somebody to offer pairing with. It
|
|
672
|
+
// has its own row, with the one control that undoes the decision.
|
|
673
|
+
const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
|
|
674
|
+
const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
|
|
601
675
|
return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
|
|
602
676
|
})(),
|
|
677
|
+
// Said no to, by somebody at this keyboard. Listed rather than merely
|
|
678
|
+
// silenced, because a refusal nobody can see is a refusal nobody can
|
|
679
|
+
// take back.
|
|
680
|
+
declined: [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
|
|
603
681
|
peers: beacon ? (() => {
|
|
604
682
|
// ONE DECK, ONE ROW, and it takes work because a deck can arrive here
|
|
605
683
|
// twice by two different routes: heard on the network, and dialled at
|
|
@@ -657,6 +735,8 @@ export function createEngine({
|
|
|
657
735
|
},
|
|
658
736
|
stop() {
|
|
659
737
|
if (timer) clearInterval(timer);
|
|
738
|
+
// A deliberate stop is not a fault, and the next start says its own.
|
|
739
|
+
if (!cfg.enabled) stalled = null;
|
|
660
740
|
timer = null;
|
|
661
741
|
beacon?.stop();
|
|
662
742
|
server?.stop();
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Why another deck cannot reach this one — read off the machine, not guessed.
|
|
2
|
+
//
|
|
3
|
+
// THE FAILURE THIS EXISTS FOR. Two decks on one router, LAN sync on at both
|
|
4
|
+
// ends, and one of them shows an empty list forever. Reported as "he sees me
|
|
5
|
+
// and I see nobody", which is the shape that gives it away: Windows lets a
|
|
6
|
+
// process send without asking anybody, and drops unsolicited inbound unless a
|
|
7
|
+
// rule says otherwise. So the beacon leaves, the far deck hears it and draws a
|
|
8
|
+
// row — and the answer coming back is dropped at this machine's edge. Both
|
|
9
|
+
// halves of the feature look broken from here and neither is.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT CANNOT BE FIXED FROM HERE, and this is settled rather than open.
|
|
12
|
+
// Creating a firewall rule needs elevation, and `relay-guard.mjs` already wrote
|
|
13
|
+
// down why no route in this server may raise a password dialog: `index.mjs`
|
|
14
|
+
// deliberately trusts a request carrying no Origin header so hook.js and curl
|
|
15
|
+
// keep working, on the reasoning that a process able to POST to loopback can
|
|
16
|
+
// already run anything as the user. That holds only while no route can do
|
|
17
|
+
// something the caller could not do for itself. One route that elevates hands
|
|
18
|
+
// every local process a way to make an authentication prompt appear wearing
|
|
19
|
+
// ccdeck's name, at a moment ccdeck chose. So this module reads, decides, and
|
|
20
|
+
// hands back a command as TEXT. It is the same shape and the same refusal.
|
|
21
|
+
//
|
|
22
|
+
// WHY IT IS WORTH SAYING ANYTHING AT ALL, given a manual address already makes
|
|
23
|
+
// the feature work without any of this: because "nothing here yet" and "this
|
|
24
|
+
// machine cannot be reached" are different facts, and an empty list says the
|
|
25
|
+
// first while meaning the second. A person reading it concludes the feature is
|
|
26
|
+
// broken, or that the other machine is off. Neither is true and neither is
|
|
27
|
+
// actionable.
|
|
28
|
+
//
|
|
29
|
+
// EVERYTHING BELOW IS A PURE FUNCTION. The probe is one PowerShell call that
|
|
30
|
+
// knows nothing and reports three lists; every judgement is here, where the
|
|
31
|
+
// suite can run it on any platform. That is the same division lan-sync.mjs and
|
|
32
|
+
// lan-socket.mjs already keep, for the same reason.
|
|
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. */
|
|
41
|
+
export const PROBE_PS = [
|
|
42
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
43
|
+
"$nets = @(Get-NetConnectionProfile | ForEach-Object { [pscustomobject]@{ alias=$_.InterfaceAlias; category=[string]$_.NetworkCategory; v4=[string]$_.IPv4Connectivity } })",
|
|
44
|
+
"$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
|
+
// WHICH INTERFACE IS THE LAN, asked of the routing table rather than guessed
|
|
47
|
+
// from a name. The beacon goes to 255.255.255.255, so the interface that
|
|
48
|
+
// carries the broadcast IS the one this feature lives or dies on — and on a
|
|
49
|
+
// machine with Tailscale beside wifi the two have different categories, so
|
|
50
|
+
// picking the wrong one reports Private and clears a machine that is blocked.
|
|
51
|
+
"$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",
|
|
53
|
+
].join("; ");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read what the probe printed, or null.
|
|
57
|
+
*
|
|
58
|
+
* REFUSES RATHER THAN GUESSES. Every field here comes back from a shell on
|
|
59
|
+
* somebody else's machine, where a policy, a missing cmdlet or a locale can
|
|
60
|
+
* turn any of it into something this did not expect. A null answer means the
|
|
61
|
+
* panel says nothing, which is what it did before this module existed — the
|
|
62
|
+
* one outcome that cannot make anything worse.
|
|
63
|
+
*
|
|
64
|
+
* `ConvertTo-Json` collapses a one-element array to a bare object, which is
|
|
65
|
+
* the classic way a Windows probe passes its own tests and fails on the one
|
|
66
|
+
* machine that has exactly one network. Normalised here rather than worked
|
|
67
|
+
* around at each use.
|
|
68
|
+
*/
|
|
69
|
+
export function readProbe(stdout) {
|
|
70
|
+
let raw = null;
|
|
71
|
+
try { raw = JSON.parse(String(stdout ?? "").trim()); }
|
|
72
|
+
catch { return null; }
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
74
|
+
const list = v => (Array.isArray(v) ? v : v == null ? [] : [v]).filter(x => x && typeof x === "object");
|
|
75
|
+
return {
|
|
76
|
+
nets: list(raw.nets).map(n => ({
|
|
77
|
+
alias: typeof n.alias === "string" ? n.alias : "",
|
|
78
|
+
category: typeof n.category === "string" ? n.category : "",
|
|
79
|
+
v4: typeof n.v4 === "string" ? n.v4 : "",
|
|
80
|
+
})).filter(n => n.alias),
|
|
81
|
+
profiles: list(raw.profiles).map(p => ({
|
|
82
|
+
name: typeof p.name === "string" ? p.name : "",
|
|
83
|
+
enabled: p.enabled === true,
|
|
84
|
+
})).filter(p => p.name),
|
|
85
|
+
rules: list(raw.rules).map(r => ({
|
|
86
|
+
direction: typeof r.direction === "string" ? r.direction : "",
|
|
87
|
+
action: typeof r.action === "string" ? r.action : "",
|
|
88
|
+
enabled: r.enabled === true,
|
|
89
|
+
profile: typeof r.profile === "string" ? r.profile : "",
|
|
90
|
+
})),
|
|
91
|
+
bcast: typeof raw.bcast === "string" ? raw.bcast.trim() : "",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The interfaces this machine holds a routable v4 address on, as the names
|
|
96
|
+
* Windows knows them by. `faces` is passed in rather than read, for the reason
|
|
97
|
+
* everything else here is pure. Measured on Windows 11 26200: node's key and
|
|
98
|
+
* `Get-NetConnectionProfile`'s `InterfaceAlias` are the same string, which is
|
|
99
|
+
* what makes matching them worth doing at all. */
|
|
100
|
+
export function localAliases(faces) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const [name, list] of Object.entries(faces ?? {})) {
|
|
103
|
+
for (const n of list ?? []) {
|
|
104
|
+
if (n?.internal) continue;
|
|
105
|
+
if (n?.family !== "IPv4" && n?.family !== 4) continue;
|
|
106
|
+
if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
|
|
107
|
+
if (!out.includes(name)) out.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A connection profile's category is spelled `DomainAuthenticated`; the
|
|
114
|
+
* firewall profile it answers to is spelled `Domain`. One mapping, in one
|
|
115
|
+
* place, because getting it wrong reads the wrong profile's `Enabled` and
|
|
116
|
+
* produces a confident wrong answer rather than a visible failure. */
|
|
117
|
+
export function profileFor(category) {
|
|
118
|
+
const c = String(category ?? "").trim().toLowerCase();
|
|
119
|
+
if (c === "public") return "Public";
|
|
120
|
+
if (c === "private") return "Private";
|
|
121
|
+
if (c === "domainauthenticated" || c === "domain") return "Domain";
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The network this deck is actually on.
|
|
127
|
+
*
|
|
128
|
+
* A machine with Tailscale, a container bridge and wifi has several profiles
|
|
129
|
+
* and only one of them is the LAN somebody means.
|
|
130
|
+
*
|
|
131
|
+
* `aliases` IS IN PREFERENCE ORDER and is walked in that order rather than
|
|
132
|
+
* matched as a set. The caller puts the broadcast route's interface first,
|
|
133
|
+
* because that is the one the beacon actually leaves by; matching whichever
|
|
134
|
+
* happened to come first in the probe's own output is how the machine this was
|
|
135
|
+
* written on reports Tailscale's Private and clears a wifi that is Public.
|
|
136
|
+
*/
|
|
137
|
+
export function lanNet(nets, aliases = []) {
|
|
138
|
+
const rows = Array.isArray(nets) ? nets : [];
|
|
139
|
+
for (const a of (Array.isArray(aliases) ? aliases : [])) {
|
|
140
|
+
const want = String(a ?? "").trim().toLowerCase();
|
|
141
|
+
if (!want) continue;
|
|
142
|
+
const hit = rows.find(n => n.alias.toLowerCase() === want);
|
|
143
|
+
if (hit) return hit;
|
|
144
|
+
}
|
|
145
|
+
return rows.find(n => n.v4 && n.v4 !== "Disconnected") ?? rows[0] ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Does an inbound allow rule for this program already cover that profile?
|
|
149
|
+
* A rule's `Profile` is a comma-joined list, and `Any` means all of them. */
|
|
150
|
+
export function ruleCovers(rules, profileName) {
|
|
151
|
+
const want = String(profileName ?? "").toLowerCase();
|
|
152
|
+
return (Array.isArray(rules) ? rules : []).some(r => {
|
|
153
|
+
if (!r.enabled) return false;
|
|
154
|
+
if (r.direction.toLowerCase() !== "inbound") return false;
|
|
155
|
+
if (r.action.toLowerCase() !== "allow") return false;
|
|
156
|
+
const on = r.profile.split(",").map(s => s.trim().toLowerCase());
|
|
157
|
+
return on.includes("any") || on.includes(want);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The command that would change it — as text, for a person to paste into an
|
|
163
|
+
* elevated shell. Never run from here; see the header.
|
|
164
|
+
*
|
|
165
|
+
* ONE RULE, SCOPED TO THE PROGRAM. It covers the discovery socket and the sync
|
|
166
|
+
* listener at once, and it goes on covering the sync listener after a restart
|
|
167
|
+
* moves it — a rule written against today's port is a rule that works today.
|
|
168
|
+
*
|
|
169
|
+
* The category line is only offered when the network is Public, and it is
|
|
170
|
+
* offered rather than assumed: Public is the right setting for a café and the
|
|
171
|
+
* wrong one for the router at home, and this module cannot tell which one
|
|
172
|
+
* somebody is sitting in. The panel says so beside it.
|
|
173
|
+
*/
|
|
174
|
+
export function fixSteps({ category, alias, exePath }) {
|
|
175
|
+
const steps = [];
|
|
176
|
+
if (profileFor(category) === "Public" && alias) {
|
|
177
|
+
steps.push(`Set-NetConnectionProfile -InterfaceAlias "${alias}" -NetworkCategory Private`);
|
|
178
|
+
}
|
|
179
|
+
steps.push(
|
|
180
|
+
"New-NetFirewallRule -DisplayName \"ccdeck (local network)\" -Direction Inbound"
|
|
181
|
+
+ ` -Program "${exePath}" -Action Allow -Profile Private`,
|
|
182
|
+
);
|
|
183
|
+
return steps;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether other decks can reach this one, and what to do when they cannot.
|
|
188
|
+
*
|
|
189
|
+
* `null` means "no opinion", and it is the answer for every machine this cannot
|
|
190
|
+
* speak about: anything that is not Windows, and any probe that did not come
|
|
191
|
+
* back. Silence rather than a hedge — a panel line that says "possibly" about a
|
|
192
|
+
* thing it did not measure is worse than no line.
|
|
193
|
+
*
|
|
194
|
+
* `blocked: false` is a real finding and is worth returning: it lets the panel
|
|
195
|
+
* stop blaming the firewall for an empty list and say the other thing instead,
|
|
196
|
+
* which is that nobody else is running.
|
|
197
|
+
*/
|
|
198
|
+
export function reachability({ platform, probe, aliases = [], exePath = "" } = {}) {
|
|
199
|
+
if (platform !== "win32") return null;
|
|
200
|
+
if (!probe) return null;
|
|
201
|
+
// The broadcast route first, then the interfaces the deck holds an address
|
|
202
|
+
// on. The routing table is the authority on where a beacon goes; the deck's
|
|
203
|
+
// own addresses are the fallback for a machine where that read came back
|
|
204
|
+
// empty, and the probe's first connected network is the fallback for that.
|
|
205
|
+
const net = lanNet(probe.nets, [probe.bcast, ...aliases]);
|
|
206
|
+
const category = net?.category ?? "";
|
|
207
|
+
const name = profileFor(category);
|
|
208
|
+
if (!name) return null;
|
|
209
|
+
const prof = probe.profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
210
|
+
// A firewall that is off blocks nothing, and saying otherwise would send
|
|
211
|
+
// somebody to add a rule that changes nothing.
|
|
212
|
+
if (prof && !prof.enabled) return { blocked: false, why: "firewall off", category, alias: net?.alias ?? "" };
|
|
213
|
+
if (ruleCovers(probe.rules, name)) {
|
|
214
|
+
return { blocked: false, why: "rule present", category, alias: net?.alias ?? "" };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
blocked: true,
|
|
218
|
+
why: "no inbound rule",
|
|
219
|
+
category,
|
|
220
|
+
alias: net?.alias ?? "",
|
|
221
|
+
// The reason in the reader's terms, not the registry's. What they need to
|
|
222
|
+
// know is which half is broken, because the other half is what makes the
|
|
223
|
+
// workaround below obvious rather than magic.
|
|
224
|
+
text: name === "Public"
|
|
225
|
+
? "This network is set to Public, and Windows drops what other decks send. They can still hear this deck — that is why one of them may already show it."
|
|
226
|
+
: "Windows has no inbound rule for this deck, so it drops what other decks send. They can still hear it — that is why one of them may already show this machine.",
|
|
227
|
+
steps: fixSteps({ category, alias: net?.alias ?? "", exePath }),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The way out that needs no rule at all, and the reason this module is not the
|
|
233
|
+
* whole answer.
|
|
234
|
+
*
|
|
235
|
+
* A round is one OUTBOUND connection — `roundWith` dials, both sides prove
|
|
236
|
+
* themselves over that socket, and the manifest and any transfer ride it home.
|
|
237
|
+
* Nothing in a round needs this machine to accept a connection. So a deck that
|
|
238
|
+
* cannot be reached can still do every part of this by dialling first, and the
|
|
239
|
+
* two controls that make it dial are an address somebody typed and an invite
|
|
240
|
+
* somebody else minted.
|
|
241
|
+
*
|
|
242
|
+
* WHICH WAY THE INVITE GOES IS THE WHOLE OF IT, and nothing said so before.
|
|
243
|
+
* An invite carries the addresses of the deck that MINTED it, and the deck that
|
|
244
|
+
* PASTES it dials them. So a blocked machine that mints an invite is asking to
|
|
245
|
+
* be called, which is the one thing it cannot receive; the same pair works on
|
|
246
|
+
* the first try if the invite is minted at the other end and pasted here. Same
|
|
247
|
+
* two controls, opposite order, and only one of the orders works.
|
|
248
|
+
*/
|
|
249
|
+
export function workaround(blocked) {
|
|
250
|
+
if (!blocked) return null;
|
|
251
|
+
return {
|
|
252
|
+
dial: "This deck can still reach out, and one connection is all a round needs.",
|
|
253
|
+
invite: "Ask them to make the invite and paste it here — an invite is dialled by whoever pastes it, so it has to be pasted on this machine rather than made on it.",
|
|
254
|
+
address: "Or type their address below, and this deck will call them.",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -253,6 +253,11 @@ export function createSyncServer({
|
|
|
253
253
|
* is therefore a real deck rather than a port scan. The panel turns this
|
|
254
254
|
* into a row with an accept on it. */
|
|
255
255
|
onPending,
|
|
256
|
+
/** Was this deck already told no? A refusal has to be sent rather than
|
|
257
|
+
* merely held, or the far end reads "declined" and "not answered yet" as one
|
|
258
|
+
* silent wait — they are the same frame otherwise, and only one of them ever
|
|
259
|
+
* comes right by waiting. */
|
|
260
|
+
declined = () => false,
|
|
256
261
|
/** The invite this deck is currently offering, or null. A caller that proves
|
|
257
262
|
* it holds the code is somebody the owner handed a token to, so it is paired
|
|
258
263
|
* on arrival rather than queued behind a press. */
|
|
@@ -394,6 +399,12 @@ export function createSyncServer({
|
|
|
394
399
|
return;
|
|
395
400
|
}
|
|
396
401
|
}
|
|
402
|
+
// A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
|
|
403
|
+
// It is not asked again here, and — the half a held refusal cannot
|
|
404
|
+
// do — the deck that asked is told, so its own panel can stop saying
|
|
405
|
+
// "waiting" about a question that has been answered.
|
|
406
|
+
if (declined(peerFp)) return refuse("declined");
|
|
407
|
+
|
|
397
408
|
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
398
409
|
// a port scan, and it told us a name and an address a person can
|
|
399
410
|
// recognise. That is a row with an accept on it, and nothing else
|
|
@@ -525,6 +536,7 @@ export function connectToPeer({
|
|
|
525
536
|
if (msg.t === "no") {
|
|
526
537
|
return fail(new Error({
|
|
527
538
|
pending: "waiting for the other deck to accept this one",
|
|
539
|
+
declined: "that deck said no",
|
|
528
540
|
impostor: "that deck has this one pinned under a different key",
|
|
529
541
|
"bad proof": "the other deck refused this one's proof",
|
|
530
542
|
}[msg.why] ?? "the other deck refused this handshake"));
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -564,9 +564,22 @@ export function dropTrusted(trusted, fp) {
|
|
|
564
564
|
* NEWEST FIRST, AND CAPPED. The deck somebody just started is the one they are
|
|
565
565
|
* looking for; a list longer than a screen is a list nobody reads.
|
|
566
566
|
*/
|
|
567
|
-
export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8 } = {}) {
|
|
567
|
+
export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mine = [] } = {}) {
|
|
568
|
+
// NOT THIS MACHINE. A deck's own beacon is filtered by fingerprint, which is
|
|
569
|
+
// right and is not enough: a second deck on the same computer is a different
|
|
570
|
+
// process with a different key, so it passes that check honestly and then
|
|
571
|
+
// shows up in the list under this machine's own hostname, at this machine's
|
|
572
|
+
// own address, offering to pair with itself.
|
|
573
|
+
//
|
|
574
|
+
// It was reported from a screenshot — "why myself appear here in list" — and
|
|
575
|
+
// the answer is that the address is the one thing that cannot lie: a beacon
|
|
576
|
+
// arriving FROM an address this machine holds came from this machine. Pairing
|
|
577
|
+
// with it would also buy nothing, because both decks read one claude-swap
|
|
578
|
+
// store and there is nothing for either to heal.
|
|
579
|
+
const own = new Set(Array.isArray(mine) ? mine : []);
|
|
568
580
|
const live = [...(strangers ?? [])]
|
|
569
581
|
.filter(p => p && typeof p.at === "number" && now - p.at <= presentMs)
|
|
582
|
+
.filter(p => !own.has(p.addr))
|
|
570
583
|
.sort((a, b) => b.at - a.at);
|
|
571
584
|
const seen = new Set();
|
|
572
585
|
const out = [];
|