@angelky7/dsh-browser-control 1.7.7 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 分页时
@@ -60,5 +60,8 @@ export declare class BridgeError extends Error {
60
60
  /**
61
61
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
62
62
  * 保证多次包裹不会把 code 洗掉。
63
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
64
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
65
+ * 不带 `fn` 的 wait 仍是只读。
63
66
  */
64
- export declare function classify(command: string, error: unknown): BridgeError;
67
+ export declare function classify(command: string, error: unknown, params?: Record<string, unknown>): BridgeError;
@@ -69,7 +69,7 @@ const HINTS = {
69
69
  extension_offline: '浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。',
70
70
  command_timeout: '扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。',
71
71
  link_lost: '命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。',
72
- cancelled: '调用被取消:按需重新发起,不必当作错误处理。',
72
+ cancelled: '调用被取消:effectState=not_started 时命令确定没发出去,可以按需重新发起;unknown 时动作可能已经执行,先确认页面状态再决定,不要直接重做。',
73
73
  not_configured: '插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。',
74
74
  held_by_user: '这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。',
75
75
  blocked_by_policy: '这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。',
@@ -126,8 +126,11 @@ export class BridgeError extends Error {
126
126
  /**
127
127
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
128
128
  * 保证多次包裹不会把 code 洗掉。
129
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
130
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
131
+ * 不带 `fn` 的 wait 仍是只读。
129
132
  */
130
- export function classify(command, error) {
133
+ export function classify(command, error, params) {
131
134
  if (error instanceof BridgeError)
132
135
  return error;
133
136
  const raw = error instanceof Error ? error.message : String(error);
@@ -144,7 +147,9 @@ export function classify(command, error) {
144
147
  : 0;
145
148
  const message = locked ? raw.slice(markerLength).trim() : raw;
146
149
  // 写操作一旦超时或掉线,结果就是未知的;读操作永远可以安全重试。
147
- const unknownIsPossible = isWriteCommand(command);
150
+ // wait(fn) 例外:谓词是任意 JS,按写命令同档处理(见函数顶部 @param params)。
151
+ const unknownIsPossible = isWriteCommand(command)
152
+ || (command === 'wait' && typeof params?.fn === 'string' && params.fn.trim().length > 0);
148
153
  let code;
149
154
  if (locked !== undefined)
150
155
  code = locked;
@@ -166,6 +171,10 @@ export function classify(command, error) {
166
171
  ? (unknownIsPossible ? 'unknown' : 'not_started')
167
172
  : code === 'command_failed'
168
173
  ? (unknownIsPossible ? 'unknown' : 'not_started')
169
- : 'not_started';
174
+ // 取消分两段:写命令在 socket.write 之后才被取消时,帧已经出去了,结果未知;
175
+ // 「before send」的那一条确定没发,仍是 not_started。读命令没有页面副作用。
176
+ : code === 'cancelled'
177
+ ? (/cancelled before send/.test(message) ? 'not_started' : (unknownIsPossible ? 'unknown' : 'not_started'))
178
+ : 'not_started';
170
179
  return new BridgeError(command, code, effectState, message);
171
180
  }
@@ -163,6 +163,7 @@ class BridgeController {
163
163
  current;
164
164
  journal;
165
165
  journalKey = '';
166
+ journalError;
166
167
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
167
168
  audit = new AuditLog();
168
169
  /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
@@ -178,12 +179,25 @@ class BridgeController {
178
179
  get stateDir() {
179
180
  return this.current === undefined ? undefined : path.resolve(this.current.stateDir);
180
181
  }
182
+ /**
183
+ * Whether THIS run is persisting the journal, and why not. `browser_audit` /
184
+ * `browser_journal` surface this: a memory-only run used to be visible only in
185
+ * one log line, so a missing journal looked like "nothing happened".
186
+ */
187
+ get journalStatus() {
188
+ if (this.journal !== undefined)
189
+ return { enabled: true };
190
+ return this.journalError === undefined ? { enabled: false } : { enabled: false, error: this.journalError };
191
+ }
181
192
  /**
182
193
  * Record one audit entry in memory and, when the journal is open, on disk.
183
194
  * Journal appends are queued and never block the command path.
184
195
  */
185
196
  recordAudit(input) {
186
- this.journal?.append(this.audit.record(input));
197
+ // 先记账再落盘:`this.journal?.append(this.audit.record(input))` 会让可选链
198
+ // 短路掉 audit.record —— journal 没开(默认就是关的)时内存审计整本丢失。
199
+ const entry = this.audit.record(input);
200
+ this.journal?.append(entry);
187
201
  }
188
202
  /**
189
203
  * Current policy flags. Tools that compose several commands (browser_drag
@@ -264,10 +278,13 @@ class BridgeController {
264
278
  await previousJournal?.close();
265
279
  try {
266
280
  this.journal = await Journal.open(stateDir, this.log);
281
+ this.journalError = undefined;
267
282
  }
268
283
  catch (error) {
269
284
  // 账本打不开不是让桥起不来的理由:降级为纯内存审计。
270
- this.log(`journal: disabled — ${errorMessage(error)}`);
285
+ // 降级状态必须能被调用方看见(audit/journal 工具的回执),不能只写一行日志。
286
+ this.journalError = errorMessage(error);
287
+ this.log(`journal: disabled — ${this.journalError}`);
271
288
  }
272
289
  }
273
290
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : '';
@@ -281,7 +298,15 @@ class BridgeController {
281
298
  this.lastError = undefined;
282
299
  return;
283
300
  }
284
- const server = new BridgeServer({ port: config.port, token: config.token, shotsDir, log: this.log });
301
+ const server = new BridgeServer({
302
+ port: config.port,
303
+ token: config.token,
304
+ shotsDir,
305
+ log: this.log,
306
+ // HTTP /api/command 与工具走同一条闸门与账本:裸 server.execute() 会绕开
307
+ // 只读档/allowScript,而且不在审计里留下痕迹(默认 token 还是公开的那一个)。
308
+ httpCommand: (command, params, options) => this.execute(command, params, new AbortController().signal, options.timeoutMs),
309
+ });
285
310
  try {
286
311
  await server.start();
287
312
  }
@@ -349,12 +374,23 @@ class BridgeController {
349
374
  * to work; when it is gone we forget it and let the browser's active tab
350
375
  * answer, exactly like before this feature existed.
351
376
  */
352
- async isolateParams(command, params, sessionId, signal) {
353
- if (typeof params.tabId === 'number' || NO_TAB_DEFAULT_COMMANDS.has(command))
354
- return params;
377
+ /**
378
+ * The calling session's working tab, after the same liveness probe isolateParams
379
+ * uses. Tools that compose several commands (browser_drag) need the id *before*
380
+ * issuing anything, so they must not fall back to the browser's active tab on
381
+ * their own — that would silently bypass session isolation.
382
+ */
383
+ async sessionTab(signal) {
384
+ const sessionId = TOOL_SESSION.getStore();
385
+ if (sessionId === undefined)
386
+ return undefined;
387
+ return await this.liveRecordedTab(sessionId, signal);
388
+ }
389
+ /** Recorded working tab if it is still open; a gone tab is forgotten here. */
390
+ async liveRecordedTab(sessionId, signal) {
355
391
  const recorded = this.sessions.workingTab(sessionId);
356
392
  if (recorded === undefined)
357
- return params;
393
+ return undefined;
358
394
  const server = this.server;
359
395
  if (server !== undefined) {
360
396
  try {
@@ -362,11 +398,19 @@ class BridgeController {
362
398
  const live = Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded);
363
399
  if (!live) {
364
400
  this.sessions.release(sessionId);
365
- return params;
401
+ return undefined;
366
402
  }
367
403
  }
368
404
  catch { /* 列不出来就按旧行为走活动标签页 */ }
369
405
  }
406
+ return recorded;
407
+ }
408
+ async isolateParams(command, params, sessionId, signal) {
409
+ if (typeof params.tabId === 'number' || NO_TAB_DEFAULT_COMMANDS.has(command))
410
+ return params;
411
+ const recorded = await this.liveRecordedTab(sessionId, signal);
412
+ if (recorded === undefined)
413
+ return params;
370
414
  return { ...params, tabId: recorded };
371
415
  }
372
416
  /** Record the call and adopt the tab it actually touched (params or result). */
@@ -1936,21 +1980,44 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
1936
1980
  if (nodeId === 0)
1937
1981
  throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
1938
1982
  await cdpCall(ctrl, tabId, 'DOM.scrollIntoViewIfNeeded', { nodeId }, signal);
1983
+ return await measureDragNode(ctrl, tabId, nodeId, selector, signal);
1984
+ }
1985
+ /**
1986
+ * Read a node's **current** viewport geometry without scrolling. Needed after the
1987
+ * *other* endpoint's scrollIntoViewIfNeeded: that scroll can move the first
1988
+ * endpoint (and this one), so coordinates measured before it are stale. DOM
1989
+ * geometry is viewport-relative; dispatching the old numbers would drag from
1990
+ * wherever the element used to be.
1991
+ */
1992
+ async function measureDragNode(ctrl, tabId, nodeId, label, signal) {
1939
1993
  let box;
1940
1994
  try {
1941
1995
  box = await cdpCall(ctrl, tabId, 'DOM.getBoxModel', { nodeId }, signal);
1942
1996
  }
1943
1997
  catch (error) {
1944
- throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
1998
+ throw new Error(`cannot measure ${JSON.stringify(label)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
1945
1999
  }
1946
2000
  const q = box?.model?.content;
1947
2001
  if (!Array.isArray(q) || q.length < 8)
1948
- throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
2002
+ throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(label)}`);
1949
2003
  const x = (q[0] + q[2] + q[4] + q[6]) / 4;
1950
2004
  const y = (q[1] + q[3] + q[5] + q[7]) / 4;
1951
2005
  const width = typeof box?.model?.width === 'number' ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]);
1952
2006
  const height = typeof box?.model?.height === 'number' ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]);
1953
- return { selector, x, y, width, height, nodeId };
2007
+ return { selector: label, x, y, width, height, nodeId };
2008
+ }
2009
+ /** CSS viewport size via Page.getLayoutMetrics (no page JS, works with allowScript off). */
2010
+ async function viewportSize(ctrl, tabId, signal) {
2011
+ try {
2012
+ const metrics = await cdpCall(ctrl, tabId, 'Page.getLayoutMetrics', {}, signal);
2013
+ const viewport = metrics?.cssVisualViewport ?? metrics?.cssLayoutViewport;
2014
+ const width = Number(viewport?.clientWidth);
2015
+ const height = Number(viewport?.clientHeight);
2016
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0)
2017
+ return { width, height };
2018
+ }
2019
+ catch { /* 量不出视口就跳过边界检查,不让一条查询失败挡掉拖拽本身 */ }
2020
+ return undefined;
1954
2021
  }
1955
2022
  /** Whether the source declares the native HTML5 `draggable` attribute. */
1956
2023
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
@@ -2098,6 +2165,8 @@ function registerDragTool(ctx, controller) {
2098
2165
  if (controller.policy.readOnly)
2099
2166
  throw new Error('browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag');
2100
2167
  let tabId = args.tabId;
2168
+ if (tabId === undefined)
2169
+ tabId = await controller.sessionTab(exec.signal);
2101
2170
  if (tabId === undefined) {
2102
2171
  const listing = await controller.execute('tabs.list', {}, exec.signal);
2103
2172
  if (typeof listing?.activeTabId !== 'number')
@@ -2114,24 +2183,59 @@ function registerDragTool(ctx, controller) {
2114
2183
  }
2115
2184
  if (resolvedMode === 'mouse') {
2116
2185
  const target = await resolveDragPoint(controller, tabId, to, exec.signal);
2186
+ // 目标端点的 scrollIntoViewIfNeeded 会把页面滚走,源端点先前的视口坐标就失效了;
2187
+ // 两端都是选择器时按当前几何重测一次(getBoxModel 不滚动,不会再次互相干扰)。
2188
+ // 若滚完后两端不在同一视口里,宁可拒绝,也不盲发一组越界坐标去拖错东西。
2189
+ let sourcePoint = source;
2190
+ let targetPoint = target;
2191
+ if (source?.nodeId !== undefined && target.nodeId !== undefined) {
2192
+ sourcePoint = await measureDragNode(controller, tabId, source.nodeId, String(from.selector), exec.signal);
2193
+ targetPoint = await measureDragNode(controller, tabId, target.nodeId, String(to.selector), exec.signal);
2194
+ }
2195
+ const viewport = await viewportSize(controller, tabId, exec.signal);
2196
+ if (viewport !== undefined) {
2197
+ const describe = (p) => `${p.selector !== undefined ? JSON.stringify(p.selector) : 'point'} at (${Math.round(p.x)},${Math.round(p.y)})`;
2198
+ const outside = (p) => p.x < 0 || p.y < 0 || p.x > viewport.width || p.y > viewport.height;
2199
+ if (outside(sourcePoint) || outside(targetPoint)) {
2200
+ throw new Error(`drag endpoints are not both visible in the viewport (${describe(sourcePoint)} → ${describe(targetPoint)}, viewport ${Math.round(viewport.width)}×${Math.round(viewport.height)}) — scroll so both fit, then retry; mode:"html5" takes selectors and needs no viewport coordinates`);
2201
+ }
2202
+ }
2117
2203
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === 'number' ? args.steps : 12)));
2118
- const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
2204
+ const sx = sourcePoint.x, sy = sourcePoint.y, tx = targetPoint.x, ty = targetPoint.y;
2119
2205
  const dispatch = async (params) => {
2120
2206
  await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', params, exec.signal);
2121
2207
  };
2122
- await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
2123
- await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
2124
- for (let i = 1; i <= steps; i++) {
2125
- const f = i / steps;
2126
- await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
2208
+ // 按住之后任何一步失败(取消、超时、CDP 报错)都必须尽力抬起:否则页面会
2209
+ // 停在「鼠标左键按着」的状态,用户下一次点击会被当成拖拽的延续。
2210
+ let pressedAt;
2211
+ try {
2212
+ await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
2213
+ await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
2214
+ pressedAt = { x: sx, y: sy };
2215
+ for (let i = 1; i <= steps; i++) {
2216
+ const f = i / steps;
2217
+ await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
2218
+ pressedAt = { x: sx + (tx - sx) * f, y: sy + (ty - sy) * f };
2219
+ }
2220
+ await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
2221
+ pressedAt = undefined;
2222
+ await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
2223
+ }
2224
+ catch (error) {
2225
+ if (pressedAt !== undefined) {
2226
+ // 独立信号:调用方的 signal 可能已经 abort,清扫仍要发出去。
2227
+ try {
2228
+ await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: pressedAt.x, y: pressedAt.y, button: 'left', buttons: 0, clickCount: 1 }, new AbortController().signal);
2229
+ }
2230
+ catch { /* 清扫失败不能盖掉原始错误 */ }
2231
+ }
2232
+ throw error;
2127
2233
  }
2128
- await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
2129
- await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
2130
2234
  const receipt = {
2131
2235
  tabId,
2132
2236
  mode: 'mouse',
2133
- from: roundDragPoint(source),
2134
- to: roundDragPoint(target),
2237
+ from: roundDragPoint(sourcePoint),
2238
+ to: roundDragPoint(targetPoint),
2135
2239
  steps,
2136
2240
  dispatched: steps + 4,
2137
2241
  elapsedMs: Date.now() - started,
@@ -2165,10 +2269,15 @@ function registerDragTool(ctx, controller) {
2165
2269
  },
2166
2270
  }));
2167
2271
  }
