agent-dag 1.47.0 → 3.0.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,467 @@
1
+ // Reading Chrome's visit log out from under a browser that is still writing it.
2
+ //
3
+ // Browser Watch wants one thing: the URLs navigated to since the last poll.
4
+ // Chrome (and every Chromium fork — Brave, Edge, Vivaldi) keeps them in a
5
+ // SQLite file called `History` in the profile directory, and three separate
6
+ // facts stand between that file and a list of rows. All three were measured on
7
+ // this machine against a real 21 MB Brave profile rather than reasoned about,
8
+ // because each of them fails in a way that looks like something else.
9
+ //
10
+ // ── 1. THE LIVE FILE CANNOT BE OPENED, EVEN READ-ONLY ───────────────────────
11
+ //
12
+ // Chrome holds an exclusive lock for as long as it is running. A read-only open
13
+ // of the real path answers `SQLITE_BUSY: database is locked` — not "empty", not
14
+ // "no such table", a hard error on the very first statement. So the file is
15
+ // COPIED and the copy is read. 21 MB copies in 168 ms and 20 of 20 copies came
16
+ // back readable; that is the whole cost of the poll.
17
+ //
18
+ // The copy is not the tidy kind either. `journal_mode` on this file is `delete`,
19
+ // NOT WAL — there is no `-wal`/`-shm` sidecar to copy alongside it, which is the
20
+ // usual Chromium advice and is wrong here. What does sit beside it during a
21
+ // write is a `History-journal` file holding the UNDO image, so a copy taken
22
+ // mid-transaction can contain writes Chrome was about to roll back, and a copy
23
+ // taken at exactly the wrong moment can be a torn page — `SQLITE_NOTADB: file is
24
+ // not a database`, reproduced here by handing the reader 4 KB of /dev/urandom.
25
+ // A few extra rows are harmless for a feature that lists navigations. A throw is
26
+ // not: this runs on a poll inside the deck's own process, so an uncaught one
27
+ // takes the whole deck down over a browser that happened to be busy. Nothing
28
+ // below throws; a failure comes back as `degraded` with a reason.
29
+ //
30
+ // ── 2. `visit_time` DOES NOT FIT IN A JAVASCRIPT NUMBER ─────────────────────
31
+ //
32
+ // Chrome counts MICROSECONDS since 1601-01-01 UTC, so today's values are around
33
+ // 1.34e16 — past 2^53. `node:sqlite` refuses to guess and throws rather than
34
+ // hand back a rounded integer:
35
+ //
36
+ // RangeError: The value of column 0 is too large to be represented as a
37
+ // JavaScript number: 13432716408648765 (code: ERR_OUT_OF_RANGE)
38
+ //
39
+ // That is a throw from `.all()`, i.e. the whole query fails, not one row. So
40
+ // every 64-bit column in the SELECT is read as `CAST(… AS TEXT)` and stays a
41
+ // STRING until `chromeTimeToMs` converts it through BigInt. `transition` is cast
42
+ // too even though its values are 32-bit today, because the failure is per-STATEMENT
43
+ // rather than per-column: one out-of-range column loses every row of the query,
44
+ // so there is no version of this worth leaving to chance for one saved cast.
45
+ //
46
+ // ── 3. THERE MAY BE NO SQLITE READER AT ALL ─────────────────────────────────
47
+ //
48
+ // `node:sqlite` exists only from Node 22.5, and this package declares
49
+ // `engines: { node: ">=18" }` — CI itself runs Node 20. A top-level
50
+ // `import "node:sqlite"` is therefore not an option: it throws
51
+ // ERR_UNKNOWN_BUILTIN_MODULE at module load, before any of this file's own error
52
+ // handling exists, and takes the server's import graph with it. It is loaded
53
+ // through a dynamic import inside a try/catch, once.
54
+ //
55
+ // The fallback is the `sqlite3` CLI, which ships with macOS (/usr/bin/sqlite3,
56
+ // 3.51.0 here) and is usually present on Linux — and is ABSENT ON WINDOWS, where
57
+ // Microsoft ships no sqlite3.exe. So the third answer is real and has to be a
58
+ // first-class one: `{ kind: "none" }`, `degraded: true`, no rows, and the caller
59
+ // falls back to the file's mtime — "something navigated at 09:06, no URL" is a
60
+ // weaker signal than a list of URLs and it is a great deal better than a blank
61
+ // panel and a crash.
62
+ import { copyFile, mkdir, rm } from "node:fs/promises";
63
+ import { createRequire } from "node:module";
64
+ import { tmpdir } from "node:os";
65
+ import { join } from "node:path";
66
+ import { pathLookup, run } from "./exec.mjs";
67
+
68
+ /**
69
+ * 1601-01-01 → 1970-01-01, in microseconds.
70
+ *
71
+ * A BigInt because that is the unit the arithmetic has to happen in: the whole
72
+ * point of the conversion is that the operands do not fit in a double, so doing
73
+ * it as `Number(t) / 1000 - 11644473600000` rounds the input before it is ever
74
+ * used. The error is under a microsecond and it is still the wrong shape — it
75
+ * produces a FRACTIONAL millisecond (1788242808648.7637 for the newest row on
76
+ * this machine), which then feeds a Date, a diff and a watermark.
77
+ */
78
+ const EPOCH_DELTA_US = 11644473600000000n;
79
+ const US_PER_MS = 1000n;
80
+
81
+ /**
82
+ * Chrome time (microseconds since 1601, as a STRING) → JS epoch milliseconds.
83
+ *
84
+ * The argument is a string because every caller gets it out of a `CAST(… AS
85
+ * TEXT)` column; a Number is accepted too, and is exactly the lossy input this
86
+ * function exists to avoid, so it is converted through its decimal spelling.
87
+ *
88
+ * Division truncates toward zero, which is floor for every value this can be
89
+ * handed — Chrome cannot record a visit before 1601. It is stated because a
90
+ * machine whose clock sat before 1970 would produce a negative microsecond count
91
+ * and truncation would round it toward the future by up to 1 ms, which is a
92
+ * rounding artefact rather than a bug worth branching for.
93
+ *
94
+ * A value that is not a run of digits answers NaN rather than throwing.
95
+ * `BigInt("")` and `BigInt("abc")` both throw SyntaxError, and this is called
96
+ * once per row inside a poll: one unreadable row must cost that row, not the
97
+ * request. readVisitsSince drops rows whose time is not finite.
98
+ */
99
+ export function chromeTimeToMs(t) {
100
+ const digits = String(t ?? "").trim();
101
+ if (!/^-?\d+$/.test(digits)) return NaN;
102
+ return Number((BigInt(digits) - EPOCH_DELTA_US) / US_PER_MS);
103
+ }
104
+
105
+ /**
106
+ * JS epoch milliseconds → Chrome time, as a STRING.
107
+ *
108
+ * A string because the result does not fit in a Number — today's values are
109
+ * 1.34e16 — so returning one would corrupt the watermark it exists to produce.
110
+ * That is the caller this function is for: a first poll has no watermark and
111
+ * seeds one from the wall clock, which is what keeps the first read to "since
112
+ * the deck started" instead of the user's entire browsing history.
113
+ *
114
+ * A non-finite input answers "0", the same floor a first call with no watermark
115
+ * gets. Zero means 1601, so nothing is skipped: an unusable input costs a wide
116
+ * read, never a silently missed navigation.
117
+ */
118
+ export function msToChromeTime(ms) {
119
+ const whole = Math.trunc(Number(ms));
120
+ if (!Number.isFinite(whole)) return "0";
121
+ return String(BigInt(whole) * US_PER_MS + EPOCH_DELTA_US);
122
+ }
123
+
124
+ /**
125
+ * The SELECT, in one place, for both backends.
126
+ *
127
+ * `floor` is the `>` operand: `"?"` for the node:sqlite path, which binds it as
128
+ * a BigInt, and a validated run of digits for the CLI path, which has no way to
129
+ * bind a parameter at all. Everything else about the statement is byte-identical
130
+ * between the two — one query to read, one query to get the casts right.
131
+ *
132
+ * Verified against the real profile: 4,893 rows in 15 ms with node:sqlite.
133
+ */
134
+ const visitsSql = (floor) =>
135
+ "SELECT u.url AS url, CAST(v.visit_time AS TEXT) t, CAST(v.transition AS TEXT) tr" +
136
+ " FROM visits v JOIN urls u ON u.id = v.url" +
137
+ ` WHERE v.visit_time > ${floor}` +
138
+ " ORDER BY v.visit_time";
139
+
140
+ /**
141
+ * The floor as a run of digits, or "0".
142
+ *
143
+ * Two jobs, and the second is the one that matters. It normalises "no watermark
144
+ * yet" — undefined, null, "" — to the 1601 floor. And it is the ONLY thing
145
+ * standing between a stored watermark and the CLI's command line, where the
146
+ * value is pasted into SQL text because sqlite3(1) offers no parameter binding.
147
+ * The watermark is the deck's own output round-tripped through the caller's
148
+ * state, not user input in the web sense; it is validated anyway, because
149
+ * "nobody can reach that value" is a claim about code that is not in this file.
150
+ */
151
+ function chromeFloor(since) {
152
+ const digits = String(since ?? "").trim();
153
+ return /^\d+$/.test(digits) ? digits : "0";
154
+ }
155
+
156
+ /** Compare two digit strings by value, without BigInt. Runs once per row. */
157
+ function cmpDigits(a, b) {
158
+ const x = a.replace(/^0+(?=\d)/, "");
159
+ const y = b.replace(/^0+(?=\d)/, "");
160
+ if (x.length !== y.length) return x.length - y.length;
161
+ return x < y ? -1 : x > y ? 1 : 0;
162
+ }
163
+
164
+ /** The one line of an error worth putting in a `reason`. */
165
+ function why(err) {
166
+ const text = String(err?.message ?? err ?? "unknown");
167
+ return text.split("\n")[0].slice(0, 200);
168
+ }
169
+
170
+ /** Cached answer of the real probe. A promise, so two concurrent first calls
171
+ * share one dynamic import and one PATH walk rather than racing to do both. */
172
+ let memo = null;
173
+
174
+ /**
175
+ * `node:sqlite`, loaded through Node's own resolver rather than the bundler's.
176
+ *
177
+ * `await import("node:sqlite")` is the obvious spelling and it is the wrong one
178
+ * here. Vite's builtin list predates the module, so under the test runner it
179
+ * strips the `node:` prefix and looks for a package called `sqlite` instead:
180
+ * "Failed to load url sqlite (resolved id: sqlite)". vitest 2 carries that bug
181
+ * (vitest-dev/vitest#7177, fixed upstream in #7179 and not in the 2.x pinned
182
+ * here), and its cost was not a failing test. `sqliteBackend()` simply resolved
183
+ * to the CLI arm in every case, so the branch most users actually run was the
184
+ * one branch no test could reach — and the suite was green about it.
185
+ *
186
+ * `createRequire` goes straight to Node, which knows the module on any runtime
187
+ * that has it, and behaves identically in production, where no bundler is
188
+ * involved at all. Still inside the caller's try/catch: a Node without the
189
+ * module throws here exactly as the dynamic import did.
190
+ */
191
+ const requireNode = createRequire(import.meta.url);
192
+
193
+ /**
194
+ * Node prints `ExperimentalWarning: SQLite is an experimental feature` to
195
+ * stderr the first time the module is loaded, and that stderr is the terminal
196
+ * the user started the deck in. It is a warning about a decision they did not
197
+ * make, about a module they cannot choose, at a moment they were looking at a
198
+ * browser tab — and there is nothing they can do with it.
199
+ *
200
+ * Only this one warning, and only by swapping the default listener for one that
201
+ * forwards everything else untouched: a blanket NODE_NO_WARNINGS would also
202
+ * swallow a deprecation the deck genuinely needs to hear about. Installed lazily
203
+ * on the first load rather than at import, so a deck whose panel is never opened
204
+ * never touches the process's listeners at all.
205
+ */
206
+ let quieted = false;
207
+ function quietSqliteWarning() {
208
+ if (quieted) return;
209
+ quieted = true;
210
+ const existing = process.listeners("warning");
211
+ process.removeAllListeners("warning");
212
+ process.on("warning", warning => {
213
+ if (warning?.name === "ExperimentalWarning" && /\bSQLite\b/.test(warning.message ?? "")) return;
214
+ for (const listener of existing) listener(warning);
215
+ });
216
+ }
217
+
218
+ const loadSqlite = async () => {
219
+ quietSqliteWarning();
220
+ return requireNode("node:sqlite");
221
+ };
222
+
223
+ /**
224
+ * Which SQLite reader this machine has, resolved once and cached.
225
+ *
226
+ * `{ kind: "node-sqlite" }` | `{ kind: "sqlite3-cli", bin }` | `{ kind: "none" }`
227
+ *
228
+ * ORDER IS DELIBERATE. node:sqlite is in-process — no spawn, no command line, no
229
+ * output to parse, and parameter binding — so it wins wherever it exists. The
230
+ * CLI is the fallback rather than the default because every call to it costs a
231
+ * process, and this runs on a poll.
232
+ *
233
+ * The dynamic import is inside a try/catch and covers more than "Node 20 has no
234
+ * such module". Between 22.5 and 22.12 node:sqlite existed but required
235
+ * `--experimental-sqlite`, which the deck is not started with, and the import
236
+ * fails there too — the same catch, the same fallback, no version arithmetic
237
+ * anywhere in this file. `DatabaseSync` is checked for by name because that is
238
+ * what the read path actually calls; a future module that exists under this
239
+ * specifier without it would otherwise be selected and then throw per poll.
240
+ *
241
+ * The CLI is found with the repo's own `pathLookup` rather than by spawning
242
+ * `sqlite3` and seeing what happens, for the reason exec.mjs was written: on
243
+ * Windows the thing on PATH is `sqlite3.exe`, spawn is not a shell and applies
244
+ * no PATHEXT, so a bare-name probe answers ENOENT on the one platform where the
245
+ * answer decides whether the feature exists at all.
246
+ *
247
+ * `deps` is for tests and is NEVER memoised — a probe with an injected PATH must
248
+ * not become this process's permanent answer, and the real answer must not be
249
+ * whatever a test asked for first. Passing nothing takes the cache; passing even
250
+ * `{}` re-probes.
251
+ */
252
+ export async function sqliteBackend(deps) {
253
+ if (deps) return probeBackend(deps);
254
+ memo ??= probeBackend({});
255
+ return memo;
256
+ }
257
+
258
+ async function probeBackend({
259
+ importSqlite = loadSqlite,
260
+ lookup = pathLookup,
261
+ platform = process.platform,
262
+ env = process.env,
263
+ } = {}) {
264
+ try {
265
+ const mod = await importSqlite();
266
+ if (typeof mod?.DatabaseSync === "function") return { kind: "node-sqlite" };
267
+ } catch {
268
+ // Node < 22.5, or 22.5–22.12 without --experimental-sqlite. Both are "no
269
+ // in-process reader", which is a state and not an error.
270
+ }
271
+ const bin = lookup("sqlite3", platform, { pathEnv: env.PATH ?? env.Path ?? "" });
272
+ if (bin) return { kind: "sqlite3-cli", bin };
273
+ return { kind: "none" };
274
+ }
275
+
276
+ /** Distinguishes this deck's copies from a sibling deck's in a shared copyDir.
277
+ * pid alone is not enough — one deck polls repeatedly and must not read a copy
278
+ * it is still writing. */
279
+ let copySeq = 0;
280
+
281
+ /**
282
+ * Every navigation newer than `sinceChromeTime`.
283
+ *
284
+ * `{ rows, watermark, degraded, reason }`
285
+ * rows [{ url, timeMs, transition }], oldest first
286
+ * watermark the newest chrome time seen, as a string, or the input unchanged
287
+ * when there was nothing to see — store it and hand it back next
288
+ * poll
289
+ * degraded true when no rows could be read for a reason that is not "no new
290
+ * navigations": no SQLite reader on this machine, no readable copy,
291
+ * a torn image. The caller falls back to the file's mtime in all of
292
+ * them, which is why they share one flag rather than one each — the
293
+ * distinction that matters to a caller is "is this list complete",
294
+ * and `reason` carries the rest for the log.
295
+ * reason null on success, otherwise a stable slug and a detail:
296
+ * "no-sqlite-reader: …" | "copy-failed: …" | "unreadable-copy: …"
297
+ *
298
+ * `opts.copyDir` is where the copy of the locked file goes; `opts.backend` skips
299
+ * the probe when the caller already resolved it; `opts.deps` injects the
300
+ * filesystem, the runner and the import for tests.
301
+ *
302
+ * NEVER THROWS. Not "rarely" — this is called from a poll in the deck's own
303
+ * process and the inputs are a file another program owns, so the failure modes
304
+ * are ordinary rather than exceptional.
305
+ */
306
+ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
307
+ const { copyDir = join(tmpdir(), "ccdeck-browser-watch"), backend } = opts;
308
+ const deps = opts.deps ?? {};
309
+ const {
310
+ copyFile: copy = copyFile,
311
+ mkdir: makeDir = mkdir,
312
+ rm: remove = rm,
313
+ run: exec = run,
314
+ importSqlite = loadSqlite,
315
+ } = deps;
316
+
317
+ const floor = chromeFloor(sinceChromeTime);
318
+ // What comes back when there is nothing to advance to. The input verbatim
319
+ // where it was usable, so a caller that stores it sees no change at all; the
320
+ // normalised floor where it was not, so the answer is always a string a later
321
+ // call can be handed.
322
+ const unchanged = floor === String(sinceChromeTime ?? "").trim() ? String(sinceChromeTime) : floor;
323
+
324
+ const chosen = backend ?? await sqliteBackend(opts.deps);
325
+ if (!chosen || chosen.kind === "none") {
326
+ return {
327
+ rows: [],
328
+ watermark: unchanged,
329
+ degraded: true,
330
+ reason: "no-sqlite-reader: node:sqlite needs Node 22.5+ and no sqlite3 was found on PATH",
331
+ };
332
+ }
333
+
334
+ let copyPath = null;
335
+ try {
336
+ await makeDir(copyDir, { recursive: true });
337
+ copyPath = join(copyDir, `history-${process.pid}-${++copySeq}.sqlite`);
338
+ await copy(historyPath, copyPath);
339
+ } catch (err) {
340
+ // The browser is not installed, the profile moved, the disk is full. All of
341
+ // them are "no rows this poll", none of them is a reason to stop polling.
342
+ await discard(remove, copyPath);
343
+ return { rows: [], watermark: unchanged, degraded: true, reason: `copy-failed: ${why(err)}` };
344
+ }
345
+
346
+ let raw;
347
+ try {
348
+ raw = chosen.kind === "node-sqlite"
349
+ ? await readViaNode(copyPath, floor, importSqlite)
350
+ : await readViaCli(copyPath, floor, chosen.bin, exec);
351
+ } catch (err) {
352
+ return { rows: [], watermark: unchanged, degraded: true, reason: `unreadable-copy: ${why(err)}` };
353
+ } finally {
354
+ // A 21 MB file per poll. Left behind, this fills the user's temp directory
355
+ // at the rate the deck polls — and the copy is a full, unencrypted list of
356
+ // everywhere they have been, which is not a thing to leave lying around
357
+ // under a predictable name.
358
+ await discard(remove, copyPath);
359
+ }
360
+
361
+ const rows = [];
362
+ let top = floor;
363
+ for (const row of raw) {
364
+ const timeMs = chromeTimeToMs(row?.t);
365
+ // A row with no URL or an unreadable time is dropped rather than repaired.
366
+ // It cannot be drawn and it must not become the watermark, because a
367
+ // watermark taken from a value this could not read would skip every real
368
+ // row behind it, permanently.
369
+ if (!row?.url || !Number.isFinite(timeMs)) continue;
370
+ const transition = Number(row.tr);
371
+ rows.push({ url: String(row.url), timeMs, transition: Number.isFinite(transition) ? transition : 0 });
372
+ const t = String(row.t).trim();
373
+ if (cmpDigits(t, top) > 0) top = t;
374
+ }
375
+
376
+ // `top` rather than the last row's time. The ORDER BY makes those the same
377
+ // today, and the watermark is the one value whose being wrong loses rows
378
+ // forever rather than for one poll — so it is computed from what was read
379
+ // instead of from an assumption about how it was sorted.
380
+ return { rows, watermark: rows.length ? top : unchanged, degraded: false, reason: null };
381
+ }
382
+
383
+ /**
384
+ * Delete the copy, and never let the deletion be the thing that fails.
385
+ *
386
+ * `maxRetries` is Node's own answer to a Windows file whose last handle is still
387
+ * closing — see __tests__/rm-temp-dir.ts, which paid for that knowledge twice.
388
+ * The catch is on top of it because a leftover 21 MB file in a temp directory is
389
+ * not worth a failed poll.
390
+ */
391
+ async function discard(remove, path) {
392
+ if (!path) return;
393
+ try {
394
+ await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
395
+ } catch {
396
+ // The OS still has it. It is in a temp directory and it is one file.
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The in-process read.
402
+ *
403
+ * `readOnly` is passed even though the target is a copy this module owns: it
404
+ * stops SQLite creating a `-journal` beside it and makes an attempt to write a
405
+ * bug rather than a silent edit of the snapshot. Older node:sqlite builds that
406
+ * predate the option ignore it, which on a private copy is harmless — the
407
+ * fallback that would otherwise be needed here would have to tell an unknown
408
+ * option apart from "file is not a database", and guessing at that is worse than
409
+ * opening a scratch file read-write.
410
+ *
411
+ * The floor is BOUND, as a BigInt, because it does not fit in a Number either.
412
+ * Binding it as a string would work by SQLite's column affinity rules rather
413
+ * than by intent — the comparison would go through an implicit TEXT→INTEGER
414
+ * conversion that the schema happens to ask for — and that is a thing to rely on
415
+ * only when there is no alternative. Here there is one.
416
+ */
417
+ async function readViaNode(file, floor, importSqlite) {
418
+ const { DatabaseSync } = await importSqlite();
419
+ const db = new DatabaseSync(file, { readOnly: true });
420
+ try {
421
+ return db.prepare(visitsSql("?")).all(BigInt(floor));
422
+ } finally {
423
+ try { db.close(); } catch { /* already closed, or never opened cleanly */ }
424
+ }
425
+ }
426
+
427
+ // sqlite3 -ascii separators: 0x1F between columns, 0x1E after every row
428
+ // including the last. Chosen over `-json` because `-json` needs sqlite3 3.33
429
+ // (2020) and Ubuntu 20.04 still ships 3.31, while `-ascii` has been there since
430
+ // 3.8 — and over the default `|` because a URL may legally contain a pipe,
431
+ // where 0x1F and 0x1E cannot appear in one at all. Confirmed against the real
432
+ // profile: 0 of 172,000 URLs contain either separator or a newline.
433
+ const UNIT = "\u001f";
434
+ const RECORD = "\u001e";
435
+
436
+ /**
437
+ * The out-of-process read.
438
+ *
439
+ * Through the repo's `run` rather than `execFile` directly, for what exec.mjs
440
+ * exists to do: candidate spelling on Windows, a deadline that reports before it
441
+ * kills, and a contract that answers `{ ok: false }` instead of rejecting.
442
+ *
443
+ * `maxBuffer` is raised well past run's 4 MB default. The steady-state poll
444
+ * returns kilobytes, but a caller that seeds its watermark at 0 asks for the
445
+ * entire history in one statement — 170k rows here — and the failure mode of the
446
+ * default is ENOBUFS, which arrives looking like a broken database rather than
447
+ * like a large one.
448
+ */
449
+ async function readViaCli(file, floor, bin, exec) {
450
+ const res = await exec(bin, ["-readonly", "-ascii", file, visitsSql(floor)], {
451
+ timeout: 15_000,
452
+ maxBuffer: 64 << 20,
453
+ });
454
+ if (!res?.ok) {
455
+ // sqlite3 puts "file is not a database" on stderr and exits 26. Reported,
456
+ // never rethrown as-is, so the reason names the tool that said it.
457
+ const said = why(res?.stderr || res?.stdout || "");
458
+ throw new Error(`sqlite3 exited ${res?.code ?? "?"}${said ? `: ${said}` : ""}`);
459
+ }
460
+ const out = [];
461
+ for (const record of String(res.stdout ?? "").split(RECORD)) {
462
+ if (!record) continue;
463
+ const [url, t, tr] = record.split(UNIT);
464
+ out.push({ url, t, tr });
465
+ }
466
+ return out;
467
+ }
@@ -0,0 +1,157 @@
1
+ // Which browsers are on this machine, which are running, and what can honestly
2
+ // be said about whether any of them is talking to the relay.
3
+ //
4
+ // THE ANSWER IS MOSTLY "I CANNOT TELL", AND SAYING SO IS THE FEATURE. Two facts
5
+ // measured on the machine this was written on decide the whole shape of this
6
+ // file:
7
+ //
8
+ // dig +short bridge.claudeusercontent.com -> 160.79.104.10
9
+ // dig +short api.anthropic.com -> 160.79.104.10
10
+ //
11
+ // The relay shares an address with the API and with claude.ai. An established
12
+ // connection to it is therefore NOT evidence of a relay session — an open
13
+ // claude.ai tab is indistinguishable — and blocking by address would sever
14
+ // Claude Code itself, which is why the killswitch blocks the NAME.
15
+ //
16
+ // lsof -nP -i TCP -a -c "Brave Browser" -> 14 lines
17
+ // lsof -nP -i TCP -a -c "Google Chrome" -> 0 lines
18
+ //
19
+ // And lsof cannot see some browsers' sockets at all. Zero lines for a browser
20
+ // that is plainly running is a blind probe, not a quiet one, so absence is not
21
+ // evidence either.
22
+ //
23
+ // Both directions therefore fail, and the honest report has three states rather
24
+ // than two: `live` (with the caveat attached), `none-seen` (the probe worked and
25
+ // found nothing) and `unknown` (the probe could not see, or does not exist on
26
+ // this platform). There is deliberately no state that means "definitely not
27
+ // connected", because nothing here can establish that.
28
+ import { existsSync } from "node:fs";
29
+ import { basename } from "node:path";
30
+ import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
31
+ import { run } from "./exec.mjs";
32
+
33
+ /** The application name each browser's processes carry, for the probes below.
34
+ * Only the ones whose name is knowable; a root with no entry here is still
35
+ * reported as installed, just never as running. */
36
+ const APP_NAME = {
37
+ chrome: "Google Chrome",
38
+ "chrome-beta": "Google Chrome Beta",
39
+ "chrome-canary": "Google Chrome Canary",
40
+ chromium: "Chromium",
41
+ brave: "Brave Browser",
42
+ edge: "Microsoft Edge",
43
+ vivaldi: "Vivaldi",
44
+ arc: "Arc",
45
+ };
46
+
47
+ /** Every address the relay currently resolves to.
48
+ *
49
+ * Empty is not an error — a machine with no `dig`, or one where the name is
50
+ * already blocked in /etc/hosts, both land here — and an empty list makes
51
+ * every connection probe answer `unknown`, which is the correct answer when
52
+ * there is nothing to compare against. */
53
+ export async function relayAddresses(host, deps = {}) {
54
+ const exec = deps.run ?? run;
55
+ const out = await exec("dig", ["+short", host]).catch(() => null);
56
+ if (!out?.ok) return [];
57
+ return String(out.stdout ?? "").split("\n")
58
+ .map(l => l.trim())
59
+ .filter(l => /^[0-9.]+$/.test(l) || /^[0-9a-f:]+$/i.test(l) && l.includes(":"));
60
+ }
61
+
62
+ /** Whether a named application has any process at all. */
63
+ export async function isRunning(app, platform = process.platform, deps = {}) {
64
+ const exec = deps.run ?? run;
65
+ if (platform === "win32") {
66
+ const out = await exec("tasklist", ["/FI", `IMAGENAME eq ${app}.exe`, "/NH"]).catch(() => null);
67
+ if (!out?.ok) return null;
68
+ return String(out.stdout ?? "").toLowerCase().includes(`${app.toLowerCase()}.exe`);
69
+ }
70
+ const out = await exec("pgrep", ["-x", app]).catch(() => null);
71
+ // pgrep exits 1 when it matched nothing, which `run` reports as not-ok — the
72
+ // same shape as "pgrep is missing". Distinguished by whether it said anything
73
+ // on stderr, because only one of the two is a failure to ask.
74
+ if (out === null) return null;
75
+ if (out.ok) return true;
76
+ return String(out.stderr ?? "").trim() === "" ? false : null;
77
+ }
78
+
79
+ /**
80
+ * What can be said about this browser's connections to the relay.
81
+ *
82
+ * -> { state: "live" | "none-seen" | "unknown", count, why }
83
+ *
84
+ * `why` is not decoration. Every one of these three answers is qualified, and a
85
+ * panel that showed the state without the qualification would be making a claim
86
+ * this module has already established it cannot make.
87
+ */
88
+ export async function relayLink(app, addresses, platform = process.platform, deps = {}) {
89
+ const exec = deps.run ?? run;
90
+ if (platform === "win32") {
91
+ return { state: "unknown", count: 0, why: "this check needs lsof, which Windows does not have" };
92
+ }
93
+ if (addresses.length === 0) {
94
+ return { state: "unknown", count: 0, why: "the relay name did not resolve, so there is nothing to match against" };
95
+ }
96
+ const out = await exec("lsof", ["-nP", "-i", "TCP", "-a", "-c", app]).catch(() => null);
97
+ if (out === null) return { state: "unknown", count: 0, why: "lsof is not available here" };
98
+
99
+ const lines = String(out.stdout ?? "").split("\n").filter(l => l.trim() !== "");
100
+ // A browser with no visible sockets at all is a BLIND probe, not a quiet one.
101
+ // Measured: lsof sees fourteen TCP lines for Brave and zero for Google Chrome
102
+ // while both are running. Reporting "none seen" here would turn "I cannot
103
+ // look" into "I looked and it was clear".
104
+ if (lines.length <= 1) {
105
+ return { state: "unknown", count: 0, why: "lsof cannot see this browser's sockets, so absence proves nothing" };
106
+ }
107
+ const hit = lines.filter(l => l.includes("ESTABLISHED") && addresses.some(a => l.includes(a)));
108
+ if (hit.length === 0) {
109
+ return { state: "none-seen", count: 0, why: "no TCP connection to that address; QUIC would not appear here" };
110
+ }
111
+ return {
112
+ state: "live",
113
+ count: hit.length,
114
+ why: "the relay shares an address with api.anthropic.com and claude.ai, so an open claude.ai tab looks the same",
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Every browser this deck knows how to look at, whether or not it is here.
120
+ *
121
+ * Installed browsers with no profile are included on purpose. "Chrome is
122
+ * installed and has never been opened" and "Chrome is not installed" are
123
+ * different answers, and a panel that lists only what it found makes them
124
+ * indistinguishable — which is the same failure browser-profiles.mjs guards
125
+ * against one level down.
126
+ */
127
+ export async function browserSurvey({
128
+ relayHost,
129
+ platform = process.platform,
130
+ env = process.env,
131
+ home,
132
+ deps = {},
133
+ } = {}) {
134
+ const exists = deps.existsSync ?? existsSync;
135
+ const roots = browserRoots(platform, env, home);
136
+ const addresses = await relayAddresses(relayHost, deps);
137
+
138
+ const out = [];
139
+ for (const root of roots) {
140
+ const installed = exists(root.root);
141
+ const profiles = installed ? profileDirs(root.root, deps.fs) : [];
142
+ const app = APP_NAME[root.key] ?? null;
143
+ const running = installed && app ? await isRunning(app, platform, deps) : false;
144
+ out.push({
145
+ key: root.key,
146
+ name: root.name,
147
+ installed,
148
+ profiles: profiles.length,
149
+ withExtension: profiles.filter(d => hasExtension(d, undefined, deps.fs)).map(d => basename(d)),
150
+ running,
151
+ relay: installed && running && app
152
+ ? await relayLink(app, addresses, platform, deps)
153
+ : { state: "unknown", count: 0, why: running ? "no process name known for this browser" : "not running" },
154
+ });
155
+ }
156
+ return out;
157
+ }