@angelky7/dsh-browser-control 1.7.3 → 1.7.5

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.en.md CHANGED
@@ -268,10 +268,12 @@ curl -X POST http://127.0.0.1:9777/api/command \
268
268
  | `browser_emulate` | Switch to a device viewport (mobile / desktop / custom, v1.0.7+); v1.7.1+ adds throttling: `network` slow3g/fast3g/offline/reset + `cpuThrottlingRate` (1–20), the receipt states the applied numbers, `device:"reset"` clears them too |
269
269
  | `browser_metrics` | Read performance counters (DOM nodes / JS heap / layout & script durations, Performance.getMetrics, read-only; puts numbers behind "this page is heavy", v1.7.1+) |
270
270
  | `browser_drag` | Drag from/to a selector or viewport point: `auto` synthesizes html5 dragstart→dragover→drop for `draggable="true"` sources (no drop unless accepted), otherwise sends real mouse input (press, interpolated moves, release — sliders/canvas/sortable widgets). **Plugin-side only** over the cdp channel embedded in extension v1.7.2, v1.7.3+ |
271
+ | `browser_audit` | Read this plugin session's in-memory audit trail — every command sent or refused (redacted params, `code`/`effectState`, duration), capped at 200 entries and wiped on restart; typed text and cookie values are redacted, eval expressions truncated. Plugin-local read, works in read-only mode (v1.7.4+) |
272
+ | `browser_journal` | Read the **persistent journal across dsh restarts** (`<stateDir>/journal.jsonl`, last 1000 entries, grouped by run) — ask it after a restart "what was I doing in which tab"; same redaction; plugin-local disk read that works with the extension offline (v1.7.5+) |
271
273
  | `browser_cleanup` | Clean up temp files |
272
274
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
273
275
 
274
- 26 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
276
+ 28 tools in total. `browser_wait`, `browser_find`, `browser_dialog` and `browser_info` existed inside the extension up to v1.0.8 but were never registered as tools, so the agent could not reach them; `browser_network_clear` existed in code and was simply missing from this table. `verify/verify_surface.mjs` now reconciles the extension command table, the commands the plugin calls, this table and the built bundle, so any drift fails `npm run verify`.
275
277
 
276
278
  ## Architecture
277
279
 
package/README.md CHANGED
@@ -261,11 +261,13 @@ curl -X POST http://127.0.0.1:9777/api/command \
261
261
  | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
262
262
  | `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
263
263
  | `browser_drag` | 拖拽:from/to 用选择器或视口坐标;`auto` 对 `draggable="true"` 的源走 html5 事件合成(dragstart→dragover→drop,未接受就不 drop),其余走真实鼠标输入(按下→插值移动→抬起,滑杆/画布/排序组件用这个)。**纯插件实现**(经 v1.7.2 预埋的 cdp 通道),v1.7.3+ |
264
+ | `browser_audit` | 读本次插件会话的内存审计账本:发过/被拒的每条命令(参数脱敏、`code`/`effectState`、耗时),最多 200 条、dsh 重启即清;输入的文字与 cookie 值打码、eval 表达式截断。纯本地读,只读档下照常可用(v1.7.4+) |
265
+ | `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000 条,按运行分组)——重启后先问它「上个会话在哪个标签页干了什么」;同样的脱敏规则;纯本地磁盘读,**扩展离线也能用**(v1.7.5+) |
264
266
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
265
267
  | `browser_cleanup` | 清理临时文件 |
266
268
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
267
269
 
268
- 共 26 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
270
+ 共 28 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
269
271
 
270
272
  ## 架构
271
273
 
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
1
+ import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import path from "node:path";
3
4
  import z from "@deepseek-ai/schemastery";