2272
+ function journalWarning(status) {
2273
+ return status.enabled
2274
+ ? ''
2275
+ : `\nJournal write: disabled${status.error === undefined ? '' : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
2276
+ }
2168
2277
  function registerAuditTool(ctx, controller) {
2169
2278
  ctx.tools.register(defineTool({
2170
2279
  name: 'browser_audit',
2171
- description: 'Read this plugin session\'s in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; eval expressions are truncated. Plugin-local read — works in read-only mode.',
2280
+ description: 'Read this plugin session\'s in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; URL query values and URLs inside error text are redacted; eval expressions are truncated. The `journal` field says whether this run is also being written to disk (enabled:false + error = memory-only). Plugin-local read — works in read-only mode.',
2172
2281
  parameters: {
2173
2282
  command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
2174
2283
  tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
@@ -2182,6 +2291,14 @@ function registerAuditTool(ctx, controller) {
2182
2291
  properties: {
2183
2292
  total: { type: 'number', required: true },
2184
2293
  shown: { type: 'number', required: true },
2294
+ journal: {
2295
+ type: 'object',
2296
+ additionalProperties: false,
2297
+ properties: {
2298
+ enabled: { type: 'boolean', required: true },
2299
+ error: { type: 'string' },
2300
+ },
2301
+ },
2185
2302
  entries: {
2186
2303
  type: 'array',
2187
2304
  required: true,
@@ -2206,8 +2323,9 @@ function registerAuditTool(ctx, controller) {
2206
2323
  },
2207
2324
  render: (_args, value) => {
2208
2325
  const v = value;
2326
+ const warn = v.journal === undefined ? '' : journalWarning(v.journal);
2209
2327
  if (v.entries.length === 0) {
2210
- return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.` }];
2328
+ return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}` }];
2211
2329
  }
2212
2330
  const lines = v.entries.map((e) => {
2213
2331
  const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
@@ -2215,7 +2333,7 @@ function registerAuditTool(ctx, controller) {
2215
2333
  const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
2216
2334
  return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
2217
2335
  });
2218
- return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
2336
+ return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
2219
2337
  },
2220
2338
  },
2221
2339
  presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
@@ -2226,14 +2344,14 @@ function registerAuditTool(ctx, controller) {
2226
2344
  sinceSeq: args.sinceSeq,
2227
2345
  limit: args.limit,
2228
2346
  });
2229
- return { total: controller.audit.size(), shown: entries.length, entries };
2347
+ return { total: controller.audit.size(), shown: entries.length, entries, journal: controller.journalStatus };
2230
2348
  },
2231
2349
  }));
2232
2350
  }
2233
2351
  function registerJournalTool(ctx, controller) {
2234
2352
  ctx.tools.register(defineTool({
2235
2353
  name: 'browser_journal',
2236
- description: 'Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover "what was I doing" after a restart.',
2354
+ description: 'Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. The `journal` field says whether the CURRENT run is writing (enabled:false + error = this run is memory-only; what you see is from earlier runs). Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover "what was I doing" after a restart.',
2237
2355
  parameters: {
2238
2356
  command: { type: 'string', description: 'Only entries whose command contains this substring.' },
2239
2357
  tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
@@ -2247,6 +2365,14 @@ function registerJournalTool(ctx, controller) {
2247
2365
  total: { type: 'number', required: true },
2248
2366
  shown: { type: 'number', required: true },
2249
2367
  runs: { type: 'number', required: true },
2368
+ journal: {
2369
+ type: 'object',
2370
+ additionalProperties: false,
2371
+ properties: {
2372
+ enabled: { type: 'boolean', required: true },
2373
+ error: { type: 'string' },
2374
+ },
2375
+ },
2250
2376
  entries: {
2251
2377
  type: 'array',
2252
2378
  required: true,
@@ -2272,8 +2398,9 @@ function registerJournalTool(ctx, controller) {
2272
2398
  },
2273
2399
  render: (_args, value) => {
2274
2400
  const v = value;
2401
+ const warn = v.journal === undefined ? '' : journalWarning(v.journal);
2275
2402
  if (v.entries.length === 0) {
2276
- return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.` }];
2403
+ return [{ type: 'text', text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}` }];
2277
2404
  }
2278
2405
  const lines = [];
2279
2406
  let run = '';
@@ -2286,7 +2413,7 @@ function registerJournalTool(ctx, controller) {
2286
2413
  const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}`;
2287
2414
  lines.push(`#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}`);
2288
2415
  }
