agent-dag 3.9.0 → 3.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,12 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "3.10.0": [
35
+ {
36
+ "title": "\ud83d\udd0c Who else can drive your browser, and where to see it",
37
+ "body": "**Browser Watch has a Remote control section.** The Claude in Chrome extension holds a connection open to Anthropic's relay, and that connection is registered against your **account**, not against this machine \u2014 so any Claude Code session signed in to the same account, running anywhere, can enumerate and drive this browser, and nothing prompts you on the machine being driven.\n\nThe panel now says whether that is the case here, and what the extension is actually allowed to do in each profile \u2014 enabled or switched off, every site or only the ones it was granted, and which of the powerful APIs it holds. That last part is not what `chrome://extensions` shows you: the per-site list there is enforced by the extension, on itself, while the grant underneath stays exactly as wide as it was.\n\nIf you want it closed, the section hands you the one command that closes it \u2014 as text, to paste in your own terminal. **ccdeck never runs it and never asks for your password**, and it never will: no part of this deck elevates, and a dashboard that could raise a password box on request would be a gift to anything else running on the machine. If the block is already in place, the section offers the command that takes it back out. And if something else in your hosts file already points that name somewhere, it says so and leaves the line alone \u2014 ccdeck only removes the line ccdeck wrote.\n\nAll of this is two file reads and no network call. It was written some releases ago and, until now, was not connected to any screen.\n\n**Also:** the README said `share` produces a `ccdeck1:` blob. It produces `ccdeck2:` \u2014 `ccdeck1:` has been read-only legacy for a while."
38
+ }
39
+ ],
34
40
  "3.9.0": [
35
41
  {
36
42
  "title": "\ud83c\udf9b\ufe0f Five switches that were not where the thing they control is",
@@ -33,6 +33,15 @@
33
33
  import { existsSync as fsExistsSync, readdirSync as fsReaddirSync, statSync as fsStatSync } from "node:fs";
34
34
  import { homedir } from "node:os";
35
35
  import { posix as posixPath, win32 as winPath } from "node:path";
36
+ // ONE DECLARATION OF THE EXTENSION ID (#798). It was written out here and again
37
+ // in relay-guard.mjs, with the same literal, in two modules that never imported
38
+ // each other — so republishing the extension under a new id and updating one
39
+ // would leave the other detector answering "not installed" forever, with
40
+ // nothing going red anywhere. It comes from relay-guard because that direction
41
+ // is the safe one: relay-guard imports node:path and nothing else, a property
42
+ // its own header states and relay-guard.test.ts pins by reading the source, and
43
+ // importing this module (node:fs) into it would break that.
44
+ import { CLAUDE_EXT_ID } from "./relay-guard.mjs";
36
45
 
37
46
  /**
38
47
  * Claude in Chrome's extension id, which is the same 32 characters in every
@@ -42,8 +51,11 @@ import { posix as posixPath, win32 as winPath } from "node:path";
42
51
  * the browser, so Brave, Edge and Vivaldi all hold it under this exact name.
43
52
  * That is what makes a directory test sufficient here and what lets one
44
53
  * constant serve every row this module returns.
54
+ *
55
+ * Re-exported rather than moved, so the callers that read it from here keep
56
+ * working and the name still documents itself where it is used.
45
57
  */
46
- export const CLAUDE_EXT_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
58
+ export { CLAUDE_EXT_ID };
47
59
 
48
60
  /**
49
61
  * The directories inside a user-data root that are browsing profiles.
@@ -37,7 +37,7 @@ import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./
37
37
  import { appendLog, logPath, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
38
38
  import { browserSurvey } from "./browser-presence.mjs";
39
39
  import { available, performable, react } from "./browser-react.mjs";
40
- import { RELAY_HOST } from "./relay-guard.mjs";
40
+ import { RELAY_HOST, hostsPath, readKillswitch, extensionReport, killswitchCommand, verdict } from "./relay-guard.mjs";
41
41
 
42
42
  /**
43
43
  * The moment this deck started, and the only floor any read uses.
@@ -77,6 +77,106 @@ function mtimeMs(file, deps) {
77
77
  try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
78
78
  }
79
79
 
80
+ /** Secure Preferences reports, keyed on the file and its mtime — see
81
+ * `relayGuard` for why this one needs a cache more than the History read
82
+ * does. */
83
+ const extCache = new Map();
84
+
85
+ /**
86
+ * What relay-guard can say about this machine, from two reads it does not do
87
+ * itself.
88
+ *
89
+ * THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
90
+ * `RELAY_HOST` greped to its own declaration and its test, so the header's
91
+ * promise — "the one command that closes it … hands back the command that would
92
+ * change it, as text, for the user to paste" — reached no surface. A reader
93
+ * auditing this repo's security posture would have believed the killswitch and
94
+ * the grant report ship. This is that promise kept: the panel now renders both.
95
+ *
96
+ * relay-guard imports node:path and nothing else, on purpose, so the reading is
97
+ * here. Two sources:
98
+ *
99
+ * THE HOSTS FILE, once. Small, and the same file for every profile.
100
+ *
101
+ * EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
102
+ * the directory is there. This one is why there is a cache: it is a single
103
+ * JSON document holding every extension's settings and it runs to megabytes
104
+ * on a profile with a few installed, while the panel polls every ten seconds.
105
+ * Keyed on mtime like the History cache above, and for the same reason — a
106
+ * browser that is closed cannot invalidate it.
107
+ *
108
+ * A read that fails is not a report of "nothing installed": `null` for that
109
+ * profile, and the aggregate says so. The difference matters here more than
110
+ * anywhere else in this file, because the reassuring answer and the unreadable
111
+ * answer are the same shape.
112
+ */
113
+ async function relayGuard(profiles, { platform, env, deps }) {
114
+ const readOne = async (file) => {
115
+ const stamp = mtimeMs(file, deps);
116
+ if (stamp === null) return null;
117
+ const hit = extCache.get(file);
118
+ if (hit && hit.stamp === stamp) return hit.report;
119
+ let report = null;
120
+ try {
121
+ report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
122
+ } catch {
123
+ // Unreadable or not JSON — a profile being written as we looked, a
124
+ // hardened profile we cannot open. Not cached, so the next poll retries.
125
+ return null;
126
+ }
127
+ extCache.set(file, { stamp, report });
128
+ return report;
129
+ };
130
+
131
+ const seen = [];
132
+ for (const profile of profiles) {
133
+ // `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
134
+ // computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
135
+ // the whole reason this reads the preferences at all. But it is a free way
136
+ // to skip every profile that has no extension to report on.
137
+ if (!profile.hasClaudeExt) continue;
138
+ const report = await readOne(profile.securePrefsPath);
139
+ seen.push({
140
+ browser: profile.browser,
141
+ name: profile.name,
142
+ profile: profile.profile,
143
+ // Null when the file could not be read, which the panel says out loud
144
+ // rather than rendering as an absence of permissions.
145
+ report,
146
+ });
147
+ }
148
+
149
+ let hostsText = null;
150
+ const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
151
+ try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
152
+ const killswitch = readKillswitch(hostsText);
153
+
154
+ // ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
155
+ // relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
156
+ // profile with the extension switched off protects nothing.
157
+ const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
158
+
159
+ return {
160
+ relayHost: RELAY_HOST,
161
+ hostsPath: hosts,
162
+ // Whether the hosts file could be read at all. `blocked: false` from an
163
+ // unreadable file and `blocked: false` from a file with no entry are the
164
+ // same value and not the same fact.
165
+ hostsRead: typeof hostsText === "string",
166
+ profiles: seen,
167
+ anyExtension,
168
+ killswitch,
169
+ verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
170
+ // Both, always, so the panel can offer the one that matches the state
171
+ // without having to know how either is spelled. Text only — nothing here
172
+ // runs it, and relay-guard could not if it tried.
173
+ command: {
174
+ block: killswitchCommand(platform, { on: true }),
175
+ unblock: killswitchCommand(platform, { on: false }),
176
+ },
177
+ };
178
+ }
179
+
80
180
  /**
81
181
  * Visits for one profile, re-reading only when the browser has written since
82
182
  * the last look.
@@ -503,6 +603,10 @@ export async function browserWatchSnapshot({
503
603
  log: watchLog(),
504
604
  profiles: [],
505
605
  browsers: [],
606
+ // Null rather than an empty report: this poll read no browser at all, and
607
+ // "the extension is not installed" is not something it is in a position
608
+ // to say. The panel renders the difference.
609
+ relay: null,
506
610
  episodes: archived,
507
611
  coverage: {
508
612
  startedMs: STARTED_MS,
@@ -753,10 +857,14 @@ export async function browserWatchSnapshot({
753
857
  _checkedMs = now;
754
858
 
755
859
  const browsers = await surveyBrowsers(platform, env, now, deps);
860
+ // Two small reads, and the answer to the one question this panel exists
861
+ // beside: whether somebody else's Claude Code can drive this browser (#799).
862
+ const relay = await relayGuard(profiles, { platform, env, deps }).catch(() => null);
756
863
 
757
864
  return {
758
865
  ok: true,
759
866
  settings: store.settings,
867
+ relay,
760
868
  // What this platform can actually do, so the panel never offers a mode
761
869
  // that would silently do nothing. See browser-react.mjs.
762
870
  reactions: available(platform),
@@ -788,7 +788,7 @@ const AGENT_NAME_MARK = '"agent-name"';
788
788
  /** Fold one line's naming records into `out`. Last value wins. Pure: `out` is
789
789
  * the only thing written, and a line that is not one of the two records — or
790
790
  * is a truncated fragment of one — leaves it untouched. */
791
- function foldSessionNamingLine(out, line) {
791
+ export function foldSessionNamingLine(out, line) {
792
792
  if (!line) return;
793
793
  const hasTitle = line.includes(AI_TITLE_MARK);
794
794
  const hasName = line.includes(AGENT_NAME_MARK);
@@ -803,22 +803,6 @@ function foldSessionNamingLine(out, line) {
803
803
  }
804
804
  }
805
805
 
806
- /**
807
- * The session naming carried by a chunk of transcript text, newest wins.
808
- *
809
- * Pure and text-in, so the suite can pin the parsing against a handful of lines
810
- * instead of a 46 MB fixture. Returns `{aiTitle: null, agentName: null}` for a
811
- * chunk holding neither — a young session, or a stretch of the file that is all
812
- * tool output — and the caller keeps whatever it already knew rather than
813
- * clearing a name it has already shown.
814
- */
815
- export function readSessionNaming(text) {
816
- const out = { aiTitle: null, agentName: null };
817
- if (!text || typeof text !== "string") return out;
818
- for (const line of text.split("\n")) foldSessionNamingLine(out, line);
819
- return out;
820
- }
821
-
822
806
  /** Fold one transcript line into the running state. Every fact the three
823
807
  * scanners need lives on a single line, so line-at-a-time folding sees
824
808
  * exactly what a whole-file pass would. */
@@ -1459,8 +1443,13 @@ const lastNameReadAt = new Map(); // sid -> ms timestamp
1459
1443
  const pendingNameReads = new Set(); // sid currently being read
1460
1444
 
1461
1445
  /** The naming the cursor has folded so far, or null when the scan has nothing.
1462
- * Exported beside readContextFromTranscript for the same reason: the rule is
1463
- * worth pinning directly rather than through a live server. */
1446
+ *
1447
+ * NOT exported, and the comment here used to say it was — "beside
1448
+ * readContextFromTranscript … the rule is worth pinning directly rather than
1449
+ * through a live server" — which stated a test contract no test had (#798).
1450
+ * What is worth pinning is the parsing, and that is `foldSessionNamingLine`,
1451
+ * which IS exported and which session-name.test.ts drives line at a time the
1452
+ * way `foldTranscriptLine` does. This wrapper is a scan and two null checks. */
1464
1453
  async function readSessionNamingFromTranscript(path) {
1465
1454
  const state = await scanTranscript(path);
1466
1455
  if (!state) return null;
@@ -158,19 +158,14 @@ export function startCommand(url, env = process.env, comspec) {
158
158
  }
159
159
 
160
160
  /**
161
- * Refuse anything that is not an http(s) URL.
161
+ * The address to actually hand the desktop, or null when it is not one and
162
+ * the ONLY guard, since #798 removed the `isOpenable` boolean that sat in front
163
+ * of it with no caller but the suite.
162
164
  *
163
- * The one caller passes a localhost address it built itself, so this is not
164
- * guarding against a hostile input today it is guarding against the day a
165
- * second caller passes a path, because every launcher above would happily open
166
- * it and `start` would run it.
167
- */
168
- export function isOpenable(url) {
169
- return normalizeOpenable(url) !== null;
170
- }
171
-
172
- /**
173
- * The address to actually hand the desktop, or null when it is not one.
165
+ * Refuse anything that is not an http(s) URL: today's one caller passes a
166
+ * loopback address this deck built itself, so this is not guarding against a
167
+ * hostile input it is guarding against the day a second caller passes a path,
168
+ * because every launcher above would happily open it and `start` would run it.
174
169
  *
175
170
  * The guard used to parse into a local `u`, check its protocol and throw the
176
171
  * parse away — so what reached `launchers` was the RAW string. `new URL()`
@@ -12,6 +12,11 @@
12
12
  // the one holding the user's logged-in sessions, so the question is worth a
13
13
  // panel rather than a footnote.
14
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
+ //
15
20
  // WHAT THIS MODULE DOES. Two reads and a string. It says whether the extension
16
21
  // is installed and what it was granted (from a profile's "Secure Preferences",
17
22
  // which the caller parses), and whether the hosts file already black-holes the
@@ -1463,6 +1463,23 @@ export function startSystemMetrics() {
1463
1463
  thermalTimer.unref?.();
1464
1464
  }
1465
1465
 
1466
+ /**
1467
+ * Stop the three timers and reset every reading this module holds.
1468
+ *
1469
+ * THE SUITE'S, AND SAID PLAINLY (#798). Production starts the loop once at boot
1470
+ * and never stops it — the process ending is what stops it — so an audit
1471
+ * grepping for callers finds none, and the honest answer is not to delete this
1472
+ * but to name what it is for. It is a RESET as much as a stop: `history`,
1473
+ * `thermal`, the CPU baselines and the miss counters all go back to their
1474
+ * initial values, which is exactly what a case needs between two runs of
1475
+ * `startSystemMetrics` in one process, and what nothing else in this module
1476
+ * offers. Deleting it would leave the suite leaking intervals into the values
1477
+ * the next case reads.
1478
+ *
1479
+ * That is also why it is safe as a test-only export where the four removed in
1480
+ * #798 were not: there is no shipped counterpart for it to drift away from. The
1481
+ * state it clears IS the state every other assertion here reads.
1482
+ */
1466
1483
  export function stopSystemMetrics() {
1467
1484
  if (cpuTimer) clearInterval(cpuTimer);
1468
1485
  if (memTimer) clearInterval(memTimer);