4
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -926,6 +927,213 @@ function policyRefusal(command, flags, params) {
926
927
  if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
927
928
  if (flags.readOnly && !READ_ONLY_ALLOWED.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会改变页面或浏览器状态,已被拒绝,没有向浏览器发出任何操作。只读模式下可用的工具是读页面与读日志那几类;要放开写操作,${CONFIG_HINT}不要原地重试本命令。`;
928
929
  }
930
+ /** 单条参数摘要上限:审计要的是形状,不是全文。 */
931
+ const AUDIT_PARAMS_MAX_CHARS = 300;
932
+ /** 失败描述上限。 */
933
+ const AUDIT_ERROR_MAX_CHARS = 200;
934
+ /** eval 表达式只留前 120 字符:里面可能有凭据。 */
935
+ const AUDIT_EXPRESSION_MAX_CHARS = 120;
936
+ /** 参数里这些键的值一律打码(大小写不敏感)。 */
937
+ const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
938
+ /**
939
+ * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
940
+ * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
941
+ */
942
+ const VALUE_REDACTED_COMMANDS = /* @__PURE__ */ new Set(["input", "cookies.set"]);
943
+ function redactValue(value) {
944
+ return `[redacted:${typeof value === "string" ? [...value].length : 0} chars]`;
945
+ }
946
+ /**
947
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
948
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
949
+ */
950
+ function summarizeAuditParams(command, params) {
951
+ let text;
952
+ try {
953
+ text = JSON.stringify(params, (key, value) => {
954
+ if (SECRET_KEY.test(key)) return "[redacted]";
955
+ if (key === "value" && VALUE_REDACTED_COMMANDS.has(command)) return redactValue(value);
956
+ if (key === "expression" && typeof value === "string" && [...value].length > AUDIT_EXPRESSION_MAX_CHARS) return `${[...value].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join("")}…[+${[...value].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
957
+ return value;
958
+ }) ?? "{}";
959
+ } catch {
960
+ text = "[unserializable params]";
961
+ }
962
+ return [...text].length > AUDIT_PARAMS_MAX_CHARS ? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join("")}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]` : text;
963
+ }
964
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
965
+ var AuditLog = class {
966
+ entries = [];
967
+ nextSeq = 1;
968
+ now;
969
+ constructor(now = Date.now) {
970
+ this.now = now;
971
+ }
972
+ /**
973
+ * 记一笔。
974
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
975
+ */
976
+ record(input) {
977
+ const entry = {
978
+ seq: this.nextSeq++,
979
+ t: this.now(),
980
+ command: input.command,
981
+ params: input.params,
982
+ ok: input.ok,
983
+ durationMs: Math.max(0, Math.round(input.durationMs))
984
+ };
985
+ if (input.tabId !== void 0) entry.tabId = input.tabId;
986
+ if (input.code !== void 0) entry.code = input.code;
987
+ if (input.effectState !== void 0) entry.effectState = input.effectState;
988
+ if (input.error !== void 0) entry.error = [...input.error].length > AUDIT_ERROR_MAX_CHARS ? `${[...input.error].slice(0, AUDIT_ERROR_MAX_CHARS).join("")}…` : input.error;
989
+ this.entries.push(entry);
990
+ if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
991
+ return entry;
992
+ }
993
+ /** 现有条目数(被挤掉的不算)。 */
994
+ size() {
995
+ return this.entries.length;
996
+ }
997
+ /**
998
+ * 过滤并取最新的一批。
999
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
1000
+ */
1001
+ list(filter = {}) {
1002
+ let matched = this.entries;
1003
+ if (filter.tabId !== void 0) matched = matched.filter((e) => e.tabId === filter.tabId);
1004
+ if (filter.command !== void 0 && filter.command.length > 0) {
1005
+ const needle = filter.command.toLowerCase();
1006
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
1007
+ }
1008
+ if (filter.sinceSeq !== void 0) {
1009
+ const floor = filter.sinceSeq;
1010
+ matched = matched.filter((e) => e.seq > floor);
1011
+ }
1012
+ const limit = Math.min(200, Math.max(1, Math.trunc(filter.limit ?? 50)));
1013
+ return matched.slice(Math.max(0, matched.length - limit));
1014
+ }
1015
+ };
1016
+ //#endregion
1017
+ //#region lib/types/journal.js
1018
+ /**
1019
+ * 会话恢复(v1.7.5):把审计账本持久化到磁盘,dsh 重启以后还查得到上一个会话做过什么。
1020
+ *
1021
+ * 为什么是文件而不是数据库:一条命令一行 JSONL,出问题时能直接打开看、能 grep、
1022
+ * 写坏了最多丢一行(读到坏行跳过,不炸整本账)。上限 1000 行,超了在**启动时**
1023
+ * 裁掉最旧的——运行中不裁剪,append 永远是纯追加。
1024
+ *
1025
+ * 隐私与审计同一套:写盘的就是 AuditLog 里那份脱敏后的摘要(输入值/cookie 值已
1026
+ * 打码),journal 层不再碰原始参数。文件在 <stateDir>/journal.jsonl,stateDir
1027
+ * 默认 ~/.dsh-browser-control,可在 profile 的 browser-bridge 配置里改。
1028
+ */
1029
+ /** 磁盘上最多保留的条数(启动时裁剪)。 */
1030
+ const JOURNAL_MAX_LINES = 1e3;
1031
+ /** 防御性上限:正常文件远小于它;超过就只读尾巴,别为了一本账吃掉内存。 */
1032
+ const JOURNAL_MAX_BYTES = 8388608;
1033
+ function isEntry(value) {
1034
+ if (value === null || typeof value !== "object") return false;
1035
+ const v = value;
1036
+ return typeof v.run === "string" && typeof v.command === "string" && typeof v.seq === "number" && typeof v.t === "number" && typeof v.params === "string" && typeof v.ok === "boolean" && typeof v.durationMs === "number";
1037
+ }
1038
+ /** 拆行并解析;坏行(截断、手改坏)跳过——一本账不能因为一行烂掉就全废。 */
1039
+ function parseLines(text) {
1040
+ const out = [];
1041
+ for (const line of text.split("\n")) {
1042
+ if (line.trim().length === 0) continue;
1043
+ try {
1044
+ const parsed = JSON.parse(line);
1045
+ if (isEntry(parsed)) out.push(parsed);
1046
+ } catch {}
1047
+ }
1048
+ return out;
1049
+ }
1050
+ /**
1051
+ * 打开(并按需裁剪)一个 journal 目录。
1052
+ * @param stateDir - 目录(不存在会创建);journal 文件是 `<stateDir>/journal.jsonl`。
1053
+ * @param log - 一行式日志;打开/裁剪的结论写进去。
1054
+ * @returns 带 run 编号的追加器;`close()` 等所有在途写入落盘。
1055
+ */
1056
+ var Journal = class Journal {
1057
+ /** 这次运行的编号:启动时刻(毫秒精度;秒级精度下两次启动同秒会撞号)。 */
1058
+ runId;
1059
+ file;
1060
+ log;
1061
+ chain = Promise.resolve();
1062
+ warned = false;
1063
+ constructor(file, runId, log) {
1064
+ this.file = file;
1065
+ this.runId = runId;
1066
+ this.log = log;
1067
+ }
1068
+ static async open(stateDir, log) {
1069
+ await mkdir(stateDir, { recursive: true });
1070
+ const file = path.join(stateDir, "journal.jsonl");
1071
+ let existing = [];
1072
+ try {
1073
+ const text = await readFile(file, "utf8");
1074
+ existing = parseLines(text.length > JOURNAL_MAX_BYTES ? text.slice(-8388608) : text);
1075
+ } catch {}
1076
+ if (existing.length > 1e3) {
1077
+ const kept = existing.slice(existing.length - JOURNAL_MAX_LINES);
1078
+ const tmp = `${file}.tmp`;
1079
+ await writeFile(tmp, kept.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8");
1080
+ await rename(tmp, file);
1081
+ log(`journal: pruned ${existing.length - kept.length} old entries (${JOURNAL_MAX_LINES} kept)`);
1082
+ } else if (existing.length > 0) {
1083
+ const last = existing[existing.length - 1];
1084
+ log(`journal: restored context — ${existing.length} entries on disk, last from run ${last.run} (#${last.seq} ${last.command})`);
1085
+ }
1086
+ return new Journal(file, (/* @__PURE__ */ new Date()).toISOString().slice(0, 23).replace("T", " "), log);
1087
+ }
1088
+ /**
1089
+ * 追加一条(异步、排队写;失败只告警一次,绝不阻塞命令路径)。
1090
+ * @param entry - AuditLog 刚记下的条目(参数已脱敏)。
1091
+ */
1092
+ append(entry) {
1093
+ const line = `${JSON.stringify({
1094
+ run: this.runId,
1095
+ ...entry
1096
+ })}\n`;
1097
+ this.chain = this.chain.then(() => appendFile(this.file, line, "utf8")).catch((error) => {
1098
+ if (!this.warned) {
1099
+ this.warned = true;
1100
+ this.log(`journal: write failed — ${error instanceof Error ? error.message : String(error)}`);
1101
+ }
1102
+ });
1103
+ }
1104
+ /** 等在途写入落盘(teardown / 测试用)。 */
1105
+ async close() {
1106
+ await this.chain.catch(() => {});
1107
+ }
1108
+ };
1109
+ /**
1110
+ * 读 journal 的尾部(不要求 Journal 实例——读在线程外也能工作,扩展离线也行)。
1111
+ * @param stateDir - journal 所在目录。
1112
+ * @param filter - tabId / 命令子串 / 条数(默认 50,最大 500;取最新)。
1113
+ */
1114
+ async function readJournal(stateDir, filter = {}) {
1115
+ let text;
1116
+ try {
1117
+ text = await readFile(path.join(stateDir, "journal.jsonl"), "utf8");
1118
+ } catch {
1119
+ return {
1120
+ total: 0,
1121
+ entries: []
1122
+ };
1123
+ }
1124
+ if (text.length > JOURNAL_MAX_BYTES) text = text.slice(-8388608);
1125
+ let matched = parseLines(text);
1126
+ if (filter.tabId !== void 0) matched = matched.filter((e) => e.tabId === filter.tabId);
1127
+ if (filter.command !== void 0 && filter.command.length > 0) {
1128
+ const needle = filter.command.toLowerCase();
1129
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
1130
+ }
1131
+ const limit = Math.min(500, Math.max(1, Math.trunc(filter.limit ?? 50)));
1132
+ return {
1133
+ total: matched.length,
1134
+ entries: matched.slice(Math.max(0, matched.length - limit))
1135
+ };
1136
+ }
929
1137
  //#endregion
