@angelky7/dsh-browser-control 1.7.3 → 1.7.4

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,11 @@ 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+) |
271
272
  | `browser_cleanup` | Clean up temp files |
272
273
  | `browser_info` | Which browser and extension build is attached (run it first when debugging, v1.0.9+) |
273
274
 
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`.
275
+ 27 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
276
 
276
277
  ## Architecture
277
278
 
package/README.md CHANGED
@@ -261,11 +261,12 @@ 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+) |
264
265
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
265
266
  | `browser_cleanup` | 清理临时文件 |
266
267
  | `browser_info` | 报告实际连上的浏览器与扩展版本(排障先跑它,v1.0.9+) |
267
268
 
268
- 共 26 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
269
+ 共 27 个工具。`browser_wait` / `browser_find` / `browser_dialog` / `browser_info` 在 v1.0.8 及以前是「扩展里已实现、但没注册成工具」,agent 调不到;`browser_network_clear` 则是代码里有、这张表漏了。这些现在由 `verify/verify_surface.mjs` 做三方对账(扩展命令表 ↔ 插件调用 ↔ 文档表 ↔ 构建产物),漂移会直接让 `npm run verify` 失败。
269
270
 
270
271
  ## 架构
271
272
 
package/lib/index.js CHANGED
@@ -926,6 +926,92 @@ function policyRefusal(command, flags, params) {
926
926
  if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
927
927
  if (flags.readOnly && !READ_ONLY_ALLOWED.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会改变页面或浏览器状态,已被拒绝,没有向浏览器发出任何操作。只读模式下可用的工具是读页面与读日志那几类;要放开写操作,${CONFIG_HINT}不要原地重试本命令。`;
928
928
  }
