ccdeck 3.25.0 → 3.26.1

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.
@@ -23,6 +23,7 @@
23
23
  // address.
24
24
  import dgram from "node:dgram";
25
25
  import { networkInterfaces } from "node:os";
26
+ import { looksLikeTunnel } from "./route-via.mjs";
26
27
 
27
28
  /** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
28
29
  /**
@@ -75,18 +76,43 @@ export function directedBroadcast(address, netmask) {
75
76
  * IPv6 broadcast address.
76
77
  */
77
78
  export function broadcastTargets(ifaces) {
78
- const out = ["255.255.255.255"];
79
- for (const list of Object.values(ifaces ?? {})) {
79
+ return broadcastPlan(ifaces).map(p => p.to);
80
+ }
81
+
82
+ /**
83
+ * The same targets, each with the interface whose subnet it is — null for the
84
+ * limited broadcast, which is nobody's. What lets a beacon be held back when
85
+ * the machine would send it out by some OTHER interface: see leavesByTunnel.
86
+ */
87
+ export function broadcastPlan(ifaces) {
88
+ const out = [{ to: "255.255.255.255", iface: null }];
89
+ for (const [name, list] of Object.entries(ifaces ?? {})) {
80
90
  for (const ni of list ?? []) {
81
91
  if (!ni || ni.internal) continue;
82
92
  // Node 18 reports `family` as the string "IPv4"; older shapes used 4.
83
93
  if (ni.family !== "IPv4" && ni.family !== 4) continue;
84
94
  const to = directedBroadcast(ni.address, ni.netmask);
85
- if (to && !out.includes(to)) out.push(to);
95
+ if (to && !out.some(p => p.to === to)) out.push({ to, iface: name });
86
96
  }
87
97
  }
88
98
  return out;
89
99
  }
100
+
101
+ /**
102
+ * Would this broadcast leave the local network?
103
+ *
104
+ * A directed broadcast belongs to the interface whose subnet it is, so the
105
+ * machine sending it out by any other one means something rerouted the local
106
+ * network — a VPN, or a Tailscale exit node without local network access —
107
+ * and the beacon would come out on the far end of the tunnel. The limited
108
+ * broadcast belongs to none, so it is held back only when it would go into
109
+ * something that is plainly a tunnel. Unknown answers nothing: send.
110
+ */
111
+ export function leavesByTunnel(target, via, isTunnel) {
112
+ if (!via) return false;
113
+ if (target.iface) return via !== target.iface;
114
+ return isTunnel(via);
115
+ }
90
116
  import net from "node:net";
91
117
  import { randomBytes } from "node:crypto";
92
118
  import {
@@ -201,6 +227,30 @@ export function createBeacon({
201
227
  // restarting the deck, and a list captured at start would announce to the
202
228
  // addresses it had at breakfast.
203
229
  ifaces = () => networkInterfaces(),
230
+ /**
231
+ * Addresses to send the beacon to one by one, beside the broadcast — the
232
+ * owner's machines on Tailscale, whose tunnel carries no broadcast at all.
233
+ * Read per announce like `ifaces`, because the tailnet list is re-read while
234
+ * the deck runs and a machine that just came online belongs in the next one.
235
+ */
236
+ unicast = () => [],
237
+ /**
238
+ * Which way a packet from this address came: "lan", "tailscale", or null for
239
+ * one to ignore entirely. Null is what a tailnet packet gets while this
240
+ * deck's Tailscale switch is off — the sockets hear it either way, because
241
+ * they bind every interface, and off has to mean the deck does not act on it.
242
+ */
243
+ routeFor = () => "lan",
244
+ /** How long to wait before trying the discovery port again while another
245
+ * program holds it, and who to tell when hearing stops or comes back. */
246
+ rebindMs = 30_000,
247
+ onHearing,
248
+ /**
249
+ * Where the machine would send each broadcast — see createRouteCheck in
250
+ * route-via.mjs. Absent, every broadcast goes, as before; the suite's
251
+ * beacons have none.
252
+ */
253
+ routes = null,
204
254
  } = {}) {
205
255
  // Randomised per process. Two beacons from one fingerprint with different
206
256
  // instance ids mean the deck restarted between them, which is the signal to
@@ -213,7 +263,34 @@ export function createBeacon({
213
263
  const host = hostId();
214
264
  const peers = new Map();
215
265
  let sock = null;
266
+ /**
267
+ * The socket beacons LEAVE by, on a port of its own. See start.
268
+ *
269
+ * Null until it is bound, and whenever it could not be — the listening
270
+ * socket then sends as it always did, because a beacon from the wrong port is
271
+ * still a beacon and none at all is a deck nobody finds.
272
+ */
273
+ let out = null;
216
274
  let timer = null;
275
+ /**
276
+ * Whether this deck can HEAR — whether it holds the discovery port.
277
+ *
278
+ * SEPARATE FROM RUNNING, because a deck that cannot bind 45317 is still most
279
+ * of a deck. It announces from its own socket, so every other deck still
280
+ * finds it, asks it and dials it; what it has lost is hearing them announce.
281
+ * Stopping the whole feature over that — which is what this did — took a
282
+ * working sync away from somebody whose Tailscale exit node happened to be
283
+ * holding the port. It keeps trying for the port instead, and takes it back
284
+ * the moment it is free.
285
+ */
286
+ let hearing = false;
287
+ let deafError = null;
288
+ /** Whether the last announce held back every broadcast because the local
289
+ * network goes through a tunnel here — see leavesByTunnel. */
290
+ let tunneled = false;
291
+ let told = null;
292
+ let rebind = null;
293
+ let stopped = true;
217
294
  /** When this deck last answered a deck it had not heard, so answering cannot
218
295
  * become a storm, and which decks it has already answered — without the
219
296
  * second, a deck that is never accepted is answered again on every packet
@@ -223,8 +300,8 @@ export function createBeacon({
223
300
 
224
301
  const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
225
302
 
226
- const announce = () => {
227
- if (!sock) return;
303
+ const announce = (also = []) => {
304
+ if (!sock && !out) return;
228
305
  // EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
229
306
  //
230
307
  // This used to send only to 255.255.255.255, on the argument that the
@@ -246,11 +323,18 @@ export function createBeacon({
246
323
  // broadcast is filtered, and it is what Syncthing sends — and every
247
324
  // interface's own directed broadcast goes out beside it. A duplicate packet
248
325
  // costs one datagram; a missing one costs the whole feature.
249
- const targets = broadcastTargets(ifaces());
326
+ const plan = broadcastPlan(ifaces());
327
+ // Asked behind the send, and read from what was last answered: the first
328
+ // beacon after a start goes as it always did, and the ones after it know.
329
+ void routes?.want?.(plan.map(p => p.to));
330
+ const kept = routes ? plan.filter(p => !leavesByTunnel(p, routes.via(p.to), looksLikeTunnel)) : plan;
331
+ tunneled = kept.length === 0;
332
+ const targets = kept.map(p => p.to);
333
+ const from = out ?? sock;
250
334
  let left = targets.length;
251
335
  const failed = [];
252
336
  for (const to of targets) {
253
- sock.send(payload(), DISCOVERY_PORT, to, err => {
337
+ from.send(payload(), DISCOVERY_PORT, to, err => {
254
338
  if (err) failed.push(`${to} (${err.code ?? err.message})`);
255
339
  // REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
256
340
  // the ordinary state of a machine with a VPN up, and a panel that said
@@ -262,82 +346,181 @@ export function createBeacon({
262
346
  }
263
347
  });
264
348
  }
349
+ // AND ONE PACKET PER TAILNET MACHINE. Not part of the verdict above: a node
350
+ // that has gone to sleep since the list was read is the ordinary state of a
351
+ // laptop, and it says nothing about whether this deck can be discovered.
352
+ let direct = [];
353
+ try { direct = [...(unicast() ?? []), ...also]; } catch { direct = [...also]; }
354
+ for (const to of new Set(direct)) {
355
+ if (typeof to !== "string" || !to || targets.includes(to)) continue;
356
+ from.send(payload(), DISCOVERY_PORT, to, () => {});
357
+ }
265
358
  };
266
359
 
267
- const start = () => new Promise(resolve => {
268
- sock = createSocket({ type: "udp4", reuseAddr: true });
269
- sock.on("error", err => { onError?.("socket", err); });
270
- sock.on("message", (msg, rinfo) => {
271
- // Everything about whether to care lives in lan-sync.mjs. This hands it
272
- // the bytes and the address and does what it is told.
273
- if (msg.length > MAX_BEACON_BYTES) return;
274
- const beacon = readBeacon(msg);
275
- const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
276
- // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
277
- // part is the change. It used to answer only a deck already in the group,
278
- // which was fine when a group existed. Now the first thing a new deck has
279
- // to become is a row on somebody's screen, and it cannot become one if
280
- // this deck never tells it that it exists.
281
- //
282
- // Measured on two real decks before any of this: deck 1 saw deck 2 the
283
- // instant it started and deck 2 saw nobody, because deck 1's own
284
- // immediate announce went out before deck 2 was listening. Thirty seconds
285
- // of an empty list is how a working feature reads as broken.
286
- //
287
- // At most once every few seconds, because the obvious version is a shout
288
- // storm: two decks answering each other's answers forever. A new pair
289
- // converges in two extra packets.
290
- const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
291
- && !peers.has(beacon.fp) && !answered.has(beacon.fp);
292
- if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
293
- repliedAt = now();
294
- answered.add(beacon.fp);
295
- announce();
296
- }
297
- if (verdict !== "peer") {
298
- // Another deck is using this one's key — see beaconVerdict. Reported
299
- // rather than fixed here: this file carries packets, and choosing a new
300
- // identity for the deck belongs to whoever stores it.
301
- if (verdict === "id-clash") onIdClash?.();
302
- // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
303
- // dropped: it is a name and an address on the same network, which is a
304
- // row somebody can accept. Nothing is asked of it and nothing is
305
- // offered to it until they do.
306
- if (verdict === "stranger") {
307
- onStranger?.({
308
- fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
309
- // Carried through so the list can show one row per machine rather
310
- // than one per key that machine has ever held.
311
- host: beacon.host, at: now(),
312
- });
313
- }
314
- return;
360
+ const onMessage = (msg, rinfo) => {
361
+ // Everything about whether to care lives in lan-sync.mjs. This hands it
362
+ // the bytes and the address and does what it is told.
363
+ if (msg.length > MAX_BEACON_BYTES) return;
364
+ let via = "lan";
365
+ try { via = routeFor(rinfo.address); } catch { via = "lan"; }
366
+ if (!via) return;
367
+ const beacon = readBeacon(msg);
368
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
369
+ // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
370
+ // part is the change. It used to answer only a deck already in the group,
371
+ // which was fine when a group existed. Now the first thing a new deck has
372
+ // to become is a row on somebody's screen, and it cannot become one if
373
+ // this deck never tells it that it exists.
374
+ //
375
+ // Measured on two real decks before any of this: deck 1 saw deck 2 the
376
+ // instant it started and deck 2 saw nobody, because deck 1's own
377
+ // immediate announce went out before deck 2 was listening. Thirty seconds
378
+ // of an empty list is how a working feature reads as broken.
379
+ //
380
+ // At most once every few seconds, because the obvious version is a shout
381
+ // storm: two decks answering each other's answers forever. A new pair
382
+ // converges in two extra packets.
383
+ const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
384
+ && !peers.has(beacon.fp) && !answered.has(beacon.fp);
385
+ if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
386
+ repliedAt = now();
387
+ answered.add(beacon.fp);
388
+ // A deck that reached this one over the tailnet is answered there too:
389
+ // a broadcast never gets back down its tunnel.
390
+ announce(via === "tailscale" ? [rinfo.address] : []);
391
+ }
392
+ if (verdict !== "peer") {
393
+ // Another deck is using this one's key — see beaconVerdict. Reported
394
+ // rather than fixed here: this file carries packets, and choosing a new
395
+ // identity for the deck belongs to whoever stores it.
396
+ if (verdict === "id-clash") onIdClash?.();
397
+ // A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
398
+ // dropped: it is a name and an address on the same network, which is a
399
+ // row somebody can accept. Nothing is asked of it and nothing is
400
+ // offered to it until they do.
401
+ if (verdict === "stranger") {
402
+ onStranger?.({
403
+ fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
404
+ // Carried through so the list can show one row per machine rather
405
+ // than one per key that machine has ever held.
406
+ host: beacon.host, at: now(), via,
407
+ });
315
408
  }
316
- const noted = notePeer(peers, beacon, rinfo.address, now());
317
- if (noted.changed || noted.restarted) onPeer?.(noted);
409
+ return;
410
+ }
411
+ const noted = notePeer(peers, beacon, rinfo.address, now(), via);
412
+ if (noted.changed || noted.restarted) onPeer?.(noted);
413
+ };
414
+
415
+ /** Tell whoever asked, once per change rather than once per try. */
416
+ const tell = now => {
417
+ if (told === now) return;
418
+ told = now;
419
+ onHearing?.(now);
420
+ };
421
+
422
+ /** One try at the discovery port: the listening socket, or the error that
423
+ * kept it. A bind that fails never calls back; it arrives as an error. */
424
+ const listen = () => new Promise(resolve => {
425
+ let s;
426
+ try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
427
+ let bound = false;
428
+ s.on("error", err => {
429
+ if (bound) { onError?.("socket", err); return; }
430
+ try { s.close(); } catch { /* never opened */ }
431
+ resolve({ err });
318
432
  });
319
- sock.bind(DISCOVERY_PORT, "0.0.0.0", () => {
320
- try { sock.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
321
- // Immediately, not on the next tick. Syncthing's rule: a deck that just
322
- // came up should appear now rather than up to thirty seconds later, which
323
- // is the difference between "it works" and "it seems broken" for anybody
324
- // who starts two decks and watches.
325
- announce();
326
- timer = setInterval(announce, ANNOUNCE_MS);
327
- timer.unref?.();
328
- resolve();
433
+ s.on("message", onMessage);
434
+ s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
435
+ bound = true;
436
+ try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
437
+ resolve({ sock: s });
329
438
  });
330
439
  });
331
440
 
441
+ const tryListen = async () => {
442
+ const got = await listen();
443
+ if (stopped) { try { got.sock?.close(); } catch { /* gone */ } return; }
444
+ if (got.sock) {
445
+ sock = got.sock;
446
+ hearing = true;
447
+ deafError = null;
448
+ tell(true);
449
+ return;
450
+ }
451
+ hearing = false;
452
+ deafError = got.err;
453
+ tell(false);
454
+ rebind = setTimeout(() => { rebind = null; void tryListen(); }, rebindMs);
455
+ rebind.unref?.();
456
+ };
457
+
458
+ /**
459
+ * The socket beacons leave by, bound to whatever port the OS gives it.
460
+ *
461
+ * SENT FROM A PORT OF ITS OWN, NOT FROM 45317. Nothing that hears a beacon
462
+ * reads the port it came from — the reply goes to 45317 whatever the source
463
+ * — and sending from the discovery port gave that port away. Measured on a
464
+ * Mac that is a Tailscale exit node: a deck elsewhere on the tailnet routed a
465
+ * beacon through it, and Tailscale's forwarder binds its end of every UDP
466
+ * flow to the CLIENT's source port (netstack.go, forwardUDP), idling it out
467
+ * after two minutes. A beacon every thirty seconds never idles, so the Mac's
468
+ * own deck could never bind 45317 again. From an ephemeral port, the
469
+ * forwarder takes an ephemeral port.
470
+ */
471
+ const openOut = () => new Promise(resolve => {
472
+ let o;
473
+ try { o = createSocket({ type: "udp4" }); } catch { resolve(null); return; }
474
+ let opened = false;
475
+ o.on("error", err => {
476
+ if (opened) { onError?.("socket", err); return; }
477
+ opened = true;
478
+ try { o.close(); } catch { /* never opened */ }
479
+ resolve(null);
480
+ });
481
+ o.bind(0, "0.0.0.0", () => {
482
+ if (opened) return;
483
+ opened = true;
484
+ try { o.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
485
+ resolve(o);
486
+ });
487
+ });
488
+
489
+ const start = async () => {
490
+ stopped = false;
491
+ told = null;
492
+ await tryListen();
493
+ out = await openOut();
494
+ if (stopped) return;
495
+ // Immediately, not on the next tick. Syncthing's rule: a deck that just
496
+ // came up should appear now rather than up to thirty seconds later, which
497
+ // is the difference between "it works" and "it seems broken" for anybody
498
+ // who starts two decks and watches.
499
+ announce();
500
+ timer = setInterval(announce, ANNOUNCE_MS);
501
+ timer.unref?.();
502
+ };
503
+
332
504
  return {
333
505
  start,
334
506
  announce,
335
507
  peers,
508
+ /** Whether this deck holds the discovery port, and why not when it does
509
+ * not. */
510
+ hearing: () => hearing,
511
+ deafError: () => deafError,
512
+ tunneled: () => tunneled,
336
513
  stop() {
514
+ stopped = true;
337
515
  if (timer) clearInterval(timer);
338
516
  timer = null;
517
+ if (rebind) clearTimeout(rebind);
518
+ rebind = null;
519
+ hearing = false;
339
520
  try { sock?.close(); } catch { /* already closed */ }
340
521
  sock = null;
522
+ try { out?.close(); } catch { /* already closed */ }
523
+ out = null;
341
524
  },
342
525
  };
343
526
  }
@@ -894,13 +894,26 @@ export function pairable(strangers, now, { presentMs = PRESENT_MS, limit = 8, mi
894
894
  * `changed` is returned rather than inferred by the caller so a beacon that
895
895
  * says nothing new — which is most of them, one every thirty seconds per peer
896
896
  * forever — costs no render and no write.
897
+ *
898
+ * `via` is the route the packet came by: "lan", or "tailscale" for one sent to
899
+ * this machine's tailnet address — see routeOf in tailscale.mjs.
897
900
  */
898
- export function notePeer(peers, beacon, addr, now) {
901
+ export function notePeer(peers, beacon, addr, now, via = "lan") {
899
902
  const prev = peers.get(beacon.fp);
903
+ // ONE MACHINE, TWO ROUTES, AND THE LOCAL ONE WINS WHILE IT ANSWERS. A laptop
904
+ // in the office is heard on the Wi-Fi and over Tailscale in the same half
905
+ // minute, and taking whichever packet came last flipped its address every
906
+ // beacon — each flip a `changed`, and a round dialling a different route each
907
+ // minute. The tailnet address is used only once the local one has gone quiet
908
+ // for as long as presence lasts, which is the laptop having left the building.
909
+ const lanAt = via === "lan" ? now : prev && prev.via !== "tailscale" ? prev.lanAt ?? prev.lastSeen : prev?.lanAt;
910
+ const keepLan = via === "tailscale" && prev && prev.via !== "tailscale" && lanAt != null && now - lanAt < PRESENT_MS;
900
911
  const next = {
901
912
  fp: beacon.fp,
902
913
  name: beacon.name,
903
- addr,
914
+ addr: keepLan ? prev.addr : addr,
915
+ via: keepLan ? prev.via ?? "lan" : via,
916
+ lanAt: lanAt ?? null,
904
917
  port: beacon.port,
905
918
  instance: beacon.instance,
906
919
  host: beacon.host,
@@ -31,6 +31,7 @@
31
31
  // (crashPolicy, in supervisor.mjs), it is identical on every platform, and it is
32
32
  // tested. Two policies over one process is how `ccdeck --stop` becomes a
33
33
  // suggestion the machine overrules a second later.
34
+ import { inApp } from "./app-host.mjs";
34
35
  import { spawnSync } from "node:child_process";
35
36
  import { homedir } from "node:os";
36
37
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -264,6 +265,9 @@ export function taskXmlFor({ execPath, script, args = [], product = "ccdeck" } =
264
265
  */
265
266
  export function shouldOfferService({ record = null, npx = false, checkout = false, env = process.env } = {}) {
266
267
  if (env.AGENTS_DECK_NO_INSTALL === "1") return false;
268
+ // The desktop app owns start-at-login; a second login item from the deck it
269
+ // started would launch that deck with no app around it (app-host.mjs).
270
+ if (inApp(env)) return false;
267
271
  if (npx || checkout) return false;
268
272
  return record === null;
269
273
  }
@@ -0,0 +1,66 @@
1
+ // Which program holds a UDP port, in the words the LAN panel prints.
2
+ //
3
+ // A deck that cannot bind the discovery port says so, and "another program"
4
+ // is a sentence nobody can act on. On the Mac where this was measured the
5
+ // program was Tailscale, and finding that out took netstat and an hour. The
6
+ // operating system will usually say, without elevation, and when it will not
7
+ // the sentence stays the one it was.
8
+ //
9
+ // Pure readers per platform, and one runner that picks the command; nothing
10
+ // here is ever a reason for the deck to fail.
11
+ import { run as runCommand } from "./exec.mjs";
12
+
13
+ /**
14
+ * macOS: `netstat -anv -p udp` lists every UDP socket with its owner as
15
+ * `name:pid` in the process column — readable by a plain user for sockets of
16
+ * any user, which is the reason this is the Mac's answer.
17
+ */
18
+ export function holderFromNetstat(text, port) {
19
+ const at = new RegExp(`[.:]${port}\\s`);
20
+ for (const line of String(text ?? "").split("\n")) {
21
+ if (!/^udp/.test(line) || !at.test(line.split(/\s+/).slice(0, 5).join(" ") + " ")) continue;
22
+ const owner = line.split(/\s+/).find(f => /^[A-Za-z][\w.-]*:\d+$/.test(f));
23
+ if (owner) return owner.replace(/:\d+$/, "");
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Linux: `ss -H -uanp 'sport = :PORT'` — `users:(("name",pid=…))`, shown
29
+ * only for this user's own processes; somebody else's is an unnamed row. */
30
+ export function holderFromSs(text) {
31
+ const m = /users:\(\("([^"]+)"/.exec(String(text ?? ""));
32
+ return m ? m[1] : null;
33
+ }
34
+
35
+ /** Windows: the owning process's name, one line, from the PowerShell below. */
36
+ export function holderFromPowershell(text) {
37
+ const name = String(text ?? "").split(/\r?\n/).map(l => l.trim()).find(Boolean);
38
+ return name && /^[\w .()-]{1,64}$/.test(name) ? name : null;
39
+ }
40
+
41
+ /** The name a person knows the program by. Tailscale is the one measured,
42
+ * and it goes by a different process name on each platform. */
43
+ export function friendlyHolder(name) {
44
+ if (!name) return null;
45
+ if (/tailscale/i.test(name)) return "Tailscale";
46
+ return name;
47
+ }
48
+
49
+ /** Who holds UDP `port` here, or null when the machine will not say. */
50
+ export async function portHolder(port, { platform = process.platform, run = runCommand } = {}) {
51
+ try {
52
+ if (platform === "darwin") {
53
+ const r = await run("netstat", ["-anv", "-p", "udp"], { timeout: 5_000 });
54
+ return friendlyHolder(r?.ok ? holderFromNetstat(r.stdout, port) : null);
55
+ }
56
+ if (platform === "win32") {
57
+ const ps = `Get-NetUDPEndpoint -LocalPort ${Number(port)} -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }`;
58
+ const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
59
+ return friendlyHolder(r?.ok ? holderFromPowershell(r.stdout) : null);
60
+ }
61
+ const r = await run("ss", ["-H", "-uanp", `sport = :${Number(port)}`], { timeout: 5_000 });
62
+ return friendlyHolder(r?.ok ? holderFromSs(r.stdout) : null);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,109 @@
1
+ // Which interface the machine would send a packet to an address by.
2
+ //
3
+ // A BEACON FOR THE LOCAL NETWORK MUST STAY ON IT. A laptop using a Tailscale
4
+ // exit node, or any VPN that takes the local network too, routes even a
5
+ // broadcast for its own Wi-Fi into the tunnel — and on the far end it comes out
6
+ // on somebody else's network: an office Mac that is the exit node for a home
7
+ // laptop put the laptop's beacons on the office LAN, where a colleague whose
8
+ // network uses the same addresses could see the name of a machine that is
9
+ // nothing to do with them. So before a beacon goes, the machine is asked where
10
+ // it would go, and a beacon that would leave through a tunnel does not leave.
11
+ //
12
+ // Asked of the operating system rather than reasoned from interface names,
13
+ // because only the routing table knows what a VPN did to it: `route get` on
14
+ // macOS, `ip route get` on Linux, Find-NetRoute on Windows. The answers are
15
+ // cached, since routes change when networks do and not every thirty seconds,
16
+ // and a lookup that fails or cannot be made answers nothing — the beacon then
17
+ // goes as it always did.
18
+ import { run as runCommand } from "./exec.mjs";
19
+
20
+ /** How long one answer is kept. Long enough that a Windows machine starts one
21
+ * PowerShell every couple of minutes rather than every beacon; short enough
22
+ * that turning an exit node on stops the beacons into it soon after. */
23
+ export const ROUTE_TTL_MS = 120_000;
24
+
25
+ /** `route -n get <ip>` on macOS: the `interface:` line. */
26
+ export function viaFromRouteGet(text) {
27
+ const m = /^\s*interface:\s*(\S+)/m.exec(String(text ?? ""));
28
+ return m ? m[1] : null;
29
+ }
30
+
31
+ /** `ip -o route get <ip>` on Linux: the device after `dev`. */
32
+ export function viaFromIpRoute(text) {
33
+ const m = /\bdev\s+(\S+)/.exec(String(text ?? ""));
34
+ return m ? m[1] : null;
35
+ }
36
+
37
+ /** The PowerShell below prints `address|alias` per line on Windows. */
38
+ export function viaFromFindNetRoute(text) {
39
+ const out = new Map();
40
+ for (const line of String(text ?? "").split(/\r?\n/)) {
41
+ const at = line.indexOf("|");
42
+ if (at <= 0) continue;
43
+ const alias = line.slice(at + 1).trim();
44
+ if (alias) out.set(line.slice(0, at).trim(), alias);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Whether an interface is a tunnel rather than a network a person is on.
51
+ *
52
+ * Used only for the limited broadcast, which belongs to no one interface: a
53
+ * directed broadcast is judged by whether it leaves by the interface whose
54
+ * subnet it is, which needs no names at all. These are the names tunnels go
55
+ * by on the three platforms — macOS numbers every VPN `utun`, and a Windows
56
+ * adapter is called whatever its VPN named it.
57
+ */
58
+ export function looksLikeTunnel(name) {
59
+ if (typeof name !== "string" || !name) return false;
60
+ return /^(utun|tun|tap|wg|ppp|ipsec|gpd|zt)\d*$/i.test(name)
61
+ || /tailscale|wireguard|nordlynx|openvpn|vpn/i.test(name);
62
+ }
63
+
64
+ const QUAD = /^\d{1,3}(\.\d{1,3}){3}$/;
65
+
66
+ /** Ask the machine, for every address in `targets`, which interface it would
67
+ * use. A Map of address to interface name; missing where it would not say. */
68
+ export async function lookupRoutes(targets, { platform = process.platform, run = runCommand } = {}) {
69
+ const list = targets.filter(t => typeof t === "string" && QUAD.test(t));
70
+ const out = new Map();
71
+ if (!list.length) return out;
72
+ if (platform === "win32") {
73
+ const quoted = list.map(t => `'${t}'`).join(",");
74
+ const ps = `foreach($t in @(${quoted})){ $r = Find-NetRoute -RemoteIPAddress $t -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias } | Select-Object -First 1; $t + '|' + $r.InterfaceAlias }`;
75
+ const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { timeout: 15_000 });
76
+ return r?.ok ? viaFromFindNetRoute(r.stdout) : out;
77
+ }
78
+ for (const t of list) {
79
+ const r = platform === "darwin"
80
+ ? await run("route", ["-n", "get", t], { timeout: 3_000 })
81
+ : await run("ip", ["-o", "route", "get", t], { timeout: 3_000 });
82
+ const via = r?.ok ? (platform === "darwin" ? viaFromRouteGet(r.stdout) : viaFromIpRoute(r.stdout)) : null;
83
+ if (via) out.set(t, via);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * The cache the beacon reads synchronously: `via(address)` is the last answer
90
+ * or undefined, and `want(addresses)` starts one lookup for whichever of them
91
+ * are missing or stale. Never more than one lookup at a time.
92
+ */
93
+ export function createRouteCheck({ platform = process.platform, run = runCommand, now = Date.now, ttlMs = ROUTE_TTL_MS } = {}) {
94
+ const cache = new Map();
95
+ let busy = null;
96
+ return {
97
+ via: to => cache.get(to)?.via,
98
+ want(targets) {
99
+ if (busy) return busy;
100
+ const stale = (targets ?? []).filter(t => { const c = cache.get(t); return !c || now() - c.at > ttlMs; });
101
+ if (!stale.length) return Promise.resolve();
102
+ busy = lookupRoutes(stale, { platform, run })
103
+ .then(found => { for (const t of stale) cache.set(t, { via: found.get(t) ?? null, at: now() }); })
104
+ .catch(() => { /* nothing learned; the beacon goes as it always did */ })
105
+ .finally(() => { busy = null; });
106
+ return busy;
107
+ },
108
+ };
109
+ }
@@ -22,6 +22,7 @@
22
22
  // it by name and only where it can actually work: a global install, on a
23
23
  // directory we can write, outside a git checkout and outside an npx cache.
24
24
  // Everywhere else this stays what it has always been — a printed command.
25
+ import { inApp } from "./app-host.mjs";
25
26
  import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
26
27
  import { spawn } from "node:child_process";
27
28
  import { homedir } from "node:os";
@@ -1085,7 +1086,9 @@ export function upgradeBlock(pkgRoot) {
1085
1086
  // npm -g rewrites the package directory and its parent (the global
1086
1087
  // node_modules), so both have to be ours to write.
1087
1088
  writable: dirWritable(target) && dirWritable(resolve(target, "..")),
1088
- optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1",
1089
+ // Inside the desktop app the app updates itself; an `npm i -g` here would
1090
+ // install a copy it never runs (app-host.mjs).
1091
+ optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1" || inApp(),
1089
1092
  // Whether the package that write would fetch is still a deck. Kept out of
1090
1093
  // the pure rule above, like every other input here, so the policy stays one
1091
1094
  // readable expression and this file owns the filesystem half of it.
@@ -1461,7 +1464,8 @@ export async function versionReport({ running, pkgRoot, name = PUBLISHED_NAME, n
1461
1464
  // isOlder decides that, not this.
1462
1465
  const skipRegistry =
1463
1466
  process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
1464
- process.env.AGENTS_DECK_NO_INSTALL === "1";
1467
+ process.env.AGENTS_DECK_NO_INSTALL === "1" ||
1468
+ inApp();
1465
1469
  const latest = skipRegistry ? null : await latestOnNpm(asked, now, force);
1466
1470
  const marker = skipRegistry ? null : readMarker(asked);
1467
1471
  const blocked = upgradeBlock(pkgRoot);