@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 +3 -3
- package/README.md +5 -5
- package/lib/index.js +102 -11
- package/lib/types/audit.d.ts +3 -0
- package/lib/types/audit.js +55 -5
- package/lib/types/index.js +46 -9
- package/package.json +1 -1
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.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
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.
|
|
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.
|
|
68
|
-
| [dsh-browser-control-plugin-v1.7.
|
|
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.
|
|
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
|
|
267
|
-
| `browser_journal` | 读**跨 dsh 重启**的持久命令账本(`<stateDir>/journal.jsonl`,最多 1000
|
|
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 (
|
|
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)
|
|
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.
|
|
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
|
}));
|
package/lib/types/audit.d.ts
CHANGED
|
@@ -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 文本——审计工具原样把它交给模型。
|
package/lib/types/audit.js
CHANGED
|
@@ -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 (
|
|
33
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 分页时
|
package/lib/types/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
+
"version": "1.7.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|