2289
- return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}`) }];
2416
+ return [{ type: 'text', text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join('\n')}${warn}`) }];
2290
2417
  },
2291
2418
  },
2292
2419
  presentCall: () => ({ card: 'generic', title: 'Browser journal', kind: 'other' }),
@@ -2295,7 +2422,7 @@ function registerJournalTool(ctx, controller) {
2295
2422
  if (dir === undefined)
2296
2423
  throw new Error('browser-bridge is not configured yet — the journal has no stateDir to read');
2297
2424
  const { total, entries } = await readJournal(dir, { command: args.command, tabId: args.tabId, limit: args.limit });
2298
- return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries };
2425
+ return { total, shown: entries.length, runs: new Set(entries.map((e) => e.run)).size, entries, journal: controller.journalStatus };
2299
2426
  },
2300
2427
  }));
2301
2428
  }
@@ -21,6 +21,11 @@ export declare const READ_ONLY_ALLOWED: ReadonlySet<string>;
21
21
  /**
22
22
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
23
23
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
24
+ *
25
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
26
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
27
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
28
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
24
29
  */
25
30
  export declare const SCRIPT_COMMANDS: ReadonlySet<string>;
26
31
  /**
@@ -35,8 +35,22 @@ export const READ_ONLY_ALLOWED = new Set([
35
35
  /**
36
36
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
37
37
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
38
+ *
39
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
40
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
41
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
42
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
38
43
  */
