agent-dag 3.20.0 → 3.21.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.
@@ -0,0 +1,119 @@
1
+ // What a paired deck says about itself: the version it runs and the machine it
2
+ // runs on.
3
+ //
4
+ // ONLY TO A DECK THAT IS PAIRED, AND SEALED. The beacon is shouted at the whole
5
+ // network and the hello is sent before anybody has proved anything, so neither
6
+ // carries this: which build of which operating system a machine runs is the
7
+ // first thing somebody hunting for a known-broken version would want, and
8
+ // nobody on the network is owed it. It rides the manifest exchange, which only
9
+ // happens between two decks that have each accepted the other — and it is
10
+ // sealed with that connection's key, because the frames around it are JSON in
11
+ // the clear on the wire.
12
+ //
13
+ // BOTH WAYS. The deck that dials sends its own card with the question, and the
14
+ // deck that answers sends its own with the answer. A deck that only calls in —
15
+ // paired, with no address this one can reach — is never asked anything, so the
16
+ // card it sends when it calls is the only way this deck ever learns what it is.
17
+ //
18
+ // OLDER DECKS SAY NOTHING, AND NOTHING BREAKS. The field is extra on a frame
19
+ // both versions already send: a deck from before this reads the accounts and
20
+ // never looks at the rest, and a card that is absent is drawn as absent.
21
+ import { readFileSync } from "node:fs";
22
+ import { release as osRelease } from "node:os";
23
+ import { open, seal } from "./lan-sync.mjs";
24
+
25
+ /**
26
+ * The operating system, in the words its owner would use.
27
+ *
28
+ * Node reports the KERNEL — `darwin 25.5.0`, `win32 10.0.26100` — and nobody
29
+ * calls their laptop Darwin 25. So the numbers are translated on the machine
30
+ * they describe, where the translation is certain, rather than on the one
31
+ * reading them. Every input is a parameter, so all three platforms are tested
32
+ * on any one of them.
33
+ */
34
+ export function osLabel({ platform, release = "", prettyName = "" } = {}) {
35
+ const [major, minor, build] = String(release).split(".").map(n => Number.parseInt(n, 10));
36
+ const dot = Number.isInteger(minor) ? `.${minor}` : "";
37
+ if (platform === "darwin") {
38
+ if (!Number.isInteger(major)) return "macOS";
39
+ // Darwin 20 was macOS 11 and every major after it was one more — until 25,
40
+ // which Apple shipped as macOS 26 to match the year.
41
+ if (major >= 25) return `macOS ${major + 1}${dot}`;
42
+ if (major >= 20) return `macOS ${major - 9}${dot}`;
43
+ // The 10.x years, where the kernel's major minus four is the second number.
44
+ return major >= 5 ? `macOS 10.${major - 4}` : "macOS";
45
+ }
46
+ if (platform === "win32") {
47
+ // Windows 11 kept the version 10.0 and moved only the build, and 22000 is
48
+ // the first one. From here it is the only way to tell the two apart.
49
+ if (major === 10 && Number.isInteger(build)) return build >= 22_000 ? "Windows 11" : "Windows 10";
50
+ return "Windows";
51
+ }
52
+ if (platform === "linux") return prettyName || "Linux";
53
+ return typeof platform === "string" && platform ? platform : "unknown";
54
+ }
55
+
56
+ /** The distribution's own name for itself, from the file every systemd-era
57
+ * distribution ships, with the fallback path the same spec names. Only ever
58
+ * read on Linux: the paths are Linux's and mean nothing anywhere else. */
59
+ export function linuxPrettyName(read = readFileSync) {
60
+ for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
61
+ try {
62
+ const line = /^PRETTY_NAME=(.*)$/m.exec(String(read(file, "utf8")))?.[1];
63
+ const name = line?.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
64
+ if (name) return name;
65
+ } catch { /* not this one */ }
66
+ }
67
+ return "";
68
+ }
69
+
70
+ /** This deck's own card. Built once, at start: nothing in it changes while the
71
+ * process lives, and an upgrade is a restart. */
72
+ export function aboutThisDeck({
73
+ version = null, platform = process.platform, release = osRelease(), arch = process.arch, read = readFileSync,
74
+ } = {}) {
75
+ return readAbout({
76
+ version,
77
+ os: osLabel({ platform, release, prettyName: platform === "linux" ? linuxPrettyName(read) : "" }),
78
+ arch,
79
+ });
80
+ }
81
+
82
+ /** One field of a card, or null. It came off the network, so it is a string of
83
+ * bounded length with no control characters — the rule cleanName applies to a
84
+ * deck's name, for the same reason: it reaches a terminal as well as a page.
85
+ * `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
86
+ function field(v, max, shape = null) {
87
+ if (typeof v !== "string") return null;
88
+ const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
89
+ const s = [...flat].slice(0, max).join("").trim();
90
+ return s && (!shape || shape.test(s)) ? s : null;
91
+ }
92
+
93
+ /** A card, or null when there is nothing in it worth drawing. */
94
+ export function readAbout(raw) {
95
+ if (!raw || typeof raw !== "object") return null;
96
+ const version = field(raw.version, 32, /^[0-9A-Za-z][0-9A-Za-z.+-]*$/);
97
+ const os = field(raw.os, 48);
98
+ const arch = field(raw.arch, 16, /^[0-9A-Za-z_]+$/);
99
+ return version || os || arch ? { version, os, arch } : null;
100
+ }
101
+
102
+ /** Bound to one connection AND one direction, so a card cannot be replayed
103
+ * back at the deck that sent it as if it were the other deck's. */
104
+ const aboutAad = (fromFp, toFp) => `${fromFp}->${toFp}|about`;
105
+
106
+ /** This deck's card, sealed for the deck on the other end of `key`. */
107
+ export function sealAbout(key, about, fromFp, toFp) {
108
+ if (!key || !about) return null;
109
+ return seal(key, JSON.stringify(about), aboutAad(fromFp, toFp));
110
+ }
111
+
112
+ /** The other deck's card, or null — for a deck that sent none, and for one
113
+ * whose seal does not open, which mean the same thing to the panel. */
114
+ export function openAbout(key, sealed, fromFp, toFp) {
115
+ if (!key || !sealed || typeof sealed !== "object") return null;
116
+ const text = open(key, sealed, aboutAad(fromFp, toFp));
117
+ if (text == null) return null;
118
+ try { return readAbout(JSON.parse(text)); } catch { return null; }
119
+ }
@@ -30,6 +30,7 @@
30
30
  import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
