@angelky7/dsh-browser-control 1.7.8 → 1.7.9

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
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
64
64
 
65
65
  | File | Purpose |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome extension (unzip and load; **the extension no longer changes with feature updates from v1.7.2 on**: v1.7.3–v1.7.8 do not touch the extension — skip this if v1.7.2 is already loaded) |
68
- | [dsh-browser-control-plugin-v1.7.8.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.8/dsh-browser-control-plugin-v1.7.8.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
67
+ | [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome extension (unzip and load; **the extension no longer changes with feature updates from v1.7.2 on**: v1.7.3–v1.7.9 do not touch the extension — skip this if v1.7.2 is already loaded) |
68
+ | [dsh-browser-control-plugin-v1.7.9.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.9/dsh-browser-control-plugin-v1.7.9.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
69
69
 
70
70
  ## Install the Chrome extension (30 seconds)
71
71
 
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # Straight from GitHub
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.8"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.9"
108
108
 
109
109
  # Local checkout for debugging (note: the explicit file: prefix is required)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome 扩展(解压后加载;**扩展从 v1.7.2 起不再随功能更新**:v1.7.3–v1.7.8 都没有动扩展,装过 v1.7.2 的不用重装) |
68
- | [dsh-browser-control-plugin-v1.7.8.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.8/dsh-browser-control-plugin-v1.7.8.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
67
+ | [DSH-Browser-Control-v1.7.2.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.2/DSH-Browser-Control-v1.7.2.zip) | Chrome 扩展(解压后加载;**扩展从 v1.7.2 起不再随功能更新**:v1.7.3–v1.7.9 都没有动扩展,装过 v1.7.2 的不用重装) |
68
+ | [dsh-browser-control-plugin-v1.7.9.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.9/dsh-browser-control-plugin-v1.7.9.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
69
69
 
70
70
  ## 安装 Chrome 扩展(30 秒)
71
71
 
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # 直接从 GitHub 安装
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.8"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.7.9"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -263,8 +263,8 @@ curl -X POST http://127.0.0.1:9777/api/command \
263
263
  | `browser_emulate` | 切设备视口(移动 / 桌面 / 自定义,v1.0.7+);v1.7.1 起还能限速:`network` slow3g/fast3g/offline/reset + `cpuThrottlingRate`(1–20),回执写明生效数值,`device:"reset"` 一并还原 |
264
264
  | `browser_metrics` | 读性能计数器(DOM 节点数 / JS 堆 / 布局与脚本耗时,Performance.getMetrics,只读;让「这页面很重」有数字说话,v1.7.1+) |
265
265
  | `browser_drag` | 拖拽:from/to 用选择器或视口坐标;`auto` 对 `draggable="true"` 的源走 html5 事件合成(dragstart→dragover→drop,未接受就不 drop),其余走真实鼠标输入(按下→插值移动→抬起,滑杆/画布/排序组件用这个)。**纯插件实现**(经 v1.7.2 预埋的 cdp 通道),v1.7.3+ |
266
- | `browser_audit` | 读本次插件会话的内存审计账本:发过/被拒的每条命令(参数脱敏、`code`/`effectState`、耗时),最多 200 条、dsh 重启即清;输入的文字与 cookie 值打码、eval 表达式截断。纯本地读,只读档下照常可用(v1.7.4+) |
267
- | `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000 条,按运行分组)——重启后先问它「上个会话在哪个标签页干了什么」;同样的脱敏规则;纯本地磁盘读,**扩展离线也能用**(v1.7.5+) |
266
+ | `browser_audit` | 读本次插件会话的内存审计账本:发过/被拒的每条命令(参数脱敏、`code`/`effectState`、耗时),最多 200 条、dsh 重启即清;输入的文字与 cookie 值打码、URL 查询值与敏感片段打码、错误文本里的 URL 同样打码、eval 表达式截断。回执里的 `journal` 说明本次是否在写盘(`enabled:false` + `error` = 仅内存)。纯本地读,只读档下照常可用(v1.7.4+) |
267
+ | `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000 条,按运行分组)——重启后先问它「上个会话在哪个标签页干了什么」;同样的脱敏规则;`journal.enabled` 说明**本次**运行是否在写盘(false 时你看到的是更早的运行)。纯本地磁盘读,**扩展离线也能用**(v1.7.5+) |
268
268
  | `browser_sessions` | 多 session 隔离:列出共用这个浏览器的 dsh 会话与各自的**工作标签页**;`action:"release"` 释放自己的。省略 tabId 的调用默认落到**本会话**的工作标签页(先核验还开着),并发会话不再互相抢活动页;显式 tabId 永远有效。纯本地,v1.7.6+ |
269
269
  | `browser_windows` | 窗口控制:清单/开窗/改状态与几何/关整窗/跨窗挪标签(唯一能碰窗口几何的工具,v1.6.0+) |
270
270
  | `browser_cleanup` | 清理临时文件 |
package/lib/index.js CHANGED
@@ -974,6 +974,46 @@ const AUDIT_EXPRESSION_MAX_CHARS = 120;
974
974
  /** 参数里这些键的值一律打码(大小写不敏感)。 */
975
975
  const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
976
976
  /**
977
+ * URL 里的秘密不能靠「截断」处理:`?token=…&code=…` 截到 120 字符照样是明文。
978
+ * 审计的用途是「做了什么」,所以保留 scheme/host/path 与查询键名,**查询值一律打码**;
979
+ * 片段(#)只在看起来像参数(含 `=` 或 token/secret 字样)时整体打码——SPA 路由
980
+ * (`#/inbox`)要留住,OAuth 隐式流的 `#access_token=…` 不能留。
981
+ */
982
+ const URL_PATTERN = /https?:\/\/[^\s"'<>]+/g;
983
+ function redactUrlMatch(match) {
984
+ const trailing = match.match(/[).,;:!?]+$/);
985
+ const core = trailing === null ? match : match.slice(0, -trailing[0].length);
986
+ const tail = trailing === null ? "" : trailing[0];
987
+ const queryAt = core.indexOf("?");
988
+ const hashAt = core.indexOf("#");
989
+ const base = queryAt >= 0 ? core.slice(0, queryAt) : hashAt >= 0 ? core.slice(0, hashAt) : core;
990
+ const query = queryAt >= 0 ? core.slice(queryAt + 1, hashAt >= 0 ? hashAt : void 0) : "";
991
+ const hash = hashAt >= 0 ? core.slice(hashAt) : "";
992
+ let changed = false;
993
+ const redactedQuery = query.length === 0 ? "" : query.split("&").map((pair) => {
994
+ const eq = pair.indexOf("=");
995
+ if (eq < 0) return pair;
996
+ const key = pair.slice(0, eq);
997
+ let value = pair.slice(eq + 1);
998
+ try {
999
+ value = decodeURIComponent(value);
1000
+ } catch {}
1001
+ changed = true;
1002
+ return `${key}=[redacted:${[...value].length} chars]`;
1003
+ }).join("&");
1004
+ let redactedHash = hash;
1005
+ if (hash.length > 1 && (/=/.test(hash) || /token|secret|access_token/i.test(hash))) {
1006
+ redactedHash = "#[redacted]";
1007
+ changed = true;
1008
+ }
1009
+ if (!changed) return core + tail;
1010
+ return `${base}${redactedQuery.length > 0 ? `?${redactedQuery}` : ""}${redactedHash}${tail}`;
1011
+ }
1012
+ /** 把一段文本里所有 URL 的查询值/敏感片段打码(用于参数摘要与错误文本)。 */
1013
+ function redactAuditText(text) {
1014
+ return text.replace(URL_PATTERN, redactUrlMatch);
1015
+ }
1016
+ /**
977
1017
  * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
978
1018
  * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
979
1019
  */
@@ -991,7 +1031,11 @@ function summarizeAuditParams(command, params) {
991
1031
  text = JSON.stringify(params, (key, value) => {
992
1032
  if (SECRET_KEY.test(key)) return "[redacted]";
993
1033
  if (key === "value" && VALUE_REDACTED_COMMANDS.has(command)) return redactValue(value);
994
- 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]`;
1034
+ if (typeof value === "string") {
1035
+ const redacted = redactAuditText(value);
1036
+ if (key === "expression" && [...redacted].length > AUDIT_EXPRESSION_MAX_CHARS) return `${[...redacted].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join("")}…[+${[...redacted].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
1037
+ return redacted;
1038
+ }
995
1039
  return value;
996
1040
  }) ?? "{}";
997
1041
  } catch {
@@ -1023,7 +1067,10 @@ var AuditLog = class {
1023
1067
  if (input.tabId !== void 0) entry.tabId = input.tabId;
1024
1068
  if (input.code !== void 0) entry.code = input.code;
1025
1069
  if (input.effectState !== void 0) entry.effectState = input.effectState;
1026
- 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;
1070
+ if (input.error !== void 0) {
1071
+ const redacted = redactAuditText(input.error);
1072
+ entry.error = [...redacted].length > AUDIT_ERROR_MAX_CHARS ? `${[...redacted].slice(0, AUDIT_ERROR_MAX_CHARS).join("")}…` : redacted;
1073
+ }
1027
1074
  this.entries.push(entry);
1028
1075
  if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
1029
1076
  return entry;
@@ -1410,6 +1457,7 @@ var BridgeController = class {
1410
1457
  current;
1411
1458
  journal;
1412
1459
  journalKey = "";
1460
+ journalError;
1413
1461
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1414
1462
  audit = new AuditLog();
1415
1463
  /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
@@ -1426,6 +1474,18 @@ var BridgeController = class {
1426
1474
  return this.current === void 0 ? void 0 : path.resolve(this.current.stateDir);
1427
1475
  }
1428
1476
  /**
1477
+ * Whether THIS run is persisting the journal, and why not. `browser_audit` /
1478
+ * `browser_journal` surface this: a memory-only run used to be visible only in
1479
+ * one log line, so a missing journal looked like "nothing happened".
1480
+ */
1481
+ get journalStatus() {
1482
+ if (this.journal !== void 0) return { enabled: true };
1483
+ return this.journalError === void 0 ? { enabled: false } : {
1484
+ enabled: false,
1485
+ error: this.journalError
1486
+ };
1487
+ }
1488
+ /**
1429
1489
  * Record one audit entry in memory and, when the journal is open, on disk.
1430
1490
  * Journal appends are queued and never block the command path.
1431
1491
  */
@@ -1508,8 +1568,10 @@ var BridgeController = class {
1508
1568
  await previousJournal?.close();
1509
1569
  try {
1510
1570
  this.journal = await Journal.open(stateDir, this.log);
1571
+ this.journalError = void 0;
1511
1572
  } catch (error) {
1512
- this.log(`journal: disabled — ${errorMessage(error)}`);
1573
+ this.journalError = errorMessage(error);
1574
+ this.log(`journal: disabled — ${this.journalError}`);
1513
1575
  }
1514
1576
  }
1515
1577
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
@@ -4070,10 +4132,13 @@ function registerDragTool(ctx, controller) {
4070
4132
  }
4071
4133
  }));
4072
4134
  }
4135
+ function journalWarning(status) {
4136
+ return status.enabled ? "" : `\nJournal write: disabled${status.error === void 0 ? "" : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
4137
+ }
4073
4138
  function registerAuditTool(ctx, controller) {
4074
4139
  ctx.tools.register(defineTool$1({
4075
4140
  name: "browser_audit",
4076
- 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.",
4141
+ 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; URL query values and URLs inside error text are redacted; eval expressions are truncated. The `journal` field says whether this run is also being written to disk (enabled:false + error = memory-only). Plugin-local read — works in read-only mode.",
4077
4142
  parameters: {
4078
4143
  command: {
4079
4144
  type: "string",
@@ -4105,6 +4170,17 @@ function registerAuditTool(ctx, controller) {
4105
4170
  type: "number",
4106
4171
  required: true
4107
4172
  },
4173
+ journal: {
4174
+ type: "object",
4175
+ additionalProperties: false,
4176
+ properties: {
4177
+ enabled: {
4178
+ type: "boolean",
4179
+ required: true
4180
+ },
4181
+ error: { type: "string" }
4182
+ }
4183
+ },
4108
4184
  entries: {
4109
4185
  type: "array",
4110
4186
  required: true,
@@ -4147,9 +4223,10 @@ function registerAuditTool(ctx, controller) {
4147
4223
  },
4148
4224
  render: (_args, value) => {
4149
4225
  const v = value;
4226
+ const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
4150
4227
  if (v.entries.length === 0) return [{
4151
4228
  type: "text",
4152
- text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.`
4229
+ text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}`
4153
4230
  }];
4154
4231
  const lines = v.entries.map((e) => {
4155
4232
  const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
@@ -4159,7 +4236,7 @@ function registerAuditTool(ctx, controller) {
4159
4236
  });
4160
4237
  return [{
4161
4238
  type: "text",
4162
- text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
4239
+ text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}${warn}`)
4163
4240
  }];