930
1138
  //#region lib/types/index.js
931
1139
  /**
@@ -979,6 +1187,7 @@ const Config = z.object({
979
1187
  port: z.number().step(1).min(1024).max(65535).default(9777),
980
1188
  token: z.string().default("dsh-local"),
981
1189
  shotsDir: z.string().default("dsh-browser-shots"),
1190
+ stateDir: z.string().default(path.join(homedir(), ".dsh-browser-control")),
982
1191
  readOnly: z.boolean().default(false),
983
1192
  allowScript: z.boolean().default(true)
984
1193
  });
@@ -1054,6 +1263,10 @@ var BridgeController = class {
1054
1263
  lastError;
1055
1264
  chain = Promise.resolve();
1056
1265
  current;
1266
+ journal;
1267
+ journalKey = "";
1268
+ /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1269
+ audit = new AuditLog();
1057
1270
  constructor(log) {
1058
1271
  this.log = log;
1059
1272
  }
@@ -1061,6 +1274,17 @@ var BridgeController = class {
1061
1274
  get shotsDir() {
1062
1275
  return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
1063
1276
  }
1277
+ /** 审计账本落盘目录;配置到达之前是 undefined。 */
1278
+ get stateDir() {
1279
+ return this.current === void 0 ? void 0 : path.resolve(this.current.stateDir);
1280
+ }
1281
+ /**
1282
+ * Record one audit entry in memory and, when the journal is open, on disk.
1283
+ * Journal appends are queued and never block the command path.
1284
+ */
1285
+ recordAudit(input) {
1286
+ this.journal?.append(this.audit.record(input));
1287
+ }
1064
1288
  /**
1065
1289
  * Current policy flags. Tools that compose several commands (browser_drag
1066
1290
  * runs `cdp`/`eval`) read this to refuse with a tool-level message; the
@@ -1098,6 +1322,15 @@ var BridgeController = class {
1098
1322
  */
