@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 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.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.8"
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.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.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.8"
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 表达式截断。纯本地读,只读档下照常可用(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;
@@ -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.log(`journal: disabled — ${errorMessage(error)}`);
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
- if (text.length <= maxChars) return text;
1725
- const omitted = text.length - maxChars;
1726
- return `${text.slice(0, maxChars)}\n… [renderedTruncated omittedChars=${omitted} limitChars=${maxChars}] narrow the query and call again to see the rest`;
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.slice(0, maxChars)}…`;
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.slice(0, 16)}…`;
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.`
@@ -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 分页时
@@ -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
- this.log(`journal: disabled — ${errorMessage(error)}`);
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
- if (text.length <= maxChars)
496
- return text;
497
- const omitted = text.length - maxChars;
498
- return `${text.slice(0, maxChars)}\n… [renderedTruncated omittedChars=${omitted} limitChars=${maxChars}] narrow the query and call again to see the rest`;
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.slice(0, maxChars)}…`;
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
- const content = full.slice(offset, offset + limit);
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.slice(0, 16)}…`);
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.8",
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",