4164
4241
  }
4165
4242
  },
@@ -4178,7 +4255,8 @@ function registerAuditTool(ctx, controller) {
4178
4255
  return {
4179
4256
  total: controller.audit.size(),
4180
4257
  shown: entries.length,
4181
- entries
4258
+ entries,
4259
+ journal: controller.journalStatus
4182
4260
  };
4183
4261
  }
4184
4262
  }));
@@ -4186,7 +4264,7 @@ function registerAuditTool(ctx, controller) {
4186
4264
  function registerJournalTool(ctx, controller) {
4187
4265
  ctx.tools.register(defineTool$1({
4188
4266
  name: "browser_journal",
4189
- 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.",
4267
+ 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. The `journal` field says whether the CURRENT run is writing (enabled:false + error = this run is memory-only; what you see is from earlier runs). 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.",
4190
4268
  parameters: {
4191
4269
  command: {
4192
4270
  type: "string",
@@ -4218,6 +4296,17 @@ function registerJournalTool(ctx, controller) {
4218
4296
  type: "number",
4219
4297
  required: true
4220
4298
  },
4299
+ journal: {
4300
+ type: "object",
4301
+ additionalProperties: false,
4302
+ properties: {
4303
+ enabled: {
4304
+ type: "boolean",
4305
+ required: true
4306
+ },
4307
+ error: { type: "string" }
4308
+ }
4309
+ },
4221
4310
  entries: {
4222
4311
  type: "array",
4223
4312
  required: true,
@@ -4264,9 +4353,10 @@ function registerJournalTool(ctx, controller) {
4264
4353
  },
4265
4354
  render: (_args, value) => {
4266
4355
  const v = value;
4356
+ const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
4267
4357
  if (v.entries.length === 0) return [{
4268
4358
  type: "text",
4269
- text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.`
4359
+ text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}`
4270
4360
  }];
4271
4361
  const lines = [];
4272
4362
  let run = "";
@@ -4281,7 +4371,7 @@ function registerJournalTool(ctx, controller) {
4281
4371
  }
4282
4372
  return [{
4283
4373
  type: "text",
4284
- text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}`)
4374
+ text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}${warn}`)
4285
4375
  }];
4286
4376
  }
4287
4377
  },
@@ -4302,7 +4392,8 @@ function registerJournalTool(ctx, controller) {
4302
4392
  total,
4303
4393
  shown: entries.length,
4304
4394
  runs: new Set(entries.map((e) => e.run)).size,
4305
- entries
4395
+ entries,
4396
+ journal: controller.journalStatus
4306
4397
  };
4307
4398
  }
4308
4399
  }));
@@ -9,6 +9,7 @@
9
9
  * - **只存内存**(环形缓冲,200 条):跨 dsh 重启的持久化是「会话恢复」那一版的事;
10
10
  * 这一版先把账记对,别把磁盘格式写死。
11
11
  * - **参数摘要是脱敏后的**:输入框的值、cookie 的值、令牌类字段一律打码;
12
+ * URL 的查询值与敏感片段打码,错误文本里的 URL 同样打码(截断不是脱敏);
12
13
  * 表达式截断。审计的用途是「做了什么」,不是「内容是什么」。
13
14
  */
14
15
  export interface AuditEntry {
@@ -37,6 +38,8 @@ export interface AuditFilter {
37
38
  }
38
39
  /** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
39
40
  export declare const AUDIT_MAX_ENTRIES = 200;
41
+ /** 把一段文本里所有 URL 的查询值/敏感片段打码(用于参数摘要与错误文本)。 */
42
+ export declare function redactAuditText(text: string): string;
40
43
  /**
41
44
  * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
42
45
  * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
@@ -8,6 +8,50 @@ const AUDIT_ERROR_MAX_CHARS = 200;
8
8
  const AUDIT_EXPRESSION_MAX_CHARS = 120;
9
9
  /** 参数里这些键的值一律打码(大小写不敏感)。 */
10
10
  const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
11
+ /**
12
+ * URL 里的秘密不能靠「截断」处理:`?token=…&code=…` 截到 120 字符照样是明文。
13
+ * 审计的用途是「做了什么」,所以保留 scheme/host/path 与查询键名,**查询值一律打码**;
14
+ * 片段(#)只在看起来像参数(含 `=` 或 token/secret 字样)时整体打码——SPA 路由
15
+ * (`#/inbox`)要留住,OAuth 隐式流的 `#access_token=…` 不能留。
16
+ */
17
+ const URL_PATTERN = /https?:\/\/[^\s"'<>]+/g;
18
+ function redactUrlMatch(match) {
19
+ // 错误文本里的 URL 常跟着标点("...?token=x." / "...(https://…)"),先剥掉再原样接回。
20
+ const trailing = match.match(/[).,;:!?]+$/);
21
+ const core = trailing === null ? match : match.slice(0, -trailing[0].length);
22
+ const tail = trailing === null ? '' : trailing[0];
23
+ const queryAt = core.indexOf('?');
24
+ const hashAt = core.indexOf('#');
25
+ const base = queryAt >= 0 ? core.slice(0, queryAt) : (hashAt >= 0 ? core.slice(0, hashAt) : core);
26
+ const query = queryAt >= 0 ? core.slice(queryAt + 1, hashAt >= 0 ? hashAt : undefined) : '';
27
+ const hash = hashAt >= 0 ? core.slice(hashAt) : '';
28
+ let changed = false;
29
+ const redactedQuery = query.length === 0 ? '' : query.split('&').map((pair) => {
30
+ const eq = pair.indexOf('=');
31
+ if (eq < 0)
32
+ return pair;
33
+ const key = pair.slice(0, eq);
34
+ let value = pair.slice(eq + 1);
35
+ try {
36
+ value = decodeURIComponent(value);
37
+ }
38
+ catch { /* 解码失败就按原文字符数 */ }
39
+ changed = true;
40
+ return `${key}=[redacted:${[...value].length} chars]`;
41
+ }).join('&');
42
+ let redactedHash = hash;
43
+ if (hash.length > 1 && (/=/.test(hash) || /token|secret|access_token/i.test(hash))) {
44
+ redactedHash = '#[redacted]';
45
+ changed = true;
46
+ }
47
+ if (!changed)
48
+ return core + tail;
49
+ return `${base}${redactedQuery.length > 0 ? `?${redactedQuery}` : ''}${redactedHash}${tail}`;
50
+ }
51
+ /** 把一段文本里所有 URL 的查询值/敏感片段打码(用于参数摘要与错误文本)。 */
52
+ export function redactAuditText(text) {
53
+ return text.replace(URL_PATTERN, redactUrlMatch);
54
+ }
11
55
  /**
12
56
  * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
13
57
  * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
@@ -29,8 +73,13 @@ export function summarizeAuditParams(command, params) {
29
73
  return '[redacted]';
30
74
  if (key === 'value' && VALUE_REDACTED_COMMANDS.has(command))
31
75
  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]`;
76
+ if (typeof value === 'string') {
77
+ // URL query 与敏感片段先打码,再做表达式截断/整体限长:截断不是脱敏。
78
+ const redacted = redactAuditText(value);
79
+ if (key === 'expression' && [...redacted].length > AUDIT_EXPRESSION_MAX_CHARS) {
80
+ return `${[...redacted].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join('')}…[+${[...redacted].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
81
+ }
82
+ return redacted;
34
83
  }
35
84
  return value;
36
85
  }) ?? '{}';
@@ -70,9 +119,10 @@ export class AuditLog {
70
119
  if (input.effectState !== undefined)
71
120
  entry.effectState = input.effectState;
72
121
  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;
122
+ const redacted = redactAuditText(input.error);
123
+ entry.error = [...redacted].length > AUDIT_ERROR_MAX_CHARS
124
+ ? `${[...redacted].slice(0, AUDIT_ERROR_MAX_CHARS).join('')}…`
125
+ : redacted;
76
126
  }
77
127
  this.entries.push(entry);
78
128
  // 环形缓冲:超出上限从最旧的一端丢。seq 不回收——调用方拿 seq 分页时
@@ -163,6 +163,7 @@ class BridgeController {
163
163
  current;
164
164
  journal;
165
165
  journalKey = '';
166
+ journalError;
166
167
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
167
168
  audit = new AuditLog();
168
169
  /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
@@ -178,6 +179,16 @@ class BridgeController {
178
179
  get stateDir() {
179
180
  return this.current === undefined ? undefined : path.resolve(this.current.stateDir);
180
181
  }
182
+ /**
183
+ * Whether THIS run is persisting the journal, and why not. `browser_audit` /
184
+ * `browser_journal` surface this: a memory-only run used to be visible only in
185
+ * one log line, so a missing journal looked like "nothing happened".
186
+ */
187
+ get journalStatus() {
188
+ if (this.journal !== undefined)
189
+ return { enabled: true };
190
+ return this.journalError === undefined ? { enabled: false } : { enabled: false, error: this.journalError };
191
+ }
181
192
  /**
182
193
  * Record one audit entry in memory and, when the journal is open, on disk.
183
194
  * Journal appends are queued and never block the command path.
@@ -267,10 +278,13 @@ class BridgeController {
267
278
  await previousJournal?.close();
268
279
  try {
269
280
  this.journal = await Journal.open(stateDir, this.log);
281
+ this.journalError = undefined;
270
282
  }
271
283
  catch (error) {
272
284
  // 账本打不开不是让桥起不来的理由:降级为纯内存审计。
273
- this.log(`journal: disabled — ${errorMessage(error)}`);
285
+ // 降级状态必须能被调用方看见(audit/journal 工具的回执),不能只写一行日志。
286
+ this.journalError = errorMessage(error);
287
+ this.log(`journal: disabled — ${this.journalError}`);
274
288
  }
275
289
  }
276
290
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
@@ -2255,10 +2269,15 @@ function registerDragTool(ctx, controller) {
2255
2269
  },
2256
2270
  }));
2257
2271
  }
2272
+ function journalWarning(status) {
2273
+ return status.enabled
2274
+ ? ''
2275
+ : `\nJournal write: disabled${status.error === undefined ? '' : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
2276
+ }
2258
2277
  function registerAuditTool(ctx, controller) {
2259
2278
  ctx.tools.register(defineTool({
2260
2279
  name: 'browser_audit',
2261
- 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.',
2280
+ 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; URL query values and URLs inside error text are redacted; eval expressions are truncated. The `journal` field says whether this run is also being written to disk (enabled:false + error = memory-only). Plugin-local read — works in read-only mode.',
2262
2281
  parameters: {
2263
2282
  command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
2264
2283
  tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
@@ -2272,6 +2291,14 @@ function registerAuditTool(ctx, controller) {
2272
2291
  properties: {
2273
2292
  total: { type: 'number', required: true },
2274
2293
  shown: { type: 'number', required: true },
2294
+ journal: {
2295
+ type: 'object',
2296
+ additionalProperties: false,
2297
+ properties: {
2298
+ enabled: { type: 'boolean', required: true },
2299
+ error: { type: 'string' },
2300
+ },
2301
+ },
2275
2302
  entries: {
2276
2303
  type: 'array',
2277
2304
  required: true,
@@ -2296,8 +2323,9 @@ function registerAuditTool(ctx, controller) {
2296
2323
  },
2297
2324
  render: (_args, value) => {
2298
2325
  const v = value;
2326
+ const warn = v.journal === undefined ? '' : journalWarning(v.journal);
2299
2327
  if (v.entries.length === 0) {
2300
- return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.` }];
2328
+ return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}` }];
2301
2329
  }
2302
2330
  const lines = v.entries.map((e) => {
2303
2331
  const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
@@ -2305,7 +2333,7 @@ function registerAuditTool(ctx, controller) {
2305
2333
  const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
2306
2334
  return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
2307
2335
  });
2308
- return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
2336
+ return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
2309
2337
  },
2310
2338
  },
2311
2339
  presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
@@ -2316,14 +2344,14 @@ function registerAuditTool(ctx, controller) {
2316
2344
  sinceSeq: args.sinceSeq,
2317
2345
  limit: args.limit,
2318
2346
  });
2319
- return { total: controller.audit.size(), shown: entries.length, entries };
2347
+ return { total: controller.audit.size(), shown: entries.length, entries, journal: controller.journalStatus };
2320
2348
  },
2321
2349
  }));
2322
2350
  }
2323
2351
  function registerJournalTool(ctx, controller) {
2324
2352
  ctx.tools.register(defineTool({
2325
2353
  name: 'browser_journal',
2326
- 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.',
2354
+ 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. The `journal` field says whether the CURRENT run is writing (enabled:false + error = this run is memory-only; what you see is from earlier runs). 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.',
2327
2355
  parameters: {
2328
2356
  command: { type: 'string', description: 'Only entries whose command contains this substring.' },
2329
2357
  tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
@@ -2337,6 +2365,14 @@ function registerJournalTool(ctx, controller) {
2337
2365
  total: { type: 'number', required: true },
2338
2366
  shown: { type: 'number', required: true },
2339
2367
  runs: { type: 'number', required: true },
2368
+ journal: {
2369
+ type: 'object',
2370
+ additionalProperties: false,
2371
+ properties: {
2372
+ enabled: { type: 'boolean', required: true },
2373
+ error: { type: 'string' },
2374
+ },
2375
+ },
2340
2376
  entries: {
2341
2377
  type: 'array',
2342
2378
  required: true,
@@ -2362,8 +2398,9 @@ function registerJournalTool(ctx, controller) {
2362
2398
  },
2363
2399
  render: (_args, value) => {
2364
2400
  const v = value;
2401
+ const warn = v.journal === undefined ? '' : journalWarning(v.journal);
2365
2402
  if (v.entries.length === 0) {
2366
- return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.` }];
2403
+ return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}` }];
2367
2404
  }
2368
2405
  const lines = [];
2369
2406
  let run = '';
@@ -2376,7 +2413,7 @@ function registerJournalTool(ctx, controller) {
2376
2413
  const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}`;
2377
2414
  lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
2378
2415
  }
2379
- return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}`) }];
2416
+ return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
2380
2417
  },
2381
2418
  },
2382
2419
  presentCall: () => ({ card: 'generic', title: 'Browser journal', kind: 'other' }),
@@ -2385,7 +2422,7 @@ function registerJournalTool(ctx, controller) {
2385
2422
  if (dir === undefined)
2386
2423
  throw new Error('browser-bridge is not configured yet — the journal has no stateDir to read');
2387
2424
  const { total, entries } = await readJournal(dir, { command: args.command, tabId: args.tabId, limit: args.limit });
2388
- return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries };
2425
+ return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries, journal: controller.journalStatus };
2389
2426
  },
2390
2427
  }));
2391
2428
  }
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.8",
4
+ "version": "1.7.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",