1099
1323
  requireExtensionCommand(command, since) {
1100
1324
  if (this.hasExtensionCommand(command)) return;
1325
+ this.recordAudit({
1326
+ command,
1327
+ params: "",
1328
+ ok: false,
1329
+ code: "extension_outdated",
1330
+ effectState: "not_started",
1331
+ error: `connected extension v${this.extensionVersion() ?? "?"} lacks ${command} (added in ${since})`,
1332
+ durationMs: 0
1333
+ });
1101
1334
  throw extensionOutdatedError(this.extensionVersion(), command, since);
1102
1335
  }
1103
1336
  /**
@@ -1119,6 +1352,18 @@ var BridgeController = class {
1119
1352
  }
1120
1353
  async reconcileNow(config) {
1121
1354
  const shotsDir = path.resolve(config.shotsDir);
1355
+ const stateDir = path.resolve(config.stateDir);
1356
+ if (stateDir !== this.journalKey) {
1357
+ const previousJournal = this.journal;
1358
+ this.journal = void 0;
1359
+ this.journalKey = stateDir;
1360
+ await previousJournal?.close();
1361
+ try {
1362
+ this.journal = await Journal.open(stateDir, this.log);
1363
+ } catch (error) {
1364
+ this.log(`journal: disabled — ${errorMessage(error)}`);
1365
+ }
1366
+ }
1122
1367
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
1123
1368
  if (key === this.serverKey) return;
1124
1369
  const previous = this.server;
@@ -1159,13 +1404,40 @@ var BridgeController = class {
1159
1404
  * @returns the extension's result payload verbatim.
1160
1405
  */
