agent-dag 3.22.1 → 3.22.3

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,507 +0,0 @@
1
- // "Can somebody else's Claude Code drive the browser on this machine?" — read
2
- // off disk, answered without a single privileged call, and turned into the one
3
- // command that closes it.
4
- //
5
- // THE EXPOSURE. The Claude in Chrome extension holds a cloud relay open to
6
- // bridge.claudeusercontent.com, and it registers the browser under the ANTHROPIC
7
- // ACCOUNT rather than under the device. So the set of clients able to enumerate
8
- // and drive this browser is not "the sessions on this laptop", it is "every
9
- // Claude Code session signed in to that account, on any machine" — and the
10
- // pairing completes with no prompt on the machine being taken over. Upstream:
11
- // anthropics/claude-code #25551, #33813, #42660. The browser on the other end is
12
- // the one holding the user's logged-in sessions, so the question is worth a
13
- // panel rather than a footnote.
14
- //
15
- // WHERE IT SURFACES. The Browser Watch panel, under "Can this browser be
16
- // driven": the verdict, the grants each profile actually holds, and the
17
- // killswitch command as text to copy. `browser-watch.mjs`'s `relayGuard` does
18
- // the two reads this module refuses to do and calls everything below.
19
- //
20
- // WHAT THIS MODULE DOES. Two reads and a string. It says whether the extension
21
- // is installed and what it was granted (from a profile's "Secure Preferences",
22
- // which the caller parses), and whether the hosts file already black-holes the
23
- // relay (from the hosts file's TEXT, which the caller reads). Neither needs a
24
- // privilege. Then it hands back the command that would change it, as text, for
25
- // the user to paste.
26
- //
27
- // WHY IT NEVER ELEVATES AND NEVER WRITES. Not squeamishness — the deck's own
28
- // threat model. `isTrustedMutation` in index.mjs deliberately lets a request
29
- // carrying no Origin header through (`if (!hasOrigin && !site) return true;`)
30
- // so that hook.js and curl keep working, on the reasoning that a process able
31
- // to POST to loopback can already run anything as the user. That reasoning
32
- // holds only while no route can do something the caller could not do for
33
- // itself. The moment one route can raise a password dialog, any local process
34
- // gets to make an authentication prompt appear wearing ccdeck's name, at a
35
- // moment ccdeck chose — which is the whole of a phishing primitive, handed over
36
- // for free. There is no elevation anywhere else in this repo, and this module
37
- // is not where the precedent starts. It imports node:path and nothing else: no
38
- // child_process, no fs, nothing that could run or write. relay-guard.test.ts
39
- // pins that by reading this file's own source, because "we would notice" is not
40
- // a control.
41
- import { win32 as winPath } from "node:path";
42
-
43
- /** The relay the extension keeps open. Blocking this name is the whole lever:
44
- * the pairing is account-scoped, so there is no per-device setting to turn off
45
- * and no token to rotate — DNS is the seam. */
46
- export const RELAY_HOST = "bridge.claudeusercontent.com";
47
-
48
- /** Claude in Chrome, by its Web Store extension id — the key under
49
- * `extensions.settings` in a Chromium profile's "Secure Preferences". */
50
- export const CLAUDE_EXT_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
51
-
52
- /** The tag that makes a hosts line OURS rather than somebody's. Everything
53
- * downstream — what `readKillswitch` will claim, what the unblock command is
54
- * allowed to delete — hangs off this exact text appearing as a comment on the
55
- * line. A line without it is a line we did not write and will not remove. */
56
- const TAG = "ccdeck killswitch";
57
-
58
- /** The line the block command appends, verbatim. Also the line the unblock
59
- * command deletes, and the only one it may. */
60
- const KILLSWITCH_LINE = `0.0.0.0 ${RELAY_HOST} # ${TAG}`;
61
-
62
- /**
63
- * The host name with its dots escaped, for use inside a pattern.
64
- *
65
- * THIS IS THE ONE THAT ALREADY DID DAMAGE. The shell tool this feature descends
66
- * from matched the host with its dots unescaped, so `.` meant "any character"
67
- * and the pattern also matched `bridge-claudeusercontent-com` — and, being
68
- * unanchored on top of that, matched it as a SUBSTRING of unrelated lines. One
69
- * test run deleted five entries from a real /etc/hosts, one of them an internal
70
- * network mapping that nothing else on that machine knew how to reproduce.
71
- *
72
- * Escaping only `.` is sufficient here and not in general: RELAY_HOST is
73
- * letters and dots, which are otherwise literal in all three dialects this
74
- * module writes patterns for (JavaScript, POSIX BRE for sed, .NET for
75
- * PowerShell). A test pins that character-set assumption, so changing the
76
- * constant to something containing a metacharacter fails there rather than
77
- * silently widening every matcher at once.
78
- */
79
- const HOST_RE = RELAY_HOST.replace(/\./g, "\\.");
80
-
81
- /**
82
- * The tagged line, as a pattern written in the dialect JavaScript and .NET
83
- * share — so the matcher below and the delete pattern inside the Windows
84
- * command are the SAME string and cannot drift apart.
85
- *
86
- * Anchored at both ends, dots escaped, and the tag required. Each of those
87
- * three is load-bearing:
88
- *
89
- * ^[ \t]* a hosts line may be indented and still be live, but a line whose
90
- * first non-blank character is `#` is a comment, and this refuses
91
- * it — `# 0.0.0.0 bridge… # ccdeck killswitch` is a block somebody
92
- * turned OFF, and claiming it would report protection that is not
93
- * there and then delete a line that was already inert.
94
- * [ \t]+ one or more, because the file on this machine separates fields
95
- * with a tab and a hand-edited line may use several spaces.
96
- * $ without it, `0.0.0.0 bridge… # ccdeck killswitch AND SOMETHING`
97
- * counts as ours, and the unblock deletes the something with it.
98
- * the tag without it, every untagged mapping of the host is ours to delete,
99
- * including the one an admin put there on purpose.
100
- */
101
- const TAGGED_PATTERN = `^[ \\t]*0\\.0\\.0\\.0[ \\t]+${HOST_RE}[ \\t]+#[ \\t]*${TAG}[ \\t]*$`;
102
-
103
- /** The same shape in POSIX BRE, for sed. `[[:space:]]` rather than `[ \t]`
104
- * because BRE has no `\t` escape — a bracket expression written `[ \t]` in a
105
- * BRE matches a backslash and the letter t, which is not what anyone reading
106
- * it would think, and `+` is not a BRE repetition operator either, hence the
107
- * `XX*` spelling. */
108
- const TAGGED_BRE = "^[[:space:]]*0\\.0\\.0\\.0[[:space:]][[:space:]]*" + HOST_RE +
109
- "[[:space:]][[:space:]]*#[[:space:]]*" + TAG + "[[:space:]]*$";
110
-
111
- /** Case-SENSITIVE on purpose, and this is the asymmetry the module turns on:
112
- * what counts as ours drives a DELETE, so it is exactly the line we write;
113
- * what counts as foreign drives a WARNING, so it is generous (see below). Both
114
- * shell dialects match case-sensitively too — sed by default, PowerShell only
115
- * because the command asks for `-cnotmatch` rather than `-notmatch` — so a
116
- * line this claims is a line those two will actually remove. */
117
- const OURS = new RegExp(TAGGED_PATTERN);
118
-
119
- /**
120
- * Addresses that make a mapping a block rather than a redirect.
121
- *
122
- * `0.0.0.0` is the one we write and the one every hosts blocklist uses: nothing
123
- * dials it, so the connection fails immediately instead of hanging. The
124
- * loopback pair is here because it is the older convention for the same intent
125
- * and a user who typed one meant to block; the relay speaks TLS on 443 and
126
- * nothing on this machine answers there, so it fails too — just a little later.
127
- *
128
- * Everything else is treated as reachable, including a private address that
129
- * happens to be down today. This module cannot tell a sinkhole from a proxy by
130
- * looking at an octet, and the honest failure direction is to under-claim
131
- * protection rather than to over-claim it.
132
- */
133
- const BLACK_HOLE = new Set(["0.0.0.0", "127.0.0.1", "::1", "::"]);
134
-
135
- /**
136
- * Where the hosts file lives.
137
- *
138
- * `platform` and `env` are injected rather than read, because the Windows leg
139
- * has to be testable from the macOS and Linux runners in the matrix — there is
140
- * no second machine to check it on, and a leg nobody can run is a leg nobody
141
- * has checked.
142
- *
143
- * THE THREE SPELLINGS OF SystemRoot. Windows' own environment is
144
- * case-insensitive, so on a real Windows box any one of these answers. A plain
145
- * object handed in by a test — or by a caller building an environment for a
146
- * child process — is a normal JavaScript object and is not, which is how the
147
- * variable that is always set in production reads as missing in a test.
148
- * exec.mjs reads two spellings for this reason; the third costs nothing.
149
- *
150
- * WHY THE FALLBACK NAMES A DRIVE. With no root at all, joining would produce
151
- * `\System32\drivers\etc\hosts` — a path that is rooted but drive-less, which
152
- * Windows resolves against the CURRENT drive. That is a different file on every
153
- * drive the deck might be started from, and reading the wrong file here means
154
- * reporting "not blocked" for a machine that is blocked. `C:\Windows` is a
155
- * guess, but it is a stated one and it is right on approximately every Windows
156
- * install; a silently drive-relative path is wrong in a way nobody can see.
157
- */
158
- export function hostsPath(platform = process.platform, env = process.env) {
159
- if (platform !== "win32") return "/etc/hosts";
160
- const e = env || {};
161
- const root = String(e.SystemRoot || e.systemroot || e.SYSTEMROOT || "").trim();
162
- return winPath.join(root || "C:\\Windows", "System32", "drivers", "etc", "hosts");
163
- }
164
-
165
- /**
166
- * One line's mapping, or null if the line carries none.
167
- *
168
- * The hosts format has no quoting: everything from the first `#` is a comment,
169
- * the rest is an address followed by one or more names. So a line that is
170
- * entirely a comment loses its whole body here and answers null — which is what
171
- * keeps a commented-out killswitch, and a line of prose that merely mentions
172
- * the host, out of every list this module returns.
173
- */
174
- function mapping(line) {
175
- const body = line.split("#")[0];
176
- const fields = body.trim().split(/[ \t]+/).filter(Boolean);
177
- if (fields.length < 2) return null;
178
- return { address: fields[0], names: fields.slice(1) };
179
- }
180
-
181
- /**
182
- * What a hosts file says about the relay. Takes the TEXT; the caller does the
183
- * reading, so this stays a pure function and the module stays incapable of
184
- * touching the file at all.
185
- *
186
- * ours lines this feature wrote — the tagged form, exactly. These are the
187
- * lines the unblock command is allowed to delete.
188
- * foreign every OTHER live mapping of the host: an untagged black-hole
189
- * somebody added by hand, a corporate mapping pushed by config
190
- * management, the host riding along as an alias on another line.
191
- * Never deleted, always surfaced.
192
- * blocked whether the name actually fails to resolve to anything reachable.
193
- *
194
- * WHY `blocked` IS NOT `ours.length > 0`. A hosts file resolves on the first
195
- * matching entry, so one line reading `10.4.0.9 bridge.claudeusercontent.com`
196
- * above our own defeats the block completely while leaving our line right
197
- * there in the file. Reporting "protected" in that state is the single worst
198
- * thing a panel like this can do, so any live mapping pointing somewhere
199
- * reachable takes the claim away — and `foreign` is what tells the user which
200
- * line to go look at. The mirror case is generous in the safe direction: an
201
- * untagged `0.0.0.0` block that somebody else installed is a real block, and it
202
- * counts, even though it is not ours to remove.
203
- *
204
- * Host comparison is case-insensitive because DNS is, and because a hosts file
205
- * with `Bridge.ClaudeUserContent.com` in it resolves exactly the same way. That
206
- * generosity applies to `foreign` only — see OURS for why the other list stays
207
- * byte-exact.
208
- *
209
- * A non-string answers the empty verdict rather than throwing: the caller's
210
- * read can fail (no such file on a stripped container, EACCES under a hardened
211
- * profile) and a panel that cannot see the file has nothing to report, which is
212
- * not the same as a crash.
213
- */
214
- export function readKillswitch(text) {
215
- const ours = [];
216
- const foreign = [];
217
- if (typeof text !== "string") return { blocked: false, ours, foreign };
218
- let sinkholed = false;
219
- let reachable = false;
220
- // Every line ending, including the lone CR nothing writes any more and the
221
- // CRLF every Windows hosts file uses. Splitting on all three is what keeps a
222
- // trailing \r out of the strings handed back — a `\r` clinging to the end of
223
- // a line would defeat the `$` anchor and quietly make a real killswitch read
224
- // as foreign on the one platform where the file is always CRLF.
225
- for (const line of text.split(/\r\n|\r|\n/)) {
226
- const m = mapping(line);
227
- if (!m) continue;
228
- if (!m.names.some(n => n.toLowerCase() === RELAY_HOST)) continue;
229
- if (OURS.test(line)) ours.push(line);
230
- else foreign.push(line);
231
- if (BLACK_HOLE.has(m.address.toLowerCase())) sinkholed = true;
232
- else reachable = true;
233
- }
234
- return { blocked: sinkholed && !reachable, ours, foreign };
235
- }
236
-
237
- /** macOS. `dscacheutil` empties the cache and the SIGHUP restarts the resolver
238
- * that holds the rest of it; neither alone is enough, and both have been the
239
- * documented pair for long enough to survive an OS release. The signal needs
240
- * root — mDNSResponder is not the user's process — and by this point in the
241
- * paste sudo has already been answered once, so it costs no second prompt. */
242
- const FLUSH_DARWIN = "dscacheutil -flushcache 2>/dev/null; " +
243
- "sudo killall -HUP mDNSResponder 2>/dev/null || true";
244
-
245
- /** Linux. `resolvectl` only exists where systemd-resolved does, and plenty of
246
- * distributions run something else or nothing at all — so it is probed for
247
- * rather than attempted, and the `|| true` means the whole paste still exits
248
- * 0 on a machine that has no such tool. A missing flush is a cache that
249
- * expires on its own in a few minutes; a failed paste is a user who thinks
250
- * the block did not happen and does it again. */
251
- const FLUSH_LINUX = "command -v resolvectl >/dev/null 2>&1 && " +
252
- "sudo resolvectl flush-caches 2>/dev/null || true";
253
-
254
- /** Windows. Needs no elevation of its own, and runs last so its exit status is
255
- * the only one the user sees. */
256
- const FLUSH_WINDOWS = "ipconfig /flushdns";
257
-
258
- /**
259
- * The command that flips the killswitch. `on: true` blocks the relay, `on:
260
- * false` lifts the block.
261
- *
262
- * IT IS A STRING. The deck prints it, the user reads it, the user pastes it.
263
- * Nothing here runs it — see the file header for why that boundary is the whole
264
- * design and not a limitation waiting to be lifted.
265
- *
266
- * THE LEADING NEWLINE. The block command tests the last byte of the file before
267
- * appending, and appends a newline first if the file does not end in one.
268
- * Without that test, a hosts file whose last line has no terminator — which is
269
- * a perfectly ordinary file, and what several config-management tools leave
270
- * behind — gets our text welded onto the end of that last line, producing one
271
- * corrupt entry out of two valid ones. `$(…)` strips trailing newlines, so the
272
- * substitution is empty exactly when the file already ends in one; nothing else
273
- * is being tested there.
274
- *
275
- * ONE `sudo`, NOT THREE. The append is two writes and a read of the same file,
276
- * wrapped in a single `sh -c` so the user is asked for a password once and can
277
- * read the whole of what is about to run as root in one place. The flush is
278
- * separated by `;` rather than `&&` for the same reason a missing flush tool is
279
- * probed for: a cache that would not clear must not take the block down with
280
- * it.
281
- *
282
- * WHY THE UNBLOCK USES sed AND WHY THE FLAG DIFFERS PER PLATFORM. `sed -i` is
283
- * the readable form, and a command a person is about to run as root earns
284
- * readability. BSD sed requires an explicit backup suffix and GNU sed forbids
285
- * one, so the two spellings are not interchangeable — which is exactly what the
286
- * `platform` parameter is for. The `else` leg here is GNU-shaped, so a BSD that
287
- * is neither Darwin nor Linux would need its own; ccdeck states support for
288
- * Linux, macOS and Windows, and this is the edge of that statement rather than
289
- * an oversight.
290
- *
291
- * NOT IDEMPOTENT, ON PURPOSE. Pasting the block twice writes the line twice.
292
- * That resolves identically, and the unblock deletes every matching line, so
293
- * the state heals itself — cheaper than a `grep -q` guard that would double the
294
- * length of a command whose readability is the point.
295
- */
296
- export function killswitchCommand(platform = process.platform, { on }) {
297
- if (typeof on !== "boolean") {
298
- // Not a defensive nicety. The two commands are opposites, and a caller that
299
- // forgot the field would otherwise get whichever one the default happened
300
- // to name — silently lifting a block the user asked to install.
301
- throw new TypeError("killswitchCommand needs { on: true } or { on: false }");
302
- }
303
- if (platform === "win32") return windowsCommand(on);
304
- const file = hostsPath(platform);
305
- const flush = platform === "darwin" ? FLUSH_DARWIN : FLUSH_LINUX;
306
- // BSD sed wants the backup suffix as its own argument and reads an empty one
307
- // as "no backup"; GNU sed reads a following argument as the script.
308
- const inPlace = platform === "darwin" ? "sed -i ''" : "sed -i";
309
- // ALWAYS A LEADING NEWLINE, WHICH IS WHY THIS IS ONE SHORT LINE.
310
- //
311
- // The first version tested whether the file already ended in one — `[ -n
312
- // "$(tail -c1 …)" ] && printf …` inside a `sudo sh -c '…'` with nested quotes
313
- // — because appending to a hosts file with no trailing newline glues the new
314
- // entry onto its last line and corrupts it. Correct, and three lines of
315
- // shell that a person is asked to read before running as root, which is
316
- // exactly the wrong place to spend somebody's attention.
317
- //
318
- // A leading `\n` is correct in BOTH cases and needs no test: a file that ends
319
- // in a newline gains a blank line, which every hosts parser ignores, and one
320
- // that does not gets its last line finished. `tee -a` also drops the nested
321
- // quoting entirely, since only `tee` needs to be root — the printf runs as
322
- // the user and the pipe carries the text.
323
- const command = on
324
- ? `printf '\\n%s\\n' "${KILLSWITCH_LINE}" | sudo tee -a ${file} >/dev/null\n${flush}`
325
- : `sudo ${inPlace} '/${TAGGED_BRE}/d' ${file}\n${flush}`;
326
- return { command, needsAdmin: true, note: note(platform, on) };
327
- }
328
-
329
- /**
330
- * The Windows pair, as PowerShell.
331
- *
332
- * `$env:SystemRoot` rather than the path this process resolved: the command
333
- * runs in somebody else's elevated shell, and that shell knows where Windows is
334
- * installed without being told by us. It also keeps this process's environment
335
- * out of a string that is about to run as Administrator.
336
- *
337
- * Read-modify-write through `[IO.File]` rather than `Add-Content`, because
338
- * Add-Content appends a terminator AFTER its value and never one before it —
339
- * which is precisely the missing-trailing-newline corruption, just spelled in
340
- * PowerShell. Writing the whole text back keeps the file's own ACL: the handle
341
- * truncates a file that already exists rather than creating a new one, so the
342
- * inherited permissions on a file in System32 are not quietly replaced by
343
- * whatever the elevated shell would have created.
344
- *
345
- * `-cnotmatch`, not `-notmatch`. PowerShell's comparison operators are
346
- * case-INSENSITIVE by default, which would let the delete take a line the
347
- * `ours` matcher above refuses to claim — a command that removes more than the
348
- * module says it will is the exact failure this feature exists to not repeat.
349
- * `@(…)` forces an array so that a hosts file reduced to a single line does not
350
- * arrive at WriteAllLines as a bare string.
351
- */
352
- function windowsCommand(on) {
353
- const file = "$h = $env:SystemRoot + \"\\System32\\drivers\\etc\\hosts\"";
354
- const command = on
355
- ? [
356
- file,
357
- "$t = [IO.File]::ReadAllText($h)",
358
- "if ($t.Length -gt 0 -and -not $t.EndsWith(\"`n\")) { $t += \"`r`n\" }",
359
- `$t += "${KILLSWITCH_LINE}\`r\`n"`,
360
- "[IO.File]::WriteAllText($h, $t)",
361
- FLUSH_WINDOWS,
362
- ].join("; ")
363
- : [
364
- file,
365
- `$p = '${TAGGED_PATTERN}'`,
366
- "[IO.File]::WriteAllLines($h, @(Get-Content -LiteralPath $h | " +
367
- "Where-Object { $_ -cnotmatch $p }))",
368
- FLUSH_WINDOWS,
369
- ].join("; ");
370
- return { command, needsAdmin: true, note: note("win32", on) };
371
- }
372
-
373
- /**
374
- * What the command does not do, said in the panel rather than discovered later.
375
- *
376
- * The sentence about existing connections is the one that matters and it is
377
- * deliberately not written any stronger than what was actually observed: a
378
- * hosts entry is consulted when a name is resolved, and a socket that is
379
- * already open was resolved before the entry existed. It stays up. What ends it
380
- * is the browser restarting — not this command, and not waiting.
381
- */
382
- function note(platform, on) {
383
- const paste = platform === "win32"
384
- ? "Run it in a PowerShell started as Administrator."
385
- : "Paste it in a terminal yourself.";
386
- // Named for what the platform actually shows, because the promise is about a
387
- // dialog the user might otherwise see with ccdeck's name on it.
388
- const never = platform === "win32"
389
- ? "ccdeck never runs it and never raises a UAC prompt."
390
- : "ccdeck never runs it and never asks for your password.";
391
- const survives = "Blocking the name stops new connections to the relay; " +
392
- "it does not close one the extension already holds, and that one lasts " +
393
- "until the browser restarts.";
394
- const surgical = `Removes only the line tagged "${TAG}" — any other mapping ` +
395
- `of ${RELAY_HOST} in the file is left exactly where it is.`;
396
- const flush = platform === "linux"
397
- ? " The DNS cache flush runs only where systemd-resolved is installed and " +
398
- "is skipped, not failed, everywhere else."
399
- : "";
400
- return `${paste} ${never} ${on ? survives : surgical}${flush}`;
401
- }
402
-
403
- /**
404
- * The API surface worth reporting, in the order it earns alarm.
405
- *
406
- * Every one of these was granted at the BROWSER level, and that is the fact to
407
- * hold on to: `debugger` is the Chrome DevTools Protocol over every tab, which
408
- * is read-anything and click-anything; `nativeMessaging` reaches a program
409
- * outside the sandbox; `downloads` writes files to disk; `tabs` and `scripting`
410
- * are the enumerate-and-inject pair.
411
- */
412
- const SENSITIVE_APIS = ["debugger", "nativeMessaging", "downloads", "tabs", "scripting"];
413
-
414
- // Host patterns that mean every site there is. `<all_urls>` is what the real
415
- // profile on this machine carries; the any-scheme wildcard below it is the
416
- // other spelling Chrome accepts for the same reach, and missing it would
417
- // under-report the one thing this field exists to report. A scheme-specific
418
- // wildcard — every https site, say — is deliberately NOT counted: it is broad,
419
- // but it is not every site, and this flag should mean what it says.
420
- //
421
- // Written as line comments rather than a block, because the pattern itself
422
- // contains the sequence that closes one.
423
- const EVERY_SITE = new Set(["<all_urls>", "*://*/*"]);
424
-
425
- /** Defensive array read. A "Secure Preferences" file is Chrome's to write and
426
- * ours only to read; a field that is a string where an array was expected is a
427
- * Chrome release note we have not seen yet, not a reason to throw inside a
428
- * panel. */
429
- const list = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
430
-
431
- /**
432
- * What one browser profile granted the extension. Takes the already-parsed
433
- * "Secure Preferences" object, so the file reading — and its JSON.parse, which
434
- * throws on a profile Chrome is mid-write on — stays with the caller.
435
- *
436
- * present the extension has an entry in this profile.
437
- * enabled and it is not switched off.
438
- * allUrls it may act on every site.
439
- * sensitiveApis which of the APIs above it holds, in SENSITIVE_APIS order
440
- * rather than the file's, so the panel renders the same list
441
- * twice in a row and a test can assert on it.
442
- *
443
- * WHY `disable_reasons` AND NOT `state`. Checked against a real profile on this
444
- * machine: the entry carries `disable_reasons: []` and no `state` key at all.
445
- * A non-empty array is Chrome saying why it turned the extension off, so an
446
- * empty one is "no reason to be off" — enabled. The direction is worth stating
447
- * because it is the good news in this whole module, and a report that shows a
448
- * disabled extension as a live threat is a report the user stops reading.
449
- *
450
- * WHY REMOVING SITE PERMISSIONS IN chrome://extensions DOES NOT HELP. These
451
- * permissions are held at the browser level — the same profile shows
452
- * `withholding_permissions: false` and `<all_urls>` in both `explicit_host` and
453
- * `scriptable_host` — and the per-site allowlist a user configures is enforced
454
- * INSIDE the extension, by the extension. Tightening it narrows what the
455
- * extension chooses to do, not what it is able to do, and an operator driving
456
- * it through the relay is not bound by the extension's own UI. So this field
457
- * reports the browser-level grant, which is the one that would still be true
458
- * after a user "fixed" it in the settings page.
459
- *
460
- * `scriptable_host` is read alongside `explicit_host` for the same
461
- * under-reporting reason as the any-scheme wildcard in EVERY_SITE:
462
- * content-script reach into every page is the same exposure arriving through a
463
- * different key.
464
- */
465
- export function extensionReport(securePreferences, extId = CLAUDE_EXT_ID) {
466
- const settings = securePreferences?.extensions?.settings;
467
- const has = settings && typeof settings === "object" &&
468
- typeof extId === "string" && Object.hasOwn(settings, extId);
469
- // Object.hasOwn rather than a plain lookup: `settings["constructor"]` is a
470
- // function on every object alive and `settings["__proto__"]` is a prototype,
471
- // and either one would sail past a truthiness check and be reported as an
472
- // installed extension. The id is a caller-supplied string; see
473
- // prototype-keys-474.test.ts for the same footgun caught elsewhere here.
474
- const entry = has ? settings[extId] : null;
475
- if (!entry || typeof entry !== "object") {
476
- return { present: false, enabled: false, allUrls: false, sensitiveApis: [] };
477
- }
478
- const granted = entry.granted_permissions;
479
- const hosts = [...list(granted?.explicit_host), ...list(granted?.scriptable_host)];
480
- const api = new Set(list(granted?.api));
481
- return {
482
- present: true,
483
- enabled: !(Array.isArray(entry.disable_reasons) && entry.disable_reasons.length > 0),
484
- allUrls: hosts.some(h => EVERY_SITE.has(h)),
485
- sensitiveApis: SENSITIVE_APIS.filter(name => api.has(name)),
486
- };
487
- }
488
-
489
- /**
490
- * The headline, from the two facts that decide it.
491
- *
492
- * `anyExtension` is the caller's aggregate across every profile of every
493
- * browser it found — one enabled copy anywhere is enough, because the relay is
494
- * per-account and not per-profile. With no extension installed there is nothing
495
- * to block and nothing to warn about, so a machine with no browser extension
496
- * and no hosts entry is "nothing-exposed" rather than "exposed": the killswitch
497
- * is not a thing this user has to do.
498
- *
499
- * Deliberately three states and not two. "protected" and "nothing-exposed" both
500
- * mean "no action needed" today, and collapsing them would make the panel say
501
- * the block is working on a machine where it was never needed — which is the
502
- * kind of reassurance that stops meaning anything.
503
- */
504
- export function verdict({ anyExtension, blocked } = {}) {
505
- if (!anyExtension) return "nothing-exposed";
506
- return blocked ? "protected" : "exposed";
507
- }
@@ -1,78 +0,0 @@
1
- // When a quota window resets, written one way — for the Claude lanes and the
2
- // Codex lanes both, which land in the same panel.
3
- //
4
- // WHY THIS MODULE EXISTS (#374). quota.mjs had `fmtResetIso` and codex-quota.mjs
5
- // had `fmtReset`. They passed identical option bags to `toLocaleString` and then
6
- // post-processed the result differently, and codex-quota's own doc comment said
7
- // of its copy: "matches the Claude quota formatting so both read alike." It did
8
- // not. Over the same instant:
9
- //
10
- // toLocaleString : "Jun 18, 4:09 PM"
11
- // quota.mjs : "Jun 18, 4:09pm"
12
- // codex-quota.mjs: "jun 18 4:09pm" ← comma stripped, month lower-cased
13
- //
14
- // Swept over 2,794 instants across 40 days, the two disagreed on every single
15
- // one — not at a boundary, on all of them, because the difference is two
16
- // unconditional string operations rather than a tier that rarely fires. Both
17
- // strings surface in the same usage panel, one lane above the other.
18
- //
19
- // The Claude rendering is the one kept, on the comment's own terms: it is the
20
- // one the other claimed to match, and it is the one that reads like a date —
21
- // `Intl` capitalises the month and puts the comma in for the locale's own
22
- // reasons, and stripping both is a hand edit to output that was already right.
23
- // This is a FIX to what the Codex lanes print, not a neutral merge, and it is
24
- // the only visible change in this consolidation.
25
- //
26
- // The one genuine difference between the copies was the input unit — Codex
27
- // answers with a Unix timestamp in seconds and Anthropic with an ISO-8601
28
- // string — so that is the wrapper below rather than a second formatter.
29
- //
30
- // A module of its own rather than one importing the other: quota.mjs reads
31
- // Claude's OAuth credentials off disk, and having the Codex path import that to
32
- // reach a date formatter would drag the whole Claude credential chain into a
33
- // request that has nothing to do with it.
34
-
35
- /** en-US on purpose, not the host locale. This string is generated on the
36
- * server and shipped to a browser whose locale nobody here has asked, so a
37
- * server set to de-DE would otherwise send "18. Juni, 16:09" into a panel
38
- * written in English. Both copies already hardcoded en-US; it is written down
39
- * here so the next reader knows it was a decision. */
40
- const OPTS = { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true };
41
-
42
- /** The rendering itself, over a Date both entry points have already validated.
43
- * The meridiem is lower-cased and its space removed — the only edit made to
44
- * what `Intl` produces — because "4:09 PM" shouts in a lane label that is a
45
- * few characters of chrome wide. */
46
- function label(d) {
47
- return d.toLocaleString("en-US", OPTS).replace(/\s+(AM|PM)/, (_, p) => p.toLowerCase());
48
- }
49
-
50
- /** "Jun 18, 4:09pm" from a Unix timestamp in seconds, which is how the Codex
51
- * usage endpoint spells a reset time. Null when there is no reset to name.
52
- *
53
- * The validity check is inherited from the ISO copy, which had one where the
54
- * seconds copy did not: a finite but absurd number — the only shape that gets
55
- * past the falsy guard and still fails to be a date — used to render as the
56
- * literal string "invalid date" in a quota lane. Nothing observed sends one,
57
- * so this is hardening rather than a fix, and it is the safer of the two
58
- * behaviours in the same way `isOlder`'s type guard is. */
59
- export function resetLabel(unixSec) {
60
- if (!unixSec) return null;
61
- const d = new Date(unixSec * 1000);
62
- if (isNaN(d.getTime())) return null;
63
- return label(d);
64
- }
65
-
66
- /** The same label from an ISO-8601 instant, which is how Anthropic's usage
67
- * endpoint spells a reset time. Invalid and absent both answer null, so a
68
- * malformed field renders as no reset rather than "Invalid Date".
69
- *
70
- * Not written as `resetLabel(d.getTime() / 1000)`: that would send an instant
71
- * at the Unix epoch through the falsy guard above and answer null for a date
72
- * this function has already established is valid. */
73
- export function resetLabelIso(iso) {
74
- if (!iso) return null;
75
- const d = new Date(iso);
76
- if (isNaN(d.getTime())) return null;
77
- return label(d);
78
- }