31
  import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
32
  import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
+ import { openAbout, sealAbout } from "./lan-about.mjs";
33
34
  import { randomBytes } from "node:crypto";
34
35
  import { hostname, networkInterfaces } from "node:os";
35
36
 
@@ -95,6 +96,20 @@ export function localAddresses(faces = networkInterfaces()) {
95
96
  return out;
96
97
  }
97
98
 
99
+ /**
100
+ * The accounts a peer's manifest listed, as the panel may keep them.
101
+ *
102
+ * It arrived from another machine, so it is read rather than trusted: strings
103
+ * where strings belong, a boolean for the verdict, and no more rows than a
104
+ * manifest may carry. What is kept is only what the deck's dialog draws.
105
+ */
106
+ export function offered(list) {
107
+ return (Array.isArray(list) ? list : [])
108
+ .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
109
+ .slice(0, 50)
110
+ .map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
111
+ }
112
+
98
113
  /**
99
114
  * One deck's LAN sync, from settings to a healed account.
100
115
  *
@@ -118,10 +133,13 @@ export function createEngine({
118
133
  * with. Nothing secret leaves, and it is still a test shouting at an office.
119
134
  */
120
135
  createSocket,
136
+ /** This deck's own card — its version and its machine — handed to every
137
+ * paired deck and to nobody else. See lan-about.mjs. */
138
+ about = null,
121
139
  } = {}) {
122
140
  let cfg = {
123
141
  enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
124
- autoAsk: true, autoAccept: true,
142
+ autoAsk: true, autoAccept: true, aliases: {},
125
143
  };
126
144
  let identity = null;
127
145
  let beacon = null;
@@ -166,6 +184,16 @@ export function createEngine({
166
184
  /** What the last round did, for the panel. Not a log: one line per peer, most
167
185
  * recent only, because "what happened" is a question about now. */
168
186
  const lastRound = new Map();
187
+ /** What each paired deck said about itself — version, operating system,
188
+ * architecture — keyed by fingerprint, most recent only. Filled from both
189
+ * directions: the manifest a deck answers with, and the question a deck that
190
+ * calls in asks. See lan-about.mjs. */
191
+ const aboutBy = new Map();
192
+ /** The accounts each paired deck offered in its last manifest, keyed by
193
+ * fingerprint. Kept APART from lastRound on purpose: a round that fails
194
+ * replaces that line, and the list a deck offered a minute ago is still the
195
+ * best answer to "what does it share" while it is unreachable. */
196
+ const offersBy = new Map();
169
197
  /** When the last round FINISHED, whatever it did or failed to do. The panel's
170
198
  * `↻` fires one on demand and the loop fires one on its own; a reader who
171
199
  * pressed it wants to know it happened, and a reader who did not wants to
@@ -212,6 +240,15 @@ export function createEngine({
212
240
  }));
213
241
  };
214
242
 
243
+ /** This deck's card for one connection, as a frame field — or nothing, for a
244
+ * deck built without one. Spread into the frame, so a deck from before this
245
+ * existed receives exactly the frame it always did plus one key it never
246
+ * reads. */
247
+ const cardFor = (key, toFp) => {
248
+ const sealed = sealAbout(key, about, identity.fp, toFp);
249
+ return sealed ? { about: sealed } : {};
250
+ };
251
+
215
252
  /** Frames from a deck that finished the handshake AND that somebody here has
216
253
  * accepted. Nothing reaches this before both, which is the whole point of
217
254
  * where the two checks sit. `ctx.key` is this connection's key and no other
@@ -222,8 +259,16 @@ export function createEngine({
222
259
  if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
223
260
  try {
224
261
  if (msg.t === "manifest") {
262
+ // THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
263
+ // that calls in ever says what it is: nothing here dials it, so nothing
264
+ // here ever asks. A seal that does not open is a deck that said nothing.
265
+ const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
266
+ if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
225
267
  const accounts = await localAccounts();
226
- return ctx.send({ t: "manifest", accounts: manifestFor(accounts, cfg.shared) });
268
+ return ctx.send({
269
+ t: "manifest", accounts: manifestFor(accounts, cfg.shared),
270
+ ...cardFor(ctx.key, ctx.peerFp),
271
+ });
227
272
  }
228
273
  if (msg.t === "want") {
229
274
  // A SECOND PROOF, for the one operation that moves a credential. The
@@ -313,13 +358,18 @@ export function createEngine({
313
358
 
314
359
  if (!trustedPeer(cfg.trusted, conn.peerFp)) {
315
360
  const { list, added } = addTrusted(cfg.trusted, {
316
- fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
361
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
317
362
  });
318
363
  if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
319
364
  }
320
365
 
321
- const theirs = await ask({ t: "manifest" });
366
+ // Our card goes with the question and theirs comes back with the answer
367
+ // — see lan-about.mjs for why it is here and nowhere earlier.
368
+ const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
322
369
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
370
+ const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
371
+ if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
372
+ offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
323
373
  const mine = await localAccounts();
324
374
  // Only accounts I have also ticked. Sharing is mutual by construction:
325
375
  // a peer cannot push an account at me that I never agreed to hold.
@@ -449,7 +499,7 @@ export function createEngine({
449
499
  // Somebody used the token. They are pinned, and the token is retired —
450
500
  // one that pairs twice is one worth stealing twice.
451
501
  onInviteUsed: entry => {
452
- const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
502
+ const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
453
503
  cfg = { ...cfg, trusted: list };
454
504
  invite = null;
455
505
  // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
@@ -612,7 +662,7 @@ export function createEngine({
612
662
  name: cfg.name, myPort: server.port(), code: inv.code,
613
663
  });
614
664
  const { list } = addTrusted(cfg.trusted, {
615
- fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
665
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
616
666
  });
617
667
  cfg = { ...cfg, trusted: list };
618
668
  this.addPeer(at.addr, at.port);
@@ -667,7 +717,7 @@ export function createEngine({
667
717
  onChange?.();
668
718
  return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
669
719
  }
670
- const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
720
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
671
721
  cfg = { ...cfg, trusted: list };
672
722
  pending.delete(fp);
673
723
  strangers.delete(fp);
@@ -724,6 +774,24 @@ export function createEngine({
724
774
  return true;
725
775
  },
726
776
  round,
777
+ /**
778
+ * One deck, now — the `check now` in that deck's own dialog.
779
+ *
780
+ * Found the way the list found it: heard on the network under its own
781
+ * fingerprint, or dialled at an address whose answer was that fingerprint.
782
+ * Null when it is neither, which is a deck that only calls in — nothing
783
+ * here holds an address for it, so there is nobody to dial.
784
+ *
785
+ * `roundAt` is left alone: it says when EVERY paired deck was last asked,
786
+ * and asking one of them does not make that true.
787
+ */
788
+ async roundOne(fp) {
789
+ if (!beacon || typeof fp !== "string" || !fp) return null;
790
+ const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
791
+ const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
792
+ const peer = heard ?? typed;
793
+ return peer ? roundWith(peer) : null;
794
+ },
727
795
  /** Dial this address on every round from now on. Returns false for an
728
796
  * address that is not one, rather than storing a row that can never
729
797
  * connect and reports an error every minute forever. */
@@ -758,6 +826,11 @@ export function createEngine({
758
826
  // which on a deck that has just started is the honest answer.
759
827
  checkedAt: roundAt,
760
828
  name: cfg.name,
829
+ // This deck's own card, so the panel can read a peer's version against
830
+ // it; and the names somebody here gave other decks, which the panel
831
+ // and the request dialog draw in place of the ones those decks chose.
832
+ about: about ?? null,
833
+ aliases: { ...(cfg.aliases ?? {}) },
761
834
  // Whether this deck asks on its own, and whether a request that
762
835
  // arrives is answered here or answered for you.
763
836
  autoAsk: !!cfg.autoAsk,
@@ -820,6 +893,15 @@ export function createEngine({
820
893
  had.met = had.met || row.met;
821
894
  if (row.name && !had.name) had.name = row.name;
822
895
  };
896
+ // WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
897
+ // the logins it offered last, and when somebody here said yes. All
898
+ // three are keyed by the fingerprint that proved itself, so both
899
+ // halves of a merged row read the same answer.
900
+ const card = id => ({
901
+ about: aboutBy.get(id) ?? null,
902
+ offers: offersBy.get(id) ?? null,
903
+ pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
904
+ });
823
905
  for (const p of [...beacon.peers.values(), ...manual.values()]) {
824
906
  if (!stillListed(p, now())) continue;
825
907
  const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
@@ -834,6 +916,7 @@ export function createEngine({
834
916
  met: !!met,
835
917
  paired: !!trustedPeer(cfg.trusted, id),
836
918
  last: lastRound.get(p.fp) ?? null,
919
+ ...card(id),
837
920
  });
838
921
  }
839
922
  // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
@@ -592,7 +592,11 @@ export function addTrusted(trusted, entry) {
592
592
  added: false,
593
593
  };
594
594
  }
595
- return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "" }], added: true };
595
+ // WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
596
+ // was already trusted keeps whatever date it had, and one pinned before this
597
+ // field existed keeps having none rather than being given today's.
598
+ const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
599
+ return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
596
600
  }
597
601
 
598
602
  /** Take one back out. Unpairing stops what has not happened yet and takes back
@@ -0,0 +1,40 @@
1
+ // Whether somebody is looking at the deck right now, as its tabs say.
2
+ //
3
+ // The server can tell whether a page is CONNECTED — `sseClients.size` — but not
4
+ // whether anybody is looking at one: a tab behind other windows keeps its
5
+ // stream open all day. The away-update (auto-update.mjs) needs the second fact,
6
+ // so each tab reports it (src/web/presence.ts), and a claim expires here unless
7
+ // it is renewed, because a tab that crashed or lost its network never gets to
8
+ // take it back.
9
+ export const PRESENCE_TTL_MS = 45_000;
10
+ /** More tabs than anybody opens on one deck. A bound because the ids arrive
11
+ * from pages; past it the oldest claim goes. */
12
+ const MAX_TABS = 32;
13
+ const TAB_ID = /^[\w-]{1,64}$/;
14
+
15
+ export function createPresence({ ttlMs = PRESENCE_TTL_MS } = {}) {
16
+ /** tab id → when it last said it was looking */
17
+ const seen = new Map();
18
+ return {
19
+ /** A tab saying whether it is being looked at. False for an id no page
20
+ * could have made, which the route answers with a 400. */
21
+ report(tab, looking, now) {
22
+ if (typeof tab !== "string" || !TAB_ID.test(tab)) return false;
23
+ seen.delete(tab);
24
+ if (looking) {
25
+ seen.set(tab, now);
26
+ while (seen.size > MAX_TABS) seen.delete(seen.keys().next().value);
27
+ }
28
+ return true;
29
+ },
30
+ /** Whether any tab's claim is still live. A claim stamped by a clock that
31
+ * has since moved either way is as expired as an old one. */
32
+ looking(now) {
33
+ for (const [tab, at] of seen) {
34
+ if (Math.abs(now - at) > ttlMs) seen.delete(tab);
35
+ else return true;
36
+ }
37
+ return false;
38
+ },
39
+ };
40
+ }