929
+ /** 单条参数摘要上限:审计要的是形状,不是全文。 */
930
+ const AUDIT_PARAMS_MAX_CHARS = 300;
931
+ /** 失败描述上限。 */
932
+ const AUDIT_ERROR_MAX_CHARS = 200;
933
+ /** eval 表达式只留前 120 字符:里面可能有凭据。 */
934
+ const AUDIT_EXPRESSION_MAX_CHARS = 120;
935
+ /** 参数里这些键的值一律打码(大小写不敏感)。 */
936
+ const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
937
+ /**
938
+ * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
939
+ * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
940
+ */
941
+ const VALUE_REDACTED_COMMANDS = /* @__PURE__ */ new Set(["input", "cookies.set"]);
942
+ function redactValue(value) {
943
+ return `[redacted:${typeof value === "string" ? [...value].length : 0} chars]`;
944
+ }
945
+ /**
946
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
947
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
948
+ */
949
+ function summarizeAuditParams(command, params) {
950
+ let text;
951
+ try {
952
+ text = JSON.stringify(params, (key, value) => {
953
+ if (SECRET_KEY.test(key)) return "[redacted]";
954
+ if (key === "value" && VALUE_REDACTED_COMMANDS.has(command)) return redactValue(value);
955
+ 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]`;
956
+ return value;
957
+ }) ?? "{}";
958
+ } catch {
959
+ text = "[unserializable params]";
960
+ }
961
+ return [...text].length > AUDIT_PARAMS_MAX_CHARS ? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join("")}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]` : text;
962
+ }
963
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
964
+ var AuditLog = class {
965
+ entries = [];
966
+ nextSeq = 1;
967
+ now;
968
+ constructor(now = Date.now) {
969
+ this.now = now;
970
+ }
971
+ /**
972
+ * 记一笔。
973
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
974
+ */
975
+ record(input) {
976
+ const entry = {
977
+ seq: this.nextSeq++,
978
+ t: this.now(),
979
+ command: input.command,
980
+ params: input.params,
981
+ ok: input.ok,
982
+ durationMs: Math.max(0, Math.round(input.durationMs))
983
+ };
984
+ if (input.tabId !== void 0) entry.tabId = input.tabId;
985
+ if (input.code !== void 0) entry.code = input.code;
986
+ if (input.effectState !== void 0) entry.effectState = input.effectState;
987
+ 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;
988
+ this.entries.push(entry);
989
+ if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
990
+ return entry;
991
+ }
992
+ /** 现有条目数(被挤掉的不算)。 */
993
+ size() {
994
+ return this.entries.length;
995
+ }
996
+ /**
997
+ * 过滤并取最新的一批。
998
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
999
+ */
1000
+ list(filter = {}) {
1001
+ let matched = this.entries;
1002
+ if (filter.tabId !== void 0) matched = matched.filter((e) => e.tabId === filter.tabId);
1003
+ if (filter.command !== void 0 && filter.command.length > 0) {
1004
+ const needle = filter.command.toLowerCase();
1005
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
1006
+ }
1007
+ if (filter.sinceSeq !== void 0) {
1008
+ const floor = filter.sinceSeq;
1009
+ matched = matched.filter((e) => e.seq > floor);
1010
+ }
1011
+ const limit = Math.min(200, Math.max(1, Math.trunc(filter.limit ?? 50)));
1012
+ return matched.slice(Math.max(0, matched.length - limit));
1013
+ }
1014
+ };
929
1015
  //#endregion
930
1016
  //#region lib/types/index.js
931
1017
  /**
@@ -1054,6 +1140,8 @@ var BridgeController = class {
1054
1140
  lastError;
1055
1141
  chain = Promise.resolve();
1056
1142
  current;
1143
+ /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1144
+ audit = new AuditLog();
1057
1145
  constructor(log) {
1058
1146
  this.log = log;
1059
1147
  }
@@ -1098,6 +1186,15 @@ var BridgeController = class {
1098
1186
  */
1099
1187
  requireExtensionCommand(command, since) {
1100
1188
  if (this.hasExtensionCommand(command)) return;
1189
+ this.audit.record({
1190
+ command,
1191
+ params: "",
1192
+ ok: false,
1193
+ code: "extension_outdated",
1194
+ effectState: "not_started",
1195
+ error: `connected extension v${this.extensionVersion() ?? "?"} lacks ${command} (added in ${since})`,
1196
+ durationMs: 0
1197
+ });
1101
1198
  throw extensionOutdatedError(this.extensionVersion(), command, since);
1102
1199
  }
1103
1200
  /**
@@ -1159,13 +1256,40 @@ var BridgeController = class {
1159
1256
  * @returns the extension's result payload verbatim.
1160
1257
  */
1161
1258
  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
- });
1259
+ const started = Date.now();
1260
+ const summary = summarizeAuditParams(command, params);
1261
+ const tabId = typeof params.tabId === "number" ? params.tabId : void 0;
1262
+ try {
1263
+ this.policyGate(command, params);
1264
+ const server = this.server;
1265
+ 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 及更早的宿主没有这张卡片,只能改配置文件)"));
1266
+ const result = await server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1267
+ signal,
1268
+ timeoutMs
1269
+ });
1270
+ this.audit.record({
1271
+ command,
1272
+ tabId,
1273
+ params: summary,
1274
+ ok: true,
1275
+ durationMs: Date.now() - started
1276
+ });
1277
+ return result;
1278
+ } catch (error) {
1279
+ const code = error.code;
1280
+ const effectState = error.effectState;
1281
+ this.audit.record({
1282
+ command,
1283
+ tabId,
1284
+ params: summary,
1285
+ ok: false,
1286
+ code: typeof code === "string" ? code : void 0,
1287
+ effectState: typeof effectState === "string" ? effectState : void 0,
1288
+ error: errorMessage(error),
1289
+ durationMs: Date.now() - started
1290
+ });
1291
+ throw error;
1292
+ }
1169
1293
  }
1170
1294
  /**
1171
1295
  * Refuse a command the local policy forbids, before anything is sent or deleted.
@@ -1190,13 +1314,36 @@ var BridgeController = class {
1190
1314
  * @returns counts and names of what was removed.
1191
1315
  */
1192
1316
  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 —— 浏览器控制尚未加载配置,无法确定清理目录"));