39
44
  export const SCRIPT_COMMANDS = new Set(['eval']);
45
+ /**
46
+ * 这次调用会不会在页面里执行任意 JS。判据是「这次实际要做的动作」,不是命令名:
47
+ * `wait` 只有带 `fn` 时才升级为脚本调用(`selector` / `text` 是纯查询)。
48
+ */
49
+ function isScriptCall(command, params) {
50
+ if (SCRIPT_COMMANDS.has(command))
51
+ return true;
52
+ return command === 'wait' && typeof params?.fn === 'string' && params.fn.trim().length > 0;
53
+ }
40
54
  /**
41
55
  * 不经过扩展、直接在插件进程里改动本机文件的命令名。
42
56
  *
@@ -69,9 +83,19 @@ const CONFIG_HINT = '开关在 profile 的 cordis.patch.yml 里(browser-bridge
69
83
  * @returns 拒绝时写给 agent 的整句理由;放行时 `undefined`。
70
84
  */
71
85
  export function policyRefusal(command, flags, params) {
72
- if (!flags.allowScript && SCRIPT_COMMANDS.has(command)) {
73
- return `在页面里执行任意脚本已被关闭(allowScript: false):命令 ${command} 被拒绝,没有向浏览器发出任何操作。`
74
- + `要读页面内容请改用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
86
+ const scriptCall = isScriptCall(command, params);
87
+ if (!flags.allowScript && scriptCall) {
88
+ const what = command === 'wait'
89
+ ? '命令 wait 的 fn 参数(会在页面里执行任意 JS)被拒绝'
90
+ : `命令 ${command} 被拒绝`;
91
+ return `在页面里执行任意脚本已被关闭(allowScript: false):${what},没有向浏览器发出任何操作。`
92
+ + `要等页面就绪请改用 wait 的 selector / text(只读),读页面内容用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
93
+ }
94
+ // wait 白名单是按命令名放的,但带 fn 的那一种和 eval 同档:任意 JS 可以改页面,
95
+ // 只读档不能因为「它叫 wait」就放行。不带 fn 的 wait 仍走下面的白名单。
96
+ if (flags.readOnly && command === 'wait' && scriptCall) {
97
+ return `只读模式已开启(readOnly: true):命令 wait 的 fn 参数会在页面里执行任意 JS(可能改变页面状态),已被拒绝,没有向浏览器发出任何操作。`
98
+ + `要等页面就绪请改用 wait 的 selector / text(只读);要放开请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
75
99
  }
76
100
  if (flags.readOnly && LOCAL_DESTRUCTIVE_COMMANDS.has(command)) {
77
101
  return `只读模式已开启(readOnly: true):命令 ${command} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。`
@@ -90,6 +90,12 @@ export interface BridgeServerOptions {
90
90
  readonly scratchDir?: string;
91
91
  /** Optional line logger for lifecycle diagnostics. */
92
92
  readonly log?: (line: string) => void;
93
+ /**
94
+ * Executor for the HTTP command face. The plugin passes its controller so
95
+ * `/api/command` goes through the same policy gate and audit trail as tools;
96
+ * without it (bare BridgeServer users, tests) the face stays raw.
97
+ */
98
+ readonly httpCommand?: (command: string, params: Record<string, unknown>, options: BridgeExecuteOptions) => Promise<unknown>;
93
99
  }
94
100
  /**
95
101
  * Clear the screenshots directory and delete agent scratch files. Only the
@@ -116,7 +122,12 @@ export declare class BridgeServer {
116
122
  private connectedAt;
117
123
  private readonly pending;
118
124
  private lastError;
119
- private continuationRemainder;
125
+ /**
126
+ * Payloads of the message being reassembled. **Buffers, not strings**: decoding
127
+ * each fragment with `toString('utf8')` corrupts any multi-byte character that
128
+ * straddles a fragment boundary. Decode once, at FIN.
129
+ */
130
+ private continuationChunks;
120
131
  private continuationOpen;
121
132
  constructor(options: BridgeServerOptions);
122
133
  /** Current link state, also served verbatim as `/api/status`. */