@angelky7/dsh-browser-control 1.7.8 → 1.7.10
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 +156 -20
- package/lib/types/audit.d.ts +3 -0
- package/lib/types/audit.js +55 -5
- package/lib/types/index.js +79 -19
- package/lib/types/text.d.ts +19 -0
- package/lib/types/text.js +36 -0
- package/package.json +2 -2
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.10 need no extension reinstall; the v1.7.10 session fix is plugin-side only (extension clipping improvements ship with the next extension update)) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.10.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.10/dsh-browser-control-plugin-v1.7.10.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.10"
|
|
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.10 的功能更新都不需要重装扩展;v1.7.10 的会话修复只改了插件(扩展侧的截断优化随下一次扩展更新)) |
|
|
68
|
+
| [dsh-browser-control-plugin-v1.7.10.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.10/dsh-browser-control-plugin-v1.7.10.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.10"
|
|
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;
|
|
@@ -1237,6 +1284,38 @@ var SessionRegistry = class {
|
|
|
1237
1284
|
}
|
|
1238
1285
|
};
|
|
1239
1286
|
//#endregion
|
|
1287
|
+
//#region lib/types/text.js
|
|
1288
|
+
/**
|
|
1289
|
+
* UTF-16 截断守卫(issue #26)。
|
|
1290
|
+
*
|
|
1291
|
+
* 页面文本按**码元** `slice(0, n)` 会切出孤立代理项(半个 emoji)。`JSON.stringify`
|
|
1292
|
+
* 把它写成裸 `\ud83d`,模型的 JSON 解析器要求代理对成对出现,于是**整个请求体**
|
|
1293
|
+
* 被 400 拒收;坏字符进了会话历史就每轮重放,只能换会话。
|
|
1294
|
+
*
|
|
1295
|
+
* 两条规则:
|
|
1296
|
+
* 1. 切点落在高位代理项上时退一格——宁可少一个字,不留半个字符;
|
|
1297
|
+
* 2. `toWellFormed()` 兜底页面自己提供的坏字符(孤立代理项 → U+FFFD)。
|
|
1298
|
+
*/
|
|
1299
|
+
/** 把任意字符串变成 well-formed UTF-16(孤立代理项 → U+FFFD)。 */
|
|
1300
|
+
function toWellFormed(text) {
|
|
1301
|
+
const fn = text.toWellFormed;
|
|
1302
|
+
if (typeof fn === "function") return fn.call(text);
|
|
1303
|
+
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* 按码元上限截断,但不留下半个代理对。
|
|
1307
|
+
* @param text - 原文本。
|
|
1308
|
+
* @param maxChars - 码元上限(与既有 offset/limit 语义一致);≤0 返回空串。
|
|
1309
|
+
*/
|
|
1310
|
+
function sliceWellFormed(text, maxChars) {
|
|
1311
|
+
if (maxChars <= 0) return "";
|
|
1312
|
+
if (text.length <= maxChars) return toWellFormed(text);
|
|
1313
|
+
let end = maxChars;
|
|
1314
|
+
const unit = text.charCodeAt(end - 1);
|
|
1315
|
+
if (unit >= 55296 && unit <= 56319) end -= 1;
|
|
1316
|
+
return toWellFormed(text.slice(0, Math.max(0, end)));
|
|
1317
|
+
}
|
|
1318
|
+
//#endregion
|
|
1240
1319
|
//#region lib/types/index.js
|
|
1241
1320
|
/**
|
|
1242
1321
|
* Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
|
|
@@ -1314,11 +1393,23 @@ function defineTool$1(options) {
|
|
|
1314
1393
|
const execute = ((args, exec) => {
|
|
1315
1394
|
const agent = exec.agent;
|
|
1316
1395
|
const sessionId = typeof agent?.id === "string" && agent.id.length > 0 ? agent.id : void 0;
|
|
1317
|
-
return TOOL_SESSION.run(sessionId, () => inner(args, exec))
|
|
1396
|
+
return TOOL_SESSION.run(sessionId, () => inner(args, exec)).catch((error) => {
|
|
1397
|
+
if (error instanceof Error) error.message = toWellFormed(error.message);
|
|
1398
|
+
throw error;
|
|
1399
|
+
});
|
|
1318
1400
|
});
|
|
1401
|
+
const rawRender = options.output?.render;
|
|
1402
|
+
const output = rawRender === void 0 ? options.output : {
|
|
1403
|
+
...options.output,
|
|
1404
|
+
render: (args, value) => rawRender(args, value).map((part) => part.type === "text" && typeof part.text === "string" ? {
|
|
1405
|
+
...part,
|
|
1406
|
+
text: toWellFormed(part.text)
|
|
1407
|
+
} : part)
|
|
1408
|
+
};
|
|
1319
1409
|
return defineTool({
|
|
1320
1410
|
...options,
|
|
1321
|
-
execute
|
|
1411
|
+
execute,
|
|
1412
|
+
output
|
|
1322
1413
|
});
|
|
1323
1414
|
}
|
|
1324
1415
|
/** Cordis plugin name used by loader diagnostics. */
|
|
@@ -1410,6 +1501,7 @@ var BridgeController = class {
|
|
|
1410
1501
|
current;
|
|
1411
1502
|
journal;
|
|
1412
1503
|
journalKey = "";
|
|
1504
|
+
journalError;
|
|
1413
1505
|
/** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
|
|
1414
1506
|
audit = new AuditLog();
|
|
1415
1507
|
/** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
|
|
@@ -1426,6 +1518,18 @@ var BridgeController = class {
|
|
|
1426
1518
|
return this.current === void 0 ? void 0 : path.resolve(this.current.stateDir);
|
|
1427
1519
|
}
|
|
1428
1520
|
/**
|
|
1521
|
+
* Whether THIS run is persisting the journal, and why not. `browser_audit` /
|
|
1522
|
+
* `browser_journal` surface this: a memory-only run used to be visible only in
|
|
1523
|
+
* one log line, so a missing journal looked like "nothing happened".
|
|
1524
|
+
*/
|
|
1525
|
+
get journalStatus() {
|
|
1526
|
+
if (this.journal !== void 0) return { enabled: true };
|
|
1527
|
+
return this.journalError === void 0 ? { enabled: false } : {
|
|
1528
|
+
enabled: false,
|
|
1529
|
+
error: this.journalError
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1429
1533
|
* Record one audit entry in memory and, when the journal is open, on disk.
|
|
1430
1534
|
* Journal appends are queued and never block the command path.
|
|
1431
1535
|
*/
|
|
@@ -1508,8 +1612,10 @@ var BridgeController = class {
|
|
|
1508
1612
|
await previousJournal?.close();
|
|
1509
1613
|
try {
|
|
1510
1614
|
this.journal = await Journal.open(stateDir, this.log);
|
|
1615
|
+
this.journalError = void 0;
|
|
1511
1616
|
} catch (error) {
|
|
1512
|
-
this.
|
|
1617
|
+
this.journalError = errorMessage(error);
|
|
1618
|
+
this.log(`journal: disabled — ${this.journalError}`);
|
|
1513
1619
|
}
|
|
1514
1620
|
}
|
|
1515
1621
|
const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
|
|
@@ -1721,9 +1827,10 @@ function errorMessage(error) {
|
|
|
1721
1827
|
*/
|
|
1722
1828
|
const MAX_RENDERED_CHARS = 4e3;
|
|
1723
1829
|
function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1830
|
+
const safe = toWellFormed(text);
|
|
1831
|
+
if (safe.length <= maxChars) return safe;
|
|
1832
|
+
const cut = sliceWellFormed(safe, maxChars);
|
|
1833
|
+
return `${cut}\n… [renderedTruncated omittedChars=${safe.length - cut.length} limitChars=${maxChars}] narrow the query and call again to see the rest`;
|
|
1727
1834
|
}
|
|
1728
1835
|
/** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
|
|
1729
1836
|
const SNAPSHOT_RENDER_MAX_CHARS = 8e3;
|
|
@@ -1731,8 +1838,8 @@ const SNAPSHOT_RENDER_MAX_CHARS = 8e3;
|
|
|
1731
1838
|
const LOG_RENDER_MAX_CHARS = 6e3;
|
|
1732
1839
|
/** Collapse one page-supplied field to a single line inside a rendered listing. */
|
|
1733
1840
|
function flattenOneLine(value, maxChars) {
|
|
1734
|
-
const flat = value.replace(/\s+/g, " ").trim();
|
|
1735
|
-
return flat.length <= maxChars ? flat : `${flat
|
|
1841
|
+
const flat = toWellFormed(value.replace(/\s+/g, " ").trim());
|
|
1842
|
+
return flat.length <= maxChars ? flat : `${sliceWellFormed(flat, maxChars)}…`;
|
|
1736
1843
|
}
|
|
1737
1844
|
/** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
|
|
1738
1845
|
function formatClock(epochMs) {
|
|
@@ -2067,7 +2174,7 @@ function applyBrowserTools(ctx, controller) {
|
|
|
2067
2174
|
tabId: args.tabId
|
|
2068
2175
|
}, exec.signal);
|
|
2069
2176
|
const full = raw.content ?? "";
|
|
2070
|
-
const content = full.slice(offset, offset + limit);
|
|
2177
|
+
const content = toWellFormed(full.slice(offset, offset + limit));
|
|
2071
2178
|
return {
|
|
2072
2179
|
tabId: raw.tabId,
|
|
2073
2180
|
mode,
|
|
@@ -4070,10 +4177,13 @@ function registerDragTool(ctx, controller) {
|
|
|
4070
4177
|
}
|
|
4071
4178
|
}));
|
|
4072
4179
|
}
|
|
4180
|
+
function journalWarning(status) {
|
|
4181
|
+
return status.enabled ? "" : `\nJournal write: disabled${status.error === void 0 ? "" : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
|
|
4182
|
+
}
|
|
4073
4183
|
function registerAuditTool(ctx, controller) {
|
|
4074
4184
|
ctx.tools.register(defineTool$1({
|
|
4075
4185
|
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.",
|
|
4186
|
+
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
4187
|
parameters: {
|
|
4078
4188
|
command: {
|
|
4079
4189
|
type: "string",
|
|
@@ -4105,6 +4215,17 @@ function registerAuditTool(ctx, controller) {
|
|
|
4105
4215
|
type: "number",
|
|
4106
4216
|
required: true
|
|
4107
4217
|
},
|
|
4218
|
+
journal: {
|
|
4219
|
+
type: "object",
|
|
4220
|
+
additionalProperties: false,
|
|
4221
|
+
properties: {
|
|
4222
|
+
enabled: {
|
|
4223
|
+
type: "boolean",
|
|
4224
|
+
required: true
|
|
4225
|
+
},
|
|
4226
|
+
error: { type: "string" }
|
|
4227
|
+
}
|
|
4228
|
+
},
|
|
4108
4229
|
entries: {
|
|
4109
4230
|
type: "array",
|
|
4110
4231
|
required: true,
|
|
@@ -4147,9 +4268,10 @@ function registerAuditTool(ctx, controller) {
|
|
|
4147
4268
|
},
|
|
4148
4269
|
render: (_args, value) => {
|
|
4149
4270
|
const v = value;
|
|
4271
|
+
const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
|
|
4150
4272
|
if (v.entries.length === 0) return [{
|
|
4151
4273
|
type: "text",
|
|
4152
|
-
text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed
|
|
4274
|
+
text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}`
|
|
4153
4275
|
}];
|
|
4154
4276
|
const lines = v.entries.map((e) => {
|
|
4155
4277
|
const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
|
|
@@ -4159,7 +4281,7 @@ function registerAuditTool(ctx, controller) {
|
|
|
4159
4281
|
});
|
|
4160
4282
|
return [{
|
|
4161
4283
|
type: "text",
|
|
4162
|
-
text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
|
|
4284
|
+
text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}${warn}`)
|
|
4163
4285
|
}];
|
|
4164
4286
|
}
|
|
4165
4287
|
},
|
|
@@ -4178,7 +4300,8 @@ function registerAuditTool(ctx, controller) {
|
|
|
4178
4300
|
return {
|
|
4179
4301
|
total: controller.audit.size(),
|
|
4180
4302
|
shown: entries.length,
|
|
4181
|
-
entries
|
|
4303
|
+
entries,
|
|
4304
|
+
journal: controller.journalStatus
|
|
4182
4305
|
};
|
|
4183
4306
|
}
|
|
4184
4307
|
}));
|
|
@@ -4186,7 +4309,7 @@ function registerAuditTool(ctx, controller) {
|
|
|
4186
4309
|
function registerJournalTool(ctx, controller) {
|
|
4187
4310
|
ctx.tools.register(defineTool$1({
|
|
4188
4311
|
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.",
|
|
4312
|
+
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
4313
|
parameters: {
|
|
4191
4314
|
command: {
|
|
4192
4315
|
type: "string",
|
|
@@ -4218,6 +4341,17 @@ function registerJournalTool(ctx, controller) {
|
|
|
4218
4341
|
type: "number",
|
|
4219
4342
|
required: true
|
|
4220
4343
|
},
|
|
4344
|
+
journal: {
|
|
4345
|
+
type: "object",
|
|
4346
|
+
additionalProperties: false,
|
|
4347
|
+
properties: {
|
|
4348
|
+
enabled: {
|
|
4349
|
+
type: "boolean",
|
|
4350
|
+
required: true
|
|
4351
|
+
},
|
|
4352
|
+
error: { type: "string" }
|
|
4353
|
+
}
|
|
4354
|
+
},
|
|
4221
4355
|
entries: {
|
|
4222
4356
|
type: "array",
|
|
4223
4357
|
required: true,
|
|
@@ -4264,9 +4398,10 @@ function registerJournalTool(ctx, controller) {
|
|
|
4264
4398
|
},
|
|
4265
4399
|
render: (_args, value) => {
|
|
4266
4400
|
const v = value;
|
|
4401
|
+
const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
|
|
4267
4402
|
if (v.entries.length === 0) return [{
|
|
4268
4403
|
type: "text",
|
|
4269
|
-
text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet
|
|
4404
|
+
text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}`
|
|
4270
4405
|
}];
|
|
4271
4406
|
const lines = [];
|
|
4272
4407
|
let run = "";
|
|
@@ -4281,7 +4416,7 @@ function registerJournalTool(ctx, controller) {
|
|
|
4281
4416
|
}
|
|
4282
4417
|
return [{
|
|
4283
4418
|
type: "text",
|
|
4284
|
-
text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}`)
|
|
4419
|
+
text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}${warn}`)
|
|
4285
4420
|
}];
|
|
4286
4421
|
}
|
|
4287
4422
|
},
|
|
@@ -4302,7 +4437,8 @@ function registerJournalTool(ctx, controller) {
|
|
|
4302
4437
|
total,
|
|
4303
4438
|
shown: entries.length,
|
|
4304
4439
|
runs: new Set(entries.map((e) => e.run)).size,
|
|
4305
|
-
entries
|
|
4440
|
+
entries,
|
|
4441
|
+
journal: controller.journalStatus
|
|
4306
4442
|
};
|
|
4307
4443
|
}
|
|
4308
4444
|
}));
|
|
@@ -4349,7 +4485,7 @@ function registerSessionsTool(ctx, controller) {
|
|
|
4349
4485
|
},
|
|
4350
4486
|
render: (_args, value) => {
|
|
4351
4487
|
const v = value;
|
|
4352
|
-
const short = (id) => id.length <= 18 ? id : `${id
|
|
4488
|
+
const short = (id) => id.length <= 18 ? id : `${sliceWellFormed(id, 16)}…`;
|
|
4353
4489
|
if (v.releasedTabId !== void 0) return [{
|
|
4354
4490
|
type: "text",
|
|
4355
4491
|
text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.`
|
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
|
@@ -29,6 +29,7 @@ import { policyRefusal } from "./policy.js";
|
|
|
29
29
|
import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
|
|
30
30
|
import { Journal, readJournal } from "./journal.js";
|
|
31
31
|
import { SessionRegistry } from "./sessions.js";
|
|
32
|
+
import { sliceWellFormed, toWellFormed } from "./text.js";
|
|
32
33
|
/** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
|
|
33
34
|
const NAV_MAX_MS = 120_000;
|
|
34
35
|
/**
|
|
@@ -76,12 +77,28 @@ function defineTool(options) {
|
|
|
76
77
|
const execute = ((args, exec) => {
|
|
77
78
|
const agent = exec.agent;
|
|
78
79
|
const sessionId = typeof agent?.id === 'string' && agent.id.length > 0 ? agent.id : undefined;
|
|
79
|
-
return TOOL_SESSION.run(sessionId, () => inner(args, exec))
|
|
80
|
+
return TOOL_SESSION.run(sessionId, () => inner(args, exec)).catch((error) => {
|
|
81
|
+
// 错误文本同样进入模型请求体:页面异常可能带孤立代理项(issue #26)。
|
|
82
|
+
// 原地改 message 而不是包一层,保留 BridgeError 的 code/effectState。
|
|
83
|
+
if (error instanceof Error)
|
|
84
|
+
error.message = toWellFormed(error.message);
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
80
87
|
});
|
|
88
|
+
// 模型可见文本的最后一道路障(issue #26):任何 render 产出的 text 在交给宿主前
|
|
89
|
+
// 都修成 well-formed。扩展侧旧版本还在用码元截断,这里必须兜住;否则半个 emoji
|
|
90
|
+
// 会让整个请求体 400,且坏字符会随会话历史每轮重放。
|
|
91
|
+
const rawRender = options.output?.render;
|
|
92
|
+
const output = rawRender === undefined
|
|
93
|
+
? options.output
|
|
94
|
+
: {
|
|
95
|
+
...options.output,
|
|
96
|
+
render: (args, value) => rawRender(args, value).map((part) => (part.type === 'text' && typeof part.text === 'string' ? { ...part, text: toWellFormed(part.text) } : part)),
|
|
97
|
+
};
|
|
81
98
|
// Call the host helper through a non-generic point: re-instantiating its
|
|
82
99
|
// recursive InferObject types from inside a generic wrapper is what TS chokes on.
|
|
83
100
|
const hostDefine = defineHostTool;
|
|
84
|
-
return hostDefine({ ...options, execute });
|
|
101
|
+
return hostDefine({ ...options, execute, output });
|
|
85
102
|
}
|
|
86
103
|
// Re-exported so the policy tables stay testable from the published bundle
|
|
87
104
|
// (verify/verify_policy.mjs reconciles them against the extension's COMMANDS).
|
|
@@ -163,6 +180,7 @@ class BridgeController {
|
|
|
163
180
|
current;
|
|
164
181
|
journal;
|
|
165
182
|
journalKey = '';
|
|
183
|
+
journalError;
|
|
166
184
|
/** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
|
|
167
185
|
audit = new AuditLog();
|
|
168
186
|
/** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
|
|
@@ -178,6 +196,16 @@ class BridgeController {
|
|
|
178
196
|
get stateDir() {
|
|
179
197
|
return this.current === undefined ? undefined : path.resolve(this.current.stateDir);
|
|
180
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether THIS run is persisting the journal, and why not. `browser_audit` /
|
|
201
|
+
* `browser_journal` surface this: a memory-only run used to be visible only in
|
|
202
|
+
* one log line, so a missing journal looked like "nothing happened".
|
|
203
|
+
*/
|
|
204
|
+
get journalStatus() {
|
|
205
|
+
if (this.journal !== undefined)
|
|
206
|
+
return { enabled: true };
|
|
207
|
+
return this.journalError === undefined ? { enabled: false } : { enabled: false, error: this.journalError };
|
|
208
|
+
}
|
|
181
209
|
/**
|
|
182
210
|
* Record one audit entry in memory and, when the journal is open, on disk.
|
|
183
211
|
* Journal appends are queued and never block the command path.
|
|
@@ -267,10 +295,13 @@ class BridgeController {
|
|
|
267
295
|
await previousJournal?.close();
|
|
268
296
|
try {
|
|
269
297
|
this.journal = await Journal.open(stateDir, this.log);
|
|
298
|
+
this.journalError = undefined;
|
|
270
299
|
}
|
|
271
300
|
catch (error) {
|
|
272
301
|
// 账本打不开不是让桥起不来的理由:降级为纯内存审计。
|
|
273
|
-
|
|
302
|
+
// 降级状态必须能被调用方看见(audit/journal 工具的回执),不能只写一行日志。
|
|
303
|
+
this.journalError = errorMessage(error);
|
|
304
|
+
this.log(`journal: disabled — ${this.journalError}`);
|
|
274
305
|
}
|
|
275
306
|
}
|
|
276
307
|
const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
|
|
@@ -492,10 +523,14 @@ function errorMessage(error) {
|
|
|
492
523
|
*/
|
|
493
524
|
const MAX_RENDERED_CHARS = 4_000;
|
|
494
525
|
function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
526
|
+
// 页面文本可能自带孤立代理项,也可能是上一次按码元切出来的;先修好再判断长度。
|
|
527
|
+
const safe = toWellFormed(text);
|
|
528
|
+
if (safe.length <= maxChars)
|
|
529
|
+
return safe;
|
|
530
|
+
const cut = sliceWellFormed(safe, maxChars);
|
|
531
|
+
// 报数按**实际展示**的长度算:退掉的半个代理对算进 omitted,可见 + omitted = 总长。
|
|
532
|
+
const omitted = safe.length - cut.length;
|
|
533
|
+
return `${cut}\n… [renderedTruncated omittedChars=${omitted} limitChars=${maxChars}] narrow the query and call again to see the rest`;
|
|
499
534
|
}
|
|
500
535
|
/** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
|
|
501
536
|
const SNAPSHOT_RENDER_MAX_CHARS = 8_000;
|
|
@@ -503,8 +538,8 @@ const SNAPSHOT_RENDER_MAX_CHARS = 8_000;
|
|
|
503
538
|
const LOG_RENDER_MAX_CHARS = 6_000;
|
|
504
539
|
/** Collapse one page-supplied field to a single line inside a rendered listing. */
|
|
505
540
|
function flattenOneLine(value, maxChars) {
|
|
506
|
-
const flat = value.replace(/\s+/g, ' ').trim();
|
|
507
|
-
return flat.length <= maxChars ? flat : `${flat
|
|
541
|
+
const flat = toWellFormed(value.replace(/\s+/g, ' ').trim());
|
|
542
|
+
return flat.length <= maxChars ? flat : `${sliceWellFormed(flat, maxChars)}…`;
|
|
508
543
|
}
|
|
509
544
|
/** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
|
|
510
545
|
function formatClock(epochMs) {
|
|
@@ -821,7 +856,9 @@ function applyBrowserTools(ctx, controller) {
|
|
|
821
856
|
const limit = Math.min(READ_WINDOW_MAX_CHARS, Math.max(1, Math.floor(Number(args.limit ?? READ_WINDOW_MAX_CHARS)) || READ_WINDOW_MAX_CHARS));
|
|
822
857
|
const raw = await controller.execute('content', args.tabId === undefined ? { mode } : { mode, tabId: args.tabId }, exec.signal);
|
|
823
858
|
const full = raw.content ?? '';
|
|
824
|
-
|
|
859
|
+
// 码元窗口可能切在代理对中间;修成 well-formed,长度不变(U+FFFD 也是一码元),
|
|
860
|
+
// offset/truncated 的算术保持原语义。
|
|
861
|
+
const content = toWellFormed(full.slice(offset, offset + limit));
|
|
825
862
|
return {
|
|
826
863
|
tabId: raw.tabId,
|
|
827
864
|
mode,
|
|
@@ -2255,10 +2292,15 @@ function registerDragTool(ctx, controller) {
|
|
|
2255
2292
|
},
|
|
2256
2293
|
}));
|
|
2257
2294
|
}
|
|
2295
|
+
function journalWarning(status) {
|
|
2296
|
+
return status.enabled
|
|
2297
|
+
? ''
|
|
2298
|
+
: `\nJournal write: disabled${status.error === undefined ? '' : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
|
|
2299
|
+
}
|
|
2258
2300
|
function registerAuditTool(ctx, controller) {
|
|
2259
2301
|
ctx.tools.register(defineTool({
|
|
2260
2302
|
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.',
|
|
2303
|
+
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
2304
|
parameters: {
|
|
2263
2305
|
command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
|
|
2264
2306
|
tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
|
|
@@ -2272,6 +2314,14 @@ function registerAuditTool(ctx, controller) {
|
|
|
2272
2314
|
properties: {
|
|
2273
2315
|
total: { type: 'number', required: true },
|
|
2274
2316
|
shown: { type: 'number', required: true },
|
|
2317
|
+
journal: {
|
|
2318
|
+
type: 'object',
|
|
2319
|
+
additionalProperties: false,
|
|
2320
|
+
properties: {
|
|
2321
|
+
enabled: { type: 'boolean', required: true },
|
|
2322
|
+
error: { type: 'string' },
|
|
2323
|
+
},
|
|
2324
|
+
},
|
|
2275
2325
|
entries: {
|
|
2276
2326
|
type: 'array',
|
|
2277
2327
|
required: true,
|
|
@@ -2296,8 +2346,9 @@ function registerAuditTool(ctx, controller) {
|
|
|
2296
2346
|
},
|
|
2297
2347
|
render: (_args, value) => {
|
|
2298
2348
|
const v = value;
|
|
2349
|
+
const warn = v.journal === undefined ? '' : journalWarning(v.journal);
|
|
2299
2350
|
if (v.entries.length === 0) {
|
|
2300
|
-
return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed
|
|
2351
|
+
return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}` }];
|
|
2301
2352
|
}
|
|
2302
2353
|
const lines = v.entries.map((e) => {
|
|
2303
2354
|
const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
|
|
@@ -2305,7 +2356,7 @@ function registerAuditTool(ctx, controller) {
|
|
|
2305
2356
|
const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
|
|
2306
2357
|
return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
|
|
2307
2358
|
});
|
|
2308
|
-
return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
|
|
2359
|
+
return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
|
|
2309
2360
|
},
|
|
2310
2361
|
},
|
|
2311
2362
|
presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
|
|
@@ -2316,14 +2367,14 @@ function registerAuditTool(ctx, controller) {
|
|
|
2316
2367
|
sinceSeq: args.sinceSeq,
|
|
2317
2368
|
limit: args.limit,
|
|
2318
2369
|
});
|
|
2319
|
-
return { total: controller.audit.size(), shown: entries.length, entries };
|
|
2370
|
+
return { total: controller.audit.size(), shown: entries.length, entries, journal: controller.journalStatus };
|
|
2320
2371
|
},
|
|
2321
2372
|
}));
|
|
2322
2373
|
}
|
|
2323
2374
|
function registerJournalTool(ctx, controller) {
|
|
2324
2375
|
ctx.tools.register(defineTool({
|
|
2325
2376
|
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.',
|
|
2377
|
+
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
2378
|
parameters: {
|
|
2328
2379
|
command: { type: 'string', description: 'Only entries whose command contains this substring.' },
|
|
2329
2380
|
tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
|
|
@@ -2337,6 +2388,14 @@ function registerJournalTool(ctx, controller) {
|
|
|
2337
2388
|
total: { type: 'number', required: true },
|
|
2338
2389
|
shown: { type: 'number', required: true },
|
|
2339
2390
|
runs: { type: 'number', required: true },
|
|
2391
|
+
journal: {
|
|
2392
|
+
type: 'object',
|
|
2393
|
+
additionalProperties: false,
|
|
2394
|
+
properties: {
|
|
2395
|
+
enabled: { type: 'boolean', required: true },
|
|
2396
|
+
error: { type: 'string' },
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2340
2399
|
entries: {
|
|
2341
2400
|
type: 'array',
|
|
2342
2401
|
required: true,
|
|
@@ -2362,8 +2421,9 @@ function registerJournalTool(ctx, controller) {
|
|
|
2362
2421
|
},
|
|
2363
2422
|
render: (_args, value) => {
|
|
2364
2423
|
const v = value;
|
|
2424
|
+
const warn = v.journal === undefined ? '' : journalWarning(v.journal);
|
|
2365
2425
|
if (v.entries.length === 0) {
|
|
2366
|
-
return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet
|
|
2426
|
+
return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}` }];
|
|
2367
2427
|
}
|
|
2368
2428
|
const lines = [];
|
|
2369
2429
|
let run = '';
|
|
@@ -2376,7 +2436,7 @@ function registerJournalTool(ctx, controller) {
|
|
|
2376
2436
|
const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}`;
|
|
2377
2437
|
lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
|
|
2378
2438
|
}
|
|
2379
|
-
return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}`) }];
|
|
2439
|
+
return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
|
|
2380
2440
|
},
|
|
2381
2441
|
},
|
|
2382
2442
|
presentCall: () => ({ card: 'generic', title: 'Browser journal', kind: 'other' }),
|
|
@@ -2385,7 +2445,7 @@ function registerJournalTool(ctx, controller) {
|
|
|
2385
2445
|
if (dir === undefined)
|
|
2386
2446
|
throw new Error('browser-bridge is not configured yet — the journal has no stateDir to read');
|
|
2387
2447
|
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 };
|
|
2448
|
+
return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries, journal: controller.journalStatus };
|
|
2389
2449
|
},
|
|
2390
2450
|
}));
|
|
2391
2451
|
}
|
|
@@ -2421,7 +2481,7 @@ function registerSessionsTool(ctx, controller) {
|
|
|
2421
2481
|
},
|
|
2422
2482
|
render: (_args, value) => {
|
|
2423
2483
|
const v = value;
|
|
2424
|
-
const short = (id) => (id.length <= 18 ? id : `${id
|
|
2484
|
+
const short = (id) => (id.length <= 18 ? id : `${sliceWellFormed(id, 16)}…`);
|
|
2425
2485
|
if (v.releasedTabId !== undefined) {
|
|
2426
2486
|
return [{ type: 'text', text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.` }];
|
|
2427
2487
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-16 截断守卫(issue #26)。
|
|
3
|
+
*
|
|
4
|
+
* 页面文本按**码元** `slice(0, n)` 会切出孤立代理项(半个 emoji)。`JSON.stringify`
|
|
5
|
+
* 把它写成裸 `\ud83d`,模型的 JSON 解析器要求代理对成对出现,于是**整个请求体**
|
|
6
|
+
* 被 400 拒收;坏字符进了会话历史就每轮重放,只能换会话。
|
|
7
|
+
*
|
|
8
|
+
* 两条规则:
|
|
9
|
+
* 1. 切点落在高位代理项上时退一格——宁可少一个字,不留半个字符;
|
|
10
|
+
* 2. `toWellFormed()` 兜底页面自己提供的坏字符(孤立代理项 → U+FFFD)。
|
|
11
|
+
*/
|
|
12
|
+
/** 把任意字符串变成 well-formed UTF-16(孤立代理项 → U+FFFD)。 */
|
|
13
|
+
export declare function toWellFormed(text: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* 按码元上限截断,但不留下半个代理对。
|
|
16
|
+
* @param text - 原文本。
|
|
17
|
+
* @param maxChars - 码元上限(与既有 offset/limit 语义一致);≤0 返回空串。
|
|
18
|
+
*/
|
|
19
|
+
export declare function sliceWellFormed(text: string, maxChars: number): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-16 截断守卫(issue #26)。
|
|
3
|
+
*
|
|
4
|
+
* 页面文本按**码元** `slice(0, n)` 会切出孤立代理项(半个 emoji)。`JSON.stringify`
|
|
5
|
+
* 把它写成裸 `\ud83d`,模型的 JSON 解析器要求代理对成对出现,于是**整个请求体**
|
|
6
|
+
* 被 400 拒收;坏字符进了会话历史就每轮重放,只能换会话。
|
|
7
|
+
*
|
|
8
|
+
* 两条规则:
|
|
9
|
+
* 1. 切点落在高位代理项上时退一格——宁可少一个字,不留半个字符;
|
|
10
|
+
* 2. `toWellFormed()` 兜底页面自己提供的坏字符(孤立代理项 → U+FFFD)。
|
|
11
|
+
*/
|
|
12
|
+
/** 把任意字符串变成 well-formed UTF-16(孤立代理项 → U+FFFD)。 */
|
|
13
|
+
export function toWellFormed(text) {
|
|
14
|
+
const fn = text.toWellFormed;
|
|
15
|
+
if (typeof fn === 'function')
|
|
16
|
+
return fn.call(text);
|
|
17
|
+
// 老运行时兜底:只替换没有配对的代理项。
|
|
18
|
+
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 按码元上限截断,但不留下半个代理对。
|
|
22
|
+
* @param text - 原文本。
|
|
23
|
+
* @param maxChars - 码元上限(与既有 offset/limit 语义一致);≤0 返回空串。
|
|
24
|
+
*/
|
|
25
|
+
export function sliceWellFormed(text, maxChars) {
|
|
26
|
+
if (maxChars <= 0)
|
|
27
|
+
return '';
|
|
28
|
+
if (text.length <= maxChars)
|
|
29
|
+
return toWellFormed(text);
|
|
30
|
+
let end = maxChars;
|
|
31
|
+
const unit = text.charCodeAt(end - 1);
|
|
32
|
+
// 高位代理项(0xD800–0xDBFF)意味着下一码元才是配对的一半:退一格。
|
|
33
|
+
if (unit >= 0xd800 && unit <= 0xdbff)
|
|
34
|
+
end -= 1;
|
|
35
|
+
return toWellFormed(text.slice(0, Math.max(0, end)));
|
|
36
|
+
}
|
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.10",
|
|
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_server_hardening.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_journal.mjs && node verify/verify_sessions.mjs && node verify/verify_snapshot_page.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_server_hardening.mjs && node verify/verify_text_safety.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_journal.mjs && node verify/verify_sessions.mjs && node verify/verify_snapshot_page.mjs && node verify/verify_compat_matrix.mjs"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public",
|