@angelky7/dsh-browser-control 1.7.9 → 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.9 do not touch the extension — skip this if v1.7.2 is already loaded) |
68
- | [dsh-browser-control-plugin-v1.7.9.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.9/dsh-browser-control-plugin-v1.7.9.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
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.9"
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.9 都没有动扩展,装过 v1.7.2 的不用重装) |
68
- | [dsh-browser-control-plugin-v1.7.9.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.7.9/dsh-browser-control-plugin-v1.7.9.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
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.9"
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"
package/lib/index.js CHANGED
@@ -1284,6 +1284,38 @@ var SessionRegistry = class {
1284
1284
  }
1285
1285
  };
1286
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
1287
1319
  //#region lib/types/index.js
1288
1320
  /**
1289
1321
  * Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
@@ -1361,11 +1393,23 @@ function defineTool$1(options) {
1361
1393
  const execute = ((args, exec) => {
1362
1394
  const agent = exec.agent;
1363
1395
  const sessionId = typeof agent?.id === "string" && agent.id.length > 0 ? agent.id : void 0;
1364
- 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
+ });
1365
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
+ };
1366
1409
  return defineTool({
1367
1410
  ...options,
1368
- execute
1411
+ execute,
1412
+ output
1369
1413
  });
1370
1414
  }
1371
1415
  /** Cordis plugin name used by loader diagnostics. */
@@ -1783,9 +1827,10 @@ function errorMessage(error) {
1783
1827
  */
1784
1828
  const MAX_RENDERED_CHARS = 4e3;
1785
1829
  function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
1786
- if (text.length <= maxChars) return text;
1787
- const omitted = text.length - maxChars;
1788
- 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`;
1789
1834
  }
1790
1835
  /** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
1791
1836
  const SNAPSHOT_RENDER_MAX_CHARS = 8e3;
@@ -1793,8 +1838,8 @@ const SNAPSHOT_RENDER_MAX_CHARS = 8e3;
1793
1838
  const LOG_RENDER_MAX_CHARS = 6e3;
1794
1839
  /** Collapse one page-supplied field to a single line inside a rendered listing. */
1795
1840
  function flattenOneLine(value, maxChars) {
1796
- const flat = value.replace(/\s+/g, " ").trim();
1797
- 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)}…`;
1798
1843
  }
1799
1844
  /** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
1800
1845
  function formatClock(epochMs) {
@@ -2129,7 +2174,7 @@ function applyBrowserTools(ctx, controller) {
2129
2174
  tabId: args.tabId
2130
2175
  }, exec.signal);
2131
2176
  const full = raw.content ?? "";
2132
- const content = full.slice(offset, offset + limit);
2177
+ const content = toWellFormed(full.slice(offset, offset + limit));
2133
2178
  return {
2134
2179
  tabId: raw.tabId,
2135
2180
  mode,
@@ -4440,7 +4485,7 @@ function registerSessionsTool(ctx, controller) {
4440
4485
  },
4441
4486
  render: (_args, value) => {
4442
4487
  const v = value;
4443
- const short = (id) => id.length <= 18 ? id : `${id.slice(0, 16)}…`;
4488
+ const short = (id) => id.length <= 18 ? id : `${sliceWellFormed(id, 16)}…`;
4444
4489
  if (v.releasedTabId !== void 0) return [{
4445
4490
  type: "text",
4446
4491
  text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.`
@@ -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).
@@ -506,10 +523,14 @@ function errorMessage(error) {
506
523
  */
507
524
  const MAX_RENDERED_CHARS = 4_000;
508
525
  function clampRendered(text, maxChars = MAX_RENDERED_CHARS) {
509
- if (text.length <= maxChars)
510
- return text;
511
- const omitted = text.length - maxChars;
512
- 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`;
513
534
  }
514
535
  /** Model-facing budget for one snapshot listing; `limit` narrows the listing. */
515
536
  const SNAPSHOT_RENDER_MAX_CHARS = 8_000;
@@ -517,8 +538,8 @@ const SNAPSHOT_RENDER_MAX_CHARS = 8_000;
517
538
  const LOG_RENDER_MAX_CHARS = 6_000;
518
539
  /** Collapse one page-supplied field to a single line inside a rendered listing. */
519
540
  function flattenOneLine(value, maxChars) {
520
- const flat = value.replace(/\s+/g, ' ').trim();
521
- 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)}…`;
522
543
  }
523
544
  /** `HH:MM:SS` in UTC from an epoch-milliseconds stamp. */
524
545
  function formatClock(epochMs) {
@@ -835,7 +856,9 @@ function applyBrowserTools(ctx, controller) {
835
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));
836
857
  const raw = await controller.execute('content', args.tabId === undefined ? { mode } : { mode, tabId: args.tabId }, exec.signal);
837
858
  const full = raw.content ?? '';
838
- const content = full.slice(offset, offset + limit);
859
+ // 码元窗口可能切在代理对中间;修成 well-formed,长度不变(U+FFFD 也是一码元),
860
+ // offset/truncated 的算术保持原语义。
861
+ const content = toWellFormed(full.slice(offset, offset + limit));
839
862
  return {
840
863
  tabId: raw.tabId,
841
864
  mode,
@@ -2458,7 +2481,7 @@ function registerSessionsTool(ctx, controller) {
2458
2481
  },
2459
2482
  render: (_args, value) => {
2460
2483
  const v = value;
2461
- const short = (id) => (id.length <= 18 ? id : `${id.slice(0, 16)}…`);
2484
+ const short = (id) => (id.length <= 18 ? id : `${sliceWellFormed(id, 16)}…`);
2462
2485
  if (v.releasedTabId !== undefined) {
2463
2486
  return [{ type: 'text', text: `Sessions: released your working tab ${v.releasedTabId} — later calls without tabId follow the browser's active tab again.` }];
2464
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.9",
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",