1317
+ const started = Date.now();
1196
1318
  try {
1197
- return await cleanupArtifacts({ shotsDir: dir });
1319
+ this.policyGate("cleanup");
1320
+ const dir = this.shotsDir;
1321
+ if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1322
+ try {
1323
+ const result = await cleanupArtifacts({ shotsDir: dir });
1324
+ this.audit.record({
1325
+ command: "cleanup",
1326
+ params: "{}",
1327
+ ok: true,
1328
+ durationMs: Date.now() - started
1329
+ });
1330
+ return result;
1331
+ } catch (error) {
1332
+ throw classify("cleanup", error);
1333
+ }
1198
1334
  } catch (error) {
1199
- throw classify("cleanup", error);
1335
+ const code = error.code;
1336
+ const effectState = error.effectState;
1337
+ this.audit.record({
1338
+ command: "cleanup",
1339
+ params: "{}",
1340
+ ok: false,
1341
+ code: typeof code === "string" ? code : void 0,
1342
+ effectState: typeof effectState === "string" ? effectState : void 0,
1343
+ error: errorMessage(error),
1344
+ durationMs: Date.now() - started
1345
+ });
1346
+ throw error;
1200
1347
  }
1201
1348
  }
1202
1349
  /** Stop the listener; safe to call repeatedly and during teardown. */
@@ -3147,6 +3294,7 @@ function applyBrowserTools(ctx, controller) {
3147
3294
  }
3148
3295
  }));
3149
3296
  registerDragTool(ctx, controller);
3297
+ registerAuditTool(ctx, controller);
3150
3298
  }
3151
3299
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
3152
3300
  async function cdpCall(ctrl, tabId, method, params, signal) {
@@ -3475,6 +3623,119 @@ function registerDragTool(ctx, controller) {
3475
3623
  }
3476
3624
  }));
3477
3625
  }
