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.
- package/README.md +6 -2
- package/dist/web/assets/index-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -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
|
+
}
|