agent-dag 3.9.0 → 3.11.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 +2 -2
- package/dist/web/assets/index-Dya_sxoa.js +227 -0
- package/dist/web/assets/index-XrBtr430.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +56 -32
- package/src/server/browser-profiles.mjs +13 -1
- package/src/server/browser-watch.mjs +109 -1
- package/src/server/index.mjs +8 -19
- package/src/server/open-url.mjs +7 -12
- package/src/server/relay-guard.mjs +5 -0
- package/src/server/system-metrics.mjs +17 -0
- package/dist/web/assets/index-CLkhZk3Y.js +0 -217
- package/dist/web/assets/index-IAX04wVV.css +0 -1
|
@@ -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),
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
1463
|
-
*
|
|
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;
|
package/src/server/open-url.mjs
CHANGED
|
@@ -158,19 +158,14 @@ export function startCommand(url, env = process.env, comspec) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
*
|
|
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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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);
|