3626
+ function registerAuditTool(ctx, controller) {
3627
+ ctx.tools.register(defineTool({
3628
+ name: "browser_audit",
3629
+ 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.",
3630
+ parameters: {
3631
+ command: {
3632
+ type: "string",
3633
+ description: "Only entries whose command contains this substring (e.g. \"nav\", \"cookies\", \"tabs\")."
3634
+ },
3635
+ tabId: {
3636
+ type: "number",
3637
+ description: "Only entries that carried this tabId."
3638
+ },
3639
+ sinceSeq: {
3640
+ type: "number",
3641
+ description: "Only entries with seq greater than this (paging)."
3642
+ },
3643
+ limit: {
3644
+ type: "number",
3645
+ description: `Max entries to return (newest last), 1–200; default 50.`
3646
+ }
3647
+ },
3648
+ output: {
3649
+ schema: {
3650
+ type: "object",
3651
+ additionalProperties: false,
3652
+ properties: {
3653
+ total: {
3654
+ type: "number",
3655
+ required: true
3656
+ },
3657
+ shown: {
3658
+ type: "number",
3659
+ required: true
3660
+ },
3661
+ entries: {
3662
+ type: "array",
3663
+ required: true,
3664
+ items: {
3665
+ type: "object",
3666
+ additionalProperties: false,
3667
+ properties: {
3668
+ seq: {
3669
+ type: "number",
3670
+ required: true
3671
+ },
3672
+ t: {
3673
+ type: "number",
3674
+ required: true
3675
+ },
3676
+ command: {
3677
+ type: "string",
3678
+ required: true
3679
+ },
3680
+ tabId: { type: "number" },
3681
+ params: {
3682
+ type: "string",
3683
+ required: true
3684
+ },
3685
+ ok: {
3686
+ type: "boolean",
3687
+ required: true
3688
+ },
3689
+ code: { type: "string" },
3690
+ effectState: { type: "string" },
3691
+ error: { type: "string" },
3692
+ durationMs: {
3693
+ type: "number",
3694
+ required: true
3695
+ }
3696
+ }
3697
+ }
3698
+ }
3699
+ }
3700
+ },
3701
+ render: (_args, value) => {
3702
+ const v = value;
3703
+ if (v.entries.length === 0) return [{
3704
+ type: "text",
3705
+ text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.`
3706
+ }];
3707
+ const lines = v.entries.map((e) => {
3708
+ const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
3709
+ const outcome = e.ok ? "ok" : `FAIL ${e.code ?? "error"}${e.effectState ? `/${e.effectState}` : ""}`;
3710
+ const err = e.ok || e.error === void 0 ? "" : ` — ${e.error}`;
3711
+ return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
3712
+ });
3713
+ return [{
3714
+ type: "text",
3715
+ text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
3716
+ }];
3717
+ }
3718
+ },
3719
+ presentCall: () => ({
3720
+ card: "generic",
3721
+ title: "Browser audit",
3722
+ kind: "other"
3723
+ }),
3724
+ async execute(args) {
3725
+ const entries = controller.audit.list({
3726
+ tabId: args.tabId,
3727
+ command: args.command,
3728
+ sinceSeq: args.sinceSeq,
3729
+ limit: args.limit
3730
+ });
3731
+ return {
3732
+ total: controller.audit.size(),
3733
+ shown: entries.length,
3734
+ entries
3735
+ };
3736
+ }
3737
+ }));
3738
+ }
3478
3739
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
3479
3740
  function apply(ctx, config) {
3480
3741
  const resolved = config;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 操作审计(v1.7.4):插件这一侧到底发过哪些命令、被拒了几次、各自用了多久。
3
+ *
4
+ * 存在的理由不是「日志」,而是回答两个现实问题:出事后「刚才谁动了这个页面」,
5
+ * 以及做「会话恢复」之前先得有一份可靠的行为底账。所以:
6
+ *
7
+ * - **记录在命令层**(controller.execute),不是工具层:模型看不到的内部重试、
8
+ * browser_drag 一次拖拽打出的 N 次 cdp 往返,都在账上,不美化。
9
+ * - **只存内存**(环形缓冲,200 条):跨 dsh 重启的持久化是「会话恢复」那一版的事;
10
+ * 这一版先把账记对,别把磁盘格式写死。
11
+ * - **参数摘要是脱敏后的**:输入框的值、cookie 的值、令牌类字段一律打码;
12
+ * 表达式截断。审计的用途是「做了什么」,不是「内容是什么」。
13
+ */
14
+ export interface AuditEntry {
15
+ seq: number;
16
+ /** epoch 毫秒 */
17
+ t: number;
18
+ command: string;
19
+ tabId?: number;
20
+ /** 脱敏后的参数摘要(JSON 文本,已截断) */
21
+ params: string;
22
+ ok: boolean;
23
+ code?: string;
24
+ effectState?: string;
25
+ /** 失败描述,截断 */
26
+ error?: string;
27
+ durationMs: number;
28
+ }
29
+ export interface AuditFilter {
30
+ tabId?: number;
31
+ /** 命令名子串匹配(如 "nav" 命中 nav / browser.info? 不需要——命令名如 tabs.open) */
32
+ command?: string;
33
+ /** 只返回 seq 严格大于它的条目(分页) */
34
+ sinceSeq?: number;
35
+ /** 最多返回多少条(取最新的 N 条,按 seq 升序给出) */
36
+ limit?: number;
37
+ }
38
+ /** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
39
+ export declare const AUDIT_MAX_ENTRIES = 200;
40
+ /**
41
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
42
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
43
+ */
44
+ export declare function summarizeAuditParams(command: string, params: Record<string, unknown>): string;
45
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
46
+ export declare class AuditLog {
47
+ private readonly entries;
48
+ private nextSeq;
49
+ private readonly now;
50
+ constructor(now?: () => number);
51
+ /**
52
+ * 记一笔。
53
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
54
+ */
55
+ record(input: {
56
+ command: string;
57
+ tabId?: number;
58
+ params: string;
59
+ ok: boolean;
60
+ code?: string;
61
+ effectState?: string;
62
+ error?: string;
63
+ durationMs: number;
64
+ }): AuditEntry;
65
+ /** 现有条目数(被挤掉的不算)。 */
66
+ size(): number;
67
+ /**
68
+ * 过滤并取最新的一批。
69
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
70
+ */
71
+ list(filter?: AuditFilter): AuditEntry[];
72
+ }
@@ -0,0 +1,107 @@
1
+ /** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
2
+ export const AUDIT_MAX_ENTRIES = 200;
3
+ /** 单条参数摘要上限:审计要的是形状,不是全文。 */
4
+ const AUDIT_PARAMS_MAX_CHARS = 300;
5
+ /** 失败描述上限。 */
6
+ const AUDIT_ERROR_MAX_CHARS = 200;
7
+ /** eval 表达式只留前 120 字符:里面可能有凭据。 */
8
+ const AUDIT_EXPRESSION_MAX_CHARS = 120;
9
+ /** 参数里这些键的值一律打码(大小写不敏感)。 */
10
+ const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
11
+ /**
12
+ * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
13
+ * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
14
+ */
15
+ const VALUE_REDACTED_COMMANDS = new Set(['input', 'cookies.set']);
16
+ function redactValue(value) {
17
+ const length = typeof value === 'string' ? [...value].length : 0;
18
+ return `[redacted:${length} chars]`;
19
+ }
20
+ /**
21
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
22
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
23
+ */
24
+ export function summarizeAuditParams(command, params) {
25
+ let text;
26
+ try {
27
+ text = JSON.stringify(params, (key, value) => {
28
+ if (SECRET_KEY.test(key))
29
+ return '[redacted]';
30
+ if (key === 'value' && VALUE_REDACTED_COMMANDS.has(command))
31
+ return redactValue(value);
32
+ if (key === 'expression' && typeof value === 'string' && [...value].length > AUDIT_EXPRESSION_MAX_CHARS) {
33
+ return `${[...value].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join('')}…[+${[...value].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
34
+ }
35
+ return value;
36
+ }) ?? '{}';
37
+ }
38
+ catch {
39
+ text = '[unserializable params]';
40
+ }
41
+ return [...text].length > AUDIT_PARAMS_MAX_CHARS
42
+ ? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join('')}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]`
43
+ : text;
44
+ }
45
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
46
+ export class AuditLog {
47
+ entries = [];
48
+ nextSeq = 1;
49
+ now;
50
+ constructor(now = Date.now) {
51
+ this.now = now;
52
+ }
53
+ /**
54
+ * 记一笔。
55
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
56
+ */
57
+ record(input) {
58
+ const entry = {
59
+ seq: this.nextSeq++,
60
+ t: this.now(),
61
+ command: input.command,
62
+ params: input.params,
63
+ ok: input.ok,
64
+ durationMs: Math.max(0, Math.round(input.durationMs)),
65
+ };
66
+ if (input.tabId !== undefined)
67
+ entry.tabId = input.tabId;
68
+ if (input.code !== undefined)
69
+ entry.code = input.code;
70
+ if (input.effectState !== undefined)
71
+ entry.effectState = input.effectState;
72
+ if (input.error !== undefined) {
73
+ entry.error = [...input.error].length > AUDIT_ERROR_MAX_CHARS
74
+ ? `${[...input.error].slice(0, AUDIT_ERROR_MAX_CHARS).join('')}…`
75
+ : input.error;
76
+ }
77
+ this.entries.push(entry);
78
+ // 环形缓冲:超出上限从最旧的一端丢。seq 不回收——调用方拿 seq 分页时
79
+ // 「中间缺了号」本身就是「被挤掉了」的机器可读信号。
80
+ if (this.entries.length > AUDIT_MAX_ENTRIES)
81
+ this.entries.splice(0, this.entries.length - AUDIT_MAX_ENTRIES);
82
+ return entry;
83
+ }
84
+ /** 现有条目数(被挤掉的不算)。 */
85
+ size() {
86
+ return this.entries.length;
87
+ }
88
+ /**
89
+ * 过滤并取最新的一批。
90
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
91
+ */
92
+ list(filter = {}) {
93
+ let matched = this.entries;
94
+ if (filter.tabId !== undefined)
95
+ matched = matched.filter((e) => e.tabId === filter.tabId);
96
+ if (filter.command !== undefined && filter.command.length > 0) {
97
+ const needle = filter.command.toLowerCase();
98
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
99
+ }
100
+ if (filter.sinceSeq !== undefined) {
101
+ const floor = filter.sinceSeq;
102
+ matched = matched.filter((e) => e.seq > floor);
103
+ }
104
+ const limit = Math.min(AUDIT_MAX_ENTRIES, Math.max(1, Math.trunc(filter.limit ?? 50)));
105
+ return matched.slice(Math.max(0, matched.length - limit));
106
+ }
107
+ }
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
24
24
  import { BridgeServer, cleanupArtifacts } from "./server.js";
25
25
  import { BridgeError, classify } from "./errors.js";
26
26
  import { policyRefusal } from "./policy.js";
27
+ import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
27
28
  /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
28
29
  const NAV_MAX_MS = 120_000;
29
30
  /**
@@ -116,6 +117,8 @@ class BridgeController {
116
117
  lastError;
117
118
  chain = Promise.resolve();
118
119
  current;
120
+ /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
121
+ audit = new AuditLog();
119
122
  constructor(log) {
120
123
  this.log = log;
121
124
  }
@@ -161,6 +164,16 @@ class BridgeController {
161
164
  requireExtensionCommand(command, since) {
162
165
  if (this.hasExtensionCommand(command))
163
166
  return;
167
+ // 预检拒绝也要上账:这是「升级插件后模型发现功能不可用」的最常见现场。
168
+ this.audit.record({
169
+ command,
170
+ params: '',
171
+ ok: false,
172
+ code: 'extension_outdated',
173
+ effectState: 'not_started',
174
+ error: `connected extension v${this.extensionVersion() ?? '?'} lacks ${command} (added in ${since})`,
175
+ durationMs: 0,
176
+ });
164
177
  throw extensionOutdatedError(this.extensionVersion(), command, since);
165
178
  }
166
179
  /**
@@ -221,12 +234,34 @@ class BridgeController {
221
234
  * @returns the extension's result payload verbatim.
222
235
  */
223
236
  async execute(command, params, signal, timeoutMs) {
224
- this.policyGate(command, params);
225
- const server = this.server;
226
- if (server === undefined) {
227
- throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
237
+ const started = Date.now();
238
+ const summary = summarizeAuditParams(command, params);
239
+ const tabId = typeof params.tabId === 'number' ? params.tabId : undefined;
240
+ try {
241
+ this.policyGate(command, params);
242
+ const server = this.server;
243
+ if (server === undefined) {
244
+ throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
245
+ }
246
+ const result = await server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
247
+ this.audit.record({ command, tabId, params: summary, ok: true, durationMs: Date.now() - started });
248
+ return result;
249
+ }
250
+ catch (error) {
251
+ const code = error.code;
252
+ const effectState = error.effectState;
253
+ this.audit.record({
254
+ command,
255
+ tabId,
256
+ params: summary,
257
+ ok: false,
258
+ code: typeof code === 'string' ? code : undefined,
259
+ effectState: typeof effectState === 'string' ? effectState : undefined,
260
+ error: errorMessage(error),
261
+ durationMs: Date.now() - started,
262
+ });
263
+ throw error;
228
264
  }
229
- return server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
230
265
  }
231
266
  /**
232
267
  * Refuse a command the local policy forbids, before anything is sent or deleted.
@@ -252,19 +287,38 @@ class BridgeController {
252
287
  * @returns counts and names of what was removed.
253
288
  */
254
289
  async cleanup() {
255
- // cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
256
- // 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
257
- this.policyGate('cleanup');
258
- const dir = this.shotsDir;
259
- if (dir === undefined)
260
- throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
290
+ const started = Date.now();
261
291
  try {
262
- return await cleanupArtifacts({ shotsDir: dir });
292
+ // cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
293
+ // 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
294
+ this.policyGate('cleanup');
295
+ const dir = this.shotsDir;
296
+ if (dir === undefined)
297
+ throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
298
+ try {
299
+ const result = await cleanupArtifacts({ shotsDir: dir });
300
+ this.audit.record({ command: 'cleanup', params: '{}', ok: true, durationMs: Date.now() - started });
301
+ return result;
302
+ }
303
+ catch (error) {
304
+ // 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
305
+ // 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
306
+ throw classify('cleanup', error);
307
+ }
263
308
  }
264
309
  catch (error) {
265
- // 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
266
- // 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
267
- throw classify('cleanup', error);
310
+ const code = error.code;
311
+ const effectState = error.effectState;
312
+ this.audit.record({
313
+ command: 'cleanup',
314
+ params: '{}',
315
+ ok: false,
316
+ code: typeof code === 'string' ? code : undefined,
317
+ effectState: typeof effectState === 'string' ? effectState : undefined,
318
+ error: errorMessage(error),
319
+ durationMs: Date.now() - started,
320
+ });
321
+ throw error;
268
322
  }
269
323
  }
270
324
  /** Stop the listener; safe to call repeatedly and during teardown. */
@@ -1692,6 +1746,7 @@ function applyBrowserTools(ctx, controller) {
1692
1746
  },
1693
1747
  }));
1694
1748
  registerDragTool(ctx, controller);
1749
+ registerAuditTool(ctx, controller);
1695
1750
  }
1696
1751
  /* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
1697
1752
  /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
@@ -1958,6 +2013,71 @@ function registerDragTool(ctx, controller) {
1958
2013
  },
1959
2014
  }));
1960
2015
  }
2016
+ function registerAuditTool(ctx, controller) {
2017
+ ctx.tools.register(defineTool({
2018
+ name: 'browser_audit',
2019
+ 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.',
2020
+ parameters: {
2021
+ command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
2022
+ tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
2023
+ sinceSeq: { type: 'number', description: 'Only entries with seq greater than this (paging).' },
2024
+ limit: { type: 'number', description: `Max entries to return (newest last), 1–${AUDIT_MAX_ENTRIES}; default 50.` },
2025
+ },
2026
+ output: {
2027
+ schema: {
2028
+ type: 'object',
2029
+ additionalProperties: false,
2030
+ properties: {
2031
+ total: { type: 'number', required: true },
2032
+ shown: { type: 'number', required: true },
2033
+ entries: {
2034
+ type: 'array',
2035
+ required: true,
2036
+ items: {
2037
+ type: 'object',
2038
+ additionalProperties: false,
2039
+ properties: {
2040
+ seq: { type: 'number', required: true },
2041
+ t: { type: 'number', required: true },
2042
+ command: { type: 'string', required: true },
2043
+ tabId: { type: 'number' },
2044
+ params: { type: 'string', required: true },
2045
+ ok: { type: 'boolean', required: true },
2046
+ code: { type: 'string' },
2047
+ effectState: { type: 'string' },
2048
+ error: { type: 'string' },
2049
+ durationMs: { type: 'number', required: true },
2050
+ },
2051
+ },
2052
+ },
2053
+ },
2054
+ },
2055
+ render: (_args, value) => {
2056
+ const v = value;
2057
+ if (v.entries.length === 0) {
2058
+ return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.` }];
2059
+ }
2060
+ const lines = v.entries.map((e) => {
2061
+ const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
2062
+ const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}${e.effectState ? `/${e.effectState}` : ''}`;
2063
+ const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
2064
+ return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
2065
+ });
2066
+ return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
2067
+ },
2068
+ },
2069
+ presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
2070
+ async execute(args) {
2071
+ const entries = controller.audit.list({
2072
+ tabId: args.tabId,
2073
+ command: args.command,
2074
+ sinceSeq: args.sinceSeq,
2075
+ limit: args.limit,
2076
+ });
2077
+ return { total: controller.audit.size(), shown: entries.length, entries };
2078
+ },
2079
+ }));
2080
+ }
1961
2081
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
1962
2082
  export function apply(ctx, config) {
1963
2083
  const resolved = config;
package/llms.txt CHANGED
@@ -42,7 +42,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
42
42
 
43
43
  ## 关键文件
44
44
 
45
- - `src/index.ts` - 26 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
45
+ - `src/index.ts` - 27 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
46
46
  - `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
47
47
  - `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
48
48
  - `src/ws.ts` - RFC 6455 帧编解码
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@angelky7/dsh-browser-control",
3
3
  "description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
4
- "version": "1.7.3",
4
+ "version": "1.7.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "build": "tsc -p tsconfig.json && tsdown",
38
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
39
39
  "check": "npm run typecheck && npm run build && publint",
40
- "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_drag.mjs && node verify/verify_compat_matrix.mjs"
40
+ "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs && node verify/verify_nav_waituntil.mjs && node verify/verify_archive.mjs && node verify/verify_windows.mjs && node verify/verify_history_nav.mjs && node verify/verify_metrics_throttle.mjs && node verify/verify_drag.mjs && node verify/verify_audit.mjs && node verify/verify_compat_matrix.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",