1161
1406
  async execute(command, params, signal, timeoutMs) {
1162
- this.policyGate(command, params);
1163
- const server = this.server;
1164
- if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1165
- return server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1166
- signal,
1167
- timeoutMs
1168
- });
1407
+ const started = Date.now();
1408
+ const summary = summarizeAuditParams(command, params);
1409
+ const tabId = typeof params.tabId === "number" ? params.tabId : void 0;
1410
+ try {
1411
+ this.policyGate(command, params);
1412
+ const server = this.server;
1413
+ if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1414
+ const result = await server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1415
+ signal,
1416
+ timeoutMs
1417
+ });
1418
+ this.recordAudit({
1419
+ command,
1420
+ tabId,
1421
+ params: summary,
1422
+ ok: true,
1423
+ durationMs: Date.now() - started
1424
+ });
1425
+ return result;
1426
+ } catch (error) {
1427
+ const code = error.code;
1428
+ const effectState = error.effectState;
1429
+ this.recordAudit({
1430
+ command,
1431
+ tabId,
1432
+ params: summary,
1433
+ ok: false,
1434
+ code: typeof code === "string" ? code : void 0,
1435
+ effectState: typeof effectState === "string" ? effectState : void 0,
1436
+ error: errorMessage(error),
1437
+ durationMs: Date.now() - started
1438
+ });
1439
+ throw error;
1440
+ }
1169
1441
  }
1170
1442
  /**
1171
1443
  * Refuse a command the local policy forbids, before anything is sent or deleted.
@@ -1190,21 +1462,48 @@ var BridgeController = class {
1190
1462
  * @returns counts and names of what was removed.
1191
1463
  */
1192
1464
  async cleanup() {
1193
- this.policyGate("cleanup");
1194
- const dir = this.shotsDir;
1195
- if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1465
+ const started = Date.now();
1196
1466
  try {
1197
- return await cleanupArtifacts({ shotsDir: dir });
1467
+ this.policyGate("cleanup");
1468
+ const dir = this.shotsDir;
1469
+ if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1470
+ try {
1471
+ const result = await cleanupArtifacts({ shotsDir: dir });
1472
+ this.recordAudit({
1473
+ command: "cleanup",
1474
+ params: "{}",
1475
+ ok: true,
1476
+ durationMs: Date.now() - started
1477
+ });
1478
+ return result;
1479
+ } catch (error) {
1480
+ throw classify("cleanup", error);
1481
+ }
1198
1482
  } catch (error) {
1199
- throw classify("cleanup", error);
1483
+ const code = error.code;
1484
+ const effectState = error.effectState;
1485
+ this.recordAudit({
1486
+ command: "cleanup",
1487
+ params: "{}",
1488
+ ok: false,
1489
+ code: typeof code === "string" ? code : void 0,
1490
+ effectState: typeof effectState === "string" ? effectState : void 0,
1491
+ error: errorMessage(error),
1492
+ durationMs: Date.now() - started
1493
+ });
1494
+ throw error;
1200
1495
  }
1201
1496
  }
1202
1497
  /** Stop the listener; safe to call repeatedly and during teardown. */
1203
- stop() {
1498
+ async stop() {
1204
1499
  const previous = this.server;
1205
1500
  this.server = void 0;
1206
1501
  this.serverKey = "";
1207
- return previous?.stop() ?? Promise.resolve();
1502
+ await (previous?.stop() ?? Promise.resolve());
1503
+ const journal = this.journal;
1504
+ this.journal = void 0;
1505
+ this.journalKey = "";
1506
+ await journal?.close();
1208
1507
  }
1209
1508
  };
1210
1509
  function errorMessage(error) {
@@ -3147,6 +3446,8 @@ function applyBrowserTools(ctx, controller) {
3147
3446
  }
3148
3447
  }));
3149
3448
  registerDragTool(ctx, controller);
3449
+ registerAuditTool(ctx, controller);
3450
+ registerJournalTool(ctx, controller);
3150
3451
  }
3151
3452
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
3152
3453
  async function cdpCall(ctrl, tabId, method, params, signal) {
@@ -3475,6 +3776,243 @@ function registerDragTool(ctx, controller) {
3475
3776
  }
3476
3777
  }));
3477
3778
  }
3779
+ function registerAuditTool(ctx, controller) {
3780
+ ctx.tools.register(defineTool({
3781
+ name: "browser_audit",
3782
+ description: "Read this plugin session's in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; eval expressions are truncated. Plugin-local read — works in read-only mode.",
3783
+ parameters: {
3784
+ command: {
3785
+ type: "string",
3786
+ description: "Only entries whose command contains this substring (e.g. \"nav\", \"cookies\", \"tabs\")."
3787
+ },
3788
+ tabId: {
3789
+ type: "number",
3790
+ description: "Only entries that carried this tabId."
3791
+ },
3792
+ sinceSeq: {
3793
+ type: "number",
3794
+ description: "Only entries with seq greater than this (paging)."
3795
+ },
3796
+ limit: {
3797
+ type: "number",
3798
+ description: `Max entries to return (newest last), 1–200; default 50.`
3799
+ }
3800
+ },
3801
+ output: {
3802
+ schema: {
3803
+ type: "object",
3804
+ additionalProperties: false,
3805
+ properties: {
3806
+ total: {
3807
+ type: "number",
3808
+ required: true
3809
+ },
3810
+ shown: {
3811
+ type: "number",
3812
+ required: true
3813
+ },
3814
+ entries: {
3815
+ type: "array",
3816
+ required: true,
3817
+ items: {
3818
+ type: "object",
3819
+ additionalProperties: false,
3820
+ properties: {
3821
+ seq: {
3822
+ type: "number",
3823
+ required: true
3824
+ },
3825
+ t: {
3826
+ type: "number",
3827
+ required: true
3828
+ },
3829
+ command: {
3830
+ type: "string",
3831
+ required: true
3832
+ },
3833
+ tabId: { type: "number" },
3834
+ params: {
3835
+ type: "string",
3836
+ required: true
3837
+ },
3838
+ ok: {
3839
+ type: "boolean",
3840
+ required: true
3841
+ },
3842
+ code: { type: "string" },
3843
+ effectState: { type: "string" },
3844
+ error: { type: "string" },
3845
+ durationMs: {
3846
+ type: "number",
3847
+ required: true
3848
+ }
3849
+ }
3850
+ }
3851
+ }
3852
+ }
3853
+ },
3854
+ render: (_args, value) => {
3855
+ const v = value;
3856
+ if (v.entries.length === 0) return [{
3857
+ type: "text",
3858
+ text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.`
3859
+ }];
3860
+ const lines = v.entries.map((e) => {
3861
+ const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
3862
+ const outcome = e.ok ? "ok" : `FAIL ${e.code ?? "error"}${e.effectState ? `/${e.effectState}` : ""}`;
3863
+ const err = e.ok || e.error === void 0 ? "" : ` — ${e.error}`;
3864
+ return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
3865
+ });
3866
+ return [{
3867
+ type: "text",
3868
+ text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
3869
+ }];
3870
+ }
3871
+ },
3872
+ presentCall: () => ({
3873
+ card: "generic",
3874
+ title: "Browser audit",
3875
+ kind: "other"
3876
+ }),
3877
+ async execute(args) {
3878
+ const entries = controller.audit.list({
3879
+ tabId: args.tabId,
3880
+ command: args.command,
3881
+ sinceSeq: args.sinceSeq,
3882
+ limit: args.limit
3883
+ });
3884
+ return {
3885
+ total: controller.audit.size(),
3886
+ shown: entries.length,
3887
+ entries
3888
+ };
3889
+ }
3890
+ }));
3891
+ }
3892
+ function registerJournalTool(ctx, controller) {
3893
+ ctx.tools.register(defineTool({
3894
+ name: "browser_journal",
3895
+ description: "Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover \"what was I doing\" after a restart.",
3896
+ parameters: {
3897
+ command: {
3898
+ type: "string",
3899
+ description: "Only entries whose command contains this substring."
3900
+ },
3901
+ tabId: {
3902
+ type: "number",
3903
+ description: "Only entries that carried this tabId."
3904
+ },
3905
+ limit: {
3906
+ type: "number",
3907
+ description: "Max entries to return (newest last), 1–500; default 50."
3908
+ }
3909
+ },
3910
+ output: {
3911
+ schema: {
3912
+ type: "object",
3913
+ additionalProperties: false,
3914
+ properties: {
3915
+ total: {
3916
+ type: "number",
3917
+ required: true
3918
+ },
3919
+ shown: {
3920
+ type: "number",
3921
+ required: true
3922
+ },
3923
+ runs: {
3924
+ type: "number",
3925
+ required: true
3926
+ },
3927
+ entries: {
3928
+ type: "array",
3929
+ required: true,
3930
+ items: {
3931
+ type: "object",
3932
+ additionalProperties: false,
3933
+ properties: {
3934
+ run: {
3935
+ type: "string",
3936
+ required: true
3937
+ },
3938
+ seq: {
3939
+ type: "number",
3940
+ required: true
3941
+ },
3942
+ t: {
3943
+ type: "number",
3944
+ required: true
3945
+ },
3946
+ command: {
3947
+ type: "string",
3948
+ required: true
3949
+ },
3950
+ tabId: { type: "number" },
3951
+ params: {
3952
+ type: "string",
3953
+ required: true
3954
+ },
3955
+ ok: {
3956
+ type: "boolean",
3957
+ required: true
3958
+ },
3959
+ code: { type: "string" },
3960
+ effectState: { type: "string" },
3961
+ error: { type: "string" },
3962
+ durationMs: {
3963
+ type: "number",
3964
+ required: true
3965
+ }
3966
+ }
3967
+ }
3968
+ }
3969
+ }
3970
+ },
3971
+ render: (_args, value) => {
3972
+ const v = value;
3973
+ if (v.entries.length === 0) return [{
3974
+ type: "text",
3975
+ text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.`
3976
+ }];
3977
+ const lines = [];
3978
+ let run = "";
3979
+ for (const e of v.entries) {
3980
+ if (e.run !== run) {
3981
+ run = e.run;
3982
+ lines.push(`── run ${run}`);
3983
+ }
3984
+ const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
3985
+ const outcome = e.ok ? "ok" : `FAIL ${e.code ?? "error"}`;
3986
+ lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
3987
+ }
3988
+ return [{
3989
+ type: "text",
3990
+ text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}`)
3991
+ }];
3992
+ }
3993
+ },
3994
+ presentCall: () => ({
3995
+ card: "generic",
3996
+ title: "Browser journal",
3997
+ kind: "other"
3998
+ }),
3999
+ async execute(args) {
4000
+ const dir = controller.stateDir;
4001
+ if (dir === void 0) throw new Error("browser-bridge is not configured yet — the journal has no stateDir to read");
4002
+ const { total, entries } = await readJournal(dir, {
4003
+ command: args.command,
4004
+ tabId: args.tabId,
4005
+ limit: args.limit
4006
+ });
4007
+ return {
4008
+ total,
4009
+ shown: entries.length,
4010
+ runs: new Set(entries.map((e) => e.run)).size,
4011
+ entries
4012
+ };
4013
+ }
4014
+ }));
4015
+ }
3478
4016
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
3479
4017
  function apply(ctx, config) {
3480
4018
  const resolved = config;