@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.
package/lib/index.js CHANGED
@@ -136,7 +136,7 @@ const HINTS = {
136
136
  extension_offline: "浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。",
137
137
  command_timeout: "扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。",
138
138
  link_lost: "命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。",
139
- cancelled: "调用被取消:按需重新发起,不必当作错误处理。",
139
+ cancelled: "调用被取消:effectState=not_started 时命令确定没发出去,可以按需重新发起;unknown 时动作可能已经执行,先确认页面状态再决定,不要直接重做。",
140
140
  not_configured: "插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。",
141
141
  held_by_user: "这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。",
142
142
  blocked_by_policy: "这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。",
@@ -190,14 +190,17 @@ var BridgeError = class extends Error {
190
190
  /**
191
191
  * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
192
192
  * 保证多次包裹不会把 code 洗掉。
193
+ * @param params - 这次调用的参数。只有 `wait` 需要它:`fn` 是在页面里执行任意 JS,
194
+ * 超时/掉线时与写命令同档报 `unknown`(谓词跑过就不能断言页面没被动过);
195
+ * 不带 `fn` 的 wait 仍是只读。
193
196
  */
194
- function classify(command, error) {
197
+ function classify(command, error, params) {
195
198
  if (error instanceof BridgeError) return error;
196
199
  const raw = error instanceof Error ? error.message : String(error);
197
200
  const locked = raw.startsWith("browser-locked-by-user:") ? "locked_by_user" : raw.startsWith("browser-lock-unreadable:") ? "lock_unreadable" : raw.startsWith("browser-hold-aborted:") ? "held_by_user" : void 0;
198
201
  const markerLength = locked === "locked_by_user" ? 23 : locked === "lock_unreadable" ? 24 : locked === "held_by_user" ? 21 : 0;
199
202
  const message = locked ? raw.slice(markerLength).trim() : raw;
200
- const unknownIsPossible = isWriteCommand(command);
203
+ const unknownIsPossible = isWriteCommand(command) || command === "wait" && typeof params?.fn === "string" && params.fn.trim().length > 0;
201
204
  let code;
202
205
  if (locked !== void 0) code = locked;
203
206
  else if (/no browser extension connected/.test(message)) code = "extension_offline";
@@ -207,7 +210,8 @@ function classify(command, error) {
207
210
  else if (/尚未加载配置|not configured yet/.test(message)) code = "not_configured";
208
211
  else if (/未启用|not enabled|桥接启动失败|listen EADDR|settings integration/.test(message)) code = "bridge_disabled";
209
212
  else code = "command_failed";
210
- return new BridgeError(command, code, code === "command_timeout" || code === "link_lost" ? unknownIsPossible ? "unknown" : "not_started" : code === "command_failed" ? unknownIsPossible ? "unknown" : "not_started" : "not_started", message);
213
+ const effectState = code === "command_timeout" || code === "link_lost" ? unknownIsPossible ? "unknown" : "not_started" : code === "command_failed" ? unknownIsPossible ? "unknown" : "not_started" : code === "cancelled" ? /cancelled before send/.test(message) ? "not_started" : unknownIsPossible ? "unknown" : "not_started" : "not_started";
214
+ return new BridgeError(command, code, effectState, message);
211
215
  }
212
216
  //#endregion
213
217
  //#region lib/types/server.js
@@ -286,7 +290,12 @@ var BridgeServer = class {
286
290
  connectedAt;
287
291
  pending = /* @__PURE__ */ new Map();
288
292
  lastError;
289
- continuationRemainder = "";
293
+ /**
294
+ * Payloads of the message being reassembled. **Buffers, not strings**: decoding
295
+ * each fragment with `toString('utf8')` corrupts any multi-byte character that
296
+ * straddles a fragment boundary. Decode once, at FIN.
297
+ */
298
+ continuationChunks = [];
290
299
  continuationOpen = false;
291
300
  constructor(options) {
292
301
  this.options = options;
@@ -391,7 +400,7 @@ var BridgeServer = class {
391
400
  if (signal !== void 0) {
392
401
  entry.onAbort = () => {
393
402
  if (!this.pending.delete(id)) return;
394
- entry.reject(/* @__PURE__ */ new Error(`browser command cancelled: ${command}`));
403
+ entry.reject(/* @__PURE__ */ new Error(`browser command cancelled after send: ${command}`));
395
404
  };
396
405
  signal.addEventListener("abort", entry.onAbort, { once: true });
397
406
  }
@@ -406,7 +415,7 @@ var BridgeServer = class {
406
415
  try {
407
416
  return await result;
408
417
  } catch (error) {
409
- throw classify(command, error);
418
+ throw classify(command, error, params);
410
419
  } finally {
411
420
  clearTimeout(entry.timer);
412
421
  if (entry.onAbort !== void 0 && signal !== void 0) signal.removeEventListener("abort", entry.onAbort);
@@ -544,20 +553,29 @@ var BridgeServer = class {
544
553
  });
545
554
  return;
546
555
  }
547
- if (typeof parsed.command !== "string" || parsed.command.length === 0) {
556
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
557
+ this.respondJson(res, 400, {
558
+ ok: false,
559
+ error: "body must be {\"command\": \"...\", \"params\": {...}}"
560
+ });
561
+ return;
562
+ }
563
+ const body = parsed;
564
+ if (typeof body.command !== "string" || body.command.length === 0) {
548
565
  this.respondJson(res, 400, {
549
566
  ok: false,
550
567
  error: "body must be {\"command\": \"...\", \"params\": {...}}"
551
568
  });
552
569
  return;
553
570
  }
554
- const timeoutMs = typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : void 0;
555
- this.execute(parsed.command, parsed.params ?? {}, timeoutMs === void 0 ? {} : { timeoutMs }).then((result) => this.respondJson(res, 200, {
571
+ const timeoutMs = typeof body.timeoutMs === "number" ? body.timeoutMs : void 0;
572
+ const options = timeoutMs === void 0 ? {} : { timeoutMs };
573
+ (this.options.httpCommand ?? ((command, params, opts) => this.execute(command, params, opts)))(body.command, body.params ?? {}, options).then((result) => this.respondJson(res, 200, {
556
574
  ok: true,
557
575
  result
558
576
  }), (error) => {
559
577
  if (error instanceof BridgeError) {
560
- const status = error.code === "extension_offline" || error.code === "bridge_disabled" || error.code === "not_configured" ? 503 : error.code === "command_timeout" || error.code === "link_lost" ? 504 : 502;
578
+ const status = error.code === "extension_offline" || error.code === "bridge_disabled" || error.code === "not_configured" ? 503 : error.code === "command_timeout" || error.code === "link_lost" ? 504 : error.code === "blocked_by_policy" ? 403 : 502;
561
579
  this.respondJson(res, status, error.toJSON());
562
580
  return;
563
581
  }
@@ -610,11 +628,15 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
610
628
  this.clientSocket = socket;
611
629
  this.hello = void 0;
612
630
  this.connectedAt = /* @__PURE__ */ new Date();
613
- this.continuationRemainder = "";
631
+ this.continuationChunks = [];
614
632
  this.continuationOpen = false;
615
633
  this.log(`extension connected from ${socket.remoteAddress}:${socket.remotePort}`);
616
634
  const reader = new FrameReader();
617
635
  socket.on("data", (chunk) => {
636
+ if (this.clientSocket !== socket) {
637
+ socket.destroy();
638
+ return;
639
+ }
618
640
  reader.push(chunk);
619
641
  try {
620
642
  reader.drain((frame) => this.handleFrame(frame));
@@ -637,7 +659,7 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
637
659
  switch (frame.opcode) {
638
660
  case 1:
639
661
  case 0:
640
- this.handleText(frame.payload.toString("utf8"), frame.fin, frame.opcode);
662
+ this.handleText(frame.payload, frame.fin, frame.opcode);
641
663
  return;
642
664
  case 8:
643
665
  this.closeClientSocket(1e3, "");
@@ -648,14 +670,14 @@ Sec-WebSocket-Accept: ${accept}\r\n\r
648
670
  default: return;
649
671
  }
650
672
  }
651
- handleText(text, fin, opcode) {
673
+ handleText(payload, fin, opcode) {
652
674
  if (opcode === 0 && !this.continuationOpen) return;
653
- if (opcode === 1) this.continuationRemainder = "";
675
+ if (opcode === 1) this.continuationChunks = [];
654
676
  this.continuationOpen = opcode === 0 || !fin;
655
- this.continuationRemainder += text;
677
+ this.continuationChunks.push(payload);
656
678
  if (!fin) return;
657
- const full = this.continuationRemainder;
658
- this.continuationRemainder = "";
679
+ const full = Buffer.concat(this.continuationChunks).toString("utf8");
680
+ this.continuationChunks = [];
659
681
  this.continuationOpen = false;
660
682
  let msg;
661
683
  try {
@@ -889,9 +911,22 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
889
911
  /**
890
912
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
891
913
  * 也可以点,但别在我页面里跑任意 JS」——`browser_evaluate` 能绕开其他所有工具做的事。
914
+ *
915
+ * **按命令名判不够**:`wait` 的 `fn` 参数同样是在页面里执行任意 JS(扩展侧用
916
+ * `(${fn})()` 求出谓词),只按名字看它会从只读白名单和脚本开关两道门缝里同时漏过去。
917
+ * 所以真正判「是不是脚本」的是下面 `isScriptCall()`:命令名 + 参数一起看,
918
+ * `wait(selector)` / `wait(text)` 仍按只读处理,`wait({fn})` 与 `eval` 同档。
892
919
  */
893
920
  const SCRIPT_COMMANDS = /* @__PURE__ */ new Set(["eval"]);
894
921
  /**
922
+ * 这次调用会不会在页面里执行任意 JS。判据是「这次实际要做的动作」,不是命令名:
923
+ * `wait` 只有带 `fn` 时才升级为脚本调用(`selector` / `text` 是纯查询)。
924
+ */
925
+ function isScriptCall(command, params) {
926
+ if (SCRIPT_COMMANDS.has(command)) return true;
927
+ return command === "wait" && typeof params?.fn === "string" && params.fn.trim().length > 0;
928
+ }
929
+ /**
895
930
  * 不经过扩展、直接在插件进程里改动本机文件的命令名。
896
931
  *
897
932
  * `browser_cleanup` 走 `BridgeController.cleanup()`,**不经过 `execute()`**,所以它天然
@@ -922,7 +957,9 @@ const CONFIG_HINT = "开关在 profile 的 cordis.patch.yml 里(browser-bridge
922
957
  * @returns 拒绝时写给 agent 的整句理由;放行时 `undefined`。
923
958
  */
924
959
  function policyRefusal(command, flags, params) {
925
- if (!flags.allowScript && SCRIPT_COMMANDS.has(command)) return `在页面里执行任意脚本已被关闭(allowScript: false):命令 ${command} 被拒绝,没有向浏览器发出任何操作。要读页面内容请改用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
960
+ const scriptCall = isScriptCall(command, params);
961
+ if (!flags.allowScript && scriptCall) return `在页面里执行任意脚本已被关闭(allowScript: false):${command === "wait" ? "命令 wait 的 fn 参数(会在页面里执行任意 JS)被拒绝" : `命令 ${command} 被拒绝`},没有向浏览器发出任何操作。要等页面就绪请改用 wait 的 selector / text(只读),读页面内容用 browser_read / browser_snapshot / browser_find;确实需要执行脚本时,${CONFIG_HINT}之后再说。`;
962
+ if (flags.readOnly && command === "wait" && scriptCall) return `只读模式已开启(readOnly: true):命令 wait 的 fn 参数会在页面里执行任意 JS(可能改变页面状态),已被拒绝,没有向浏览器发出任何操作。要等页面就绪请改用 wait 的 selector / text(只读);要放开请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
926
963
  if (flags.readOnly && LOCAL_DESTRUCTIVE_COMMANDS.has(command)) return `只读模式已开启(readOnly: true):命令 ${command} 会删除本机保存的截图与临时文件,删除不可逆,所以已被拒绝,没有任何文件被删除。只读档的含义是「什么都不要改」,清理属于改动;要释放磁盘请由用户自己关掉 readOnly,${CONFIG_HINT}。不要原地重试本命令。`;
927
964
  const isReadOnlyAction = command === "hold" && params?.action === "status";
928
965
  if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
@@ -937,6 +974,46 @@ const AUDIT_EXPRESSION_MAX_CHARS = 120;
937
974
  /** 参数里这些键的值一律打码(大小写不敏感)。 */
938
975
  const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
939
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
+ /**
940
1017
  * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
941
1018
  * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
942
1019
  */
@@ -954,7 +1031,11 @@ function summarizeAuditParams(command, params) {
954
1031
  text = JSON.stringify(params, (key, value) => {
955
1032
  if (SECRET_KEY.test(key)) return "[redacted]";
956
1033
  if (key === "value" && VALUE_REDACTED_COMMANDS.has(command)) return redactValue(value);
957
- 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
+ }
958
1039
  return value;
959
1040
  }) ?? "{}";
960
1041
  } catch {
@@ -986,7 +1067,10 @@ var AuditLog = class {
986
1067
  if (input.tabId !== void 0) entry.tabId = input.tabId;
987
1068
  if (input.code !== void 0) entry.code = input.code;
988
1069
  if (input.effectState !== void 0) entry.effectState = input.effectState;
989
- 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
+ }
990
1074
  this.entries.push(entry);
991
1075
  if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
992
1076
  return entry;
@@ -1373,6 +1457,7 @@ var BridgeController = class {
1373
1457
  current;
1374
1458
  journal;
1375
1459
  journalKey = "";
1460
+ journalError;
1376
1461
  /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1377
1462
  audit = new AuditLog();
1378
1463
  /** 多 session 隔离:每个 dsh 会话的工作标签页(见 sessions.ts)。 */
@@ -1389,11 +1474,24 @@ var BridgeController = class {
1389
1474
  return this.current === void 0 ? void 0 : path.resolve(this.current.stateDir);
1390
1475
  }
1391
1476
  /**
1477
+ * Whether THIS run is persisting the journal, and why not. `browser_audit` /
1478
+ * `browser_journal` surface this: a memory-only run used to be visible only in
1479
+ * one log line, so a missing journal looked like "nothing happened".
1480
+ */
1481
+ get journalStatus() {
1482
+ if (this.journal !== void 0) return { enabled: true };
1483
+ return this.journalError === void 0 ? { enabled: false } : {
1484
+ enabled: false,
1485
+ error: this.journalError
1486
+ };
1487
+ }
1488
+ /**
1392
1489
  * Record one audit entry in memory and, when the journal is open, on disk.
1393
1490
  * Journal appends are queued and never block the command path.
1394
1491
  */
1395
1492
  recordAudit(input) {
1396
- this.journal?.append(this.audit.record(input));
1493
+ const entry = this.audit.record(input);
1494
+ this.journal?.append(entry);
1397
1495
  }
1398
1496
  /**
1399
1497
  * Current policy flags. Tools that compose several commands (browser_drag
@@ -1470,8 +1568,10 @@ var BridgeController = class {
1470
1568
  await previousJournal?.close();
1471
1569
  try {
1472
1570
  this.journal = await Journal.open(stateDir, this.log);
1571
+ this.journalError = void 0;
1473
1572
  } catch (error) {
1474
- this.log(`journal: disabled — ${errorMessage(error)}`);
1573
+ this.journalError = errorMessage(error);
1574
+ this.log(`journal: disabled — ${this.journalError}`);
1475
1575
  }
1476
1576
  }
1477
1577
  const key = config.enabled ? `${config.port}|${config.token}|${shotsDir}` : "";
@@ -1488,7 +1588,8 @@ var BridgeController = class {
1488
1588
  port: config.port,
1489
1589
  token: config.token,
1490
1590
  shotsDir,
1491
- log: this.log
1591
+ log: this.log,
1592
+ httpCommand: (command, params, options) => this.execute(command, params, new AbortController().signal, options.timeoutMs)
1492
1593
  });
1493
1594
  try {
1494
1595
  await server.start();
@@ -1560,18 +1661,35 @@ var BridgeController = class {
1560
1661
  * to work; when it is gone we forget it and let the browser's active tab
1561
1662
  * answer, exactly like before this feature existed.
1562
1663
  */
1563
- async isolateParams(command, params, sessionId, signal) {
1564
- if (typeof params.tabId === "number" || NO_TAB_DEFAULT_COMMANDS.has(command)) return params;
1664
+ /**
1665
+ * The calling session's working tab, after the same liveness probe isolateParams
1666
+ * uses. Tools that compose several commands (browser_drag) need the id *before*
1667
+ * issuing anything, so they must not fall back to the browser's active tab on
1668
+ * their own — that would silently bypass session isolation.
1669
+ */
1670
+ async sessionTab(signal) {
1671
+ const sessionId = TOOL_SESSION.getStore();
1672
+ if (sessionId === void 0) return void 0;
1673
+ return await this.liveRecordedTab(sessionId, signal);
1674
+ }
1675
+ /** Recorded working tab if it is still open; a gone tab is forgotten here. */
1676
+ async liveRecordedTab(sessionId, signal) {
1565
1677
  const recorded = this.sessions.workingTab(sessionId);
1566
- if (recorded === void 0) return params;
1678
+ if (recorded === void 0) return void 0;
1567
1679
  const server = this.server;
1568
1680
  if (server !== void 0) try {
1569
1681
  const listing = await server.execute("tabs.list", {}, { signal });
1570
1682
  if (!(Array.isArray(listing?.tabs) && listing.tabs.some((tab) => tab?.id === recorded))) {
1571
1683
  this.sessions.release(sessionId);
1572
- return params;
1684
+ return;
1573
1685
  }
1574
1686
  } catch {}
1687
+ return recorded;
1688
+ }
1689
+ async isolateParams(command, params, sessionId, signal) {
1690
+ if (typeof params.tabId === "number" || NO_TAB_DEFAULT_COMMANDS.has(command)) return params;
1691
+ const recorded = await this.liveRecordedTab(sessionId, signal);
1692
+ if (recorded === void 0) return params;
1575
1693
  return {
1576
1694
  ...params,
1577
1695
  tabId: recorded
@@ -3667,16 +3785,26 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3667
3785
  }, signal))?.nodeId ?? 0;
3668
3786
  if (nodeId === 0) throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
3669
3787
  await cdpCall(ctrl, tabId, "DOM.scrollIntoViewIfNeeded", { nodeId }, signal);
3788
+ return await measureDragNode(ctrl, tabId, nodeId, selector, signal);
3789
+ }
3790
+ /**
3791
+ * Read a node's **current** viewport geometry without scrolling. Needed after the
3792
+ * *other* endpoint's scrollIntoViewIfNeeded: that scroll can move the first
3793
+ * endpoint (and this one), so coordinates measured before it are stale. DOM
3794
+ * geometry is viewport-relative; dispatching the old numbers would drag from
3795
+ * wherever the element used to be.
3796
+ */
3797
+ async function measureDragNode(ctrl, tabId, nodeId, label, signal) {
3670
3798
  let box;
3671
3799
  try {
3672
3800
  box = await cdpCall(ctrl, tabId, "DOM.getBoxModel", { nodeId }, signal);
3673
3801
  } catch (error) {
3674
- throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3802
+ throw new Error(`cannot measure ${JSON.stringify(label)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3675
3803
  }
3676
3804
  const q = box?.model?.content;
3677
- if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
3805
+ if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(label)}`);
3678
3806
  return {
3679
- selector,
3807
+ selector: label,
3680
3808
  x: (q[0] + q[2] + q[4] + q[6]) / 4,
3681
3809
  y: (q[1] + q[3] + q[5] + q[7]) / 4,
3682
3810
  width: typeof box?.model?.width === "number" ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]),
@@ -3684,6 +3812,19 @@ async function resolveDragPoint(ctrl, tabId, target, signal) {
3684
3812
  nodeId
3685
3813
  };
3686
3814
  }
3815
+ /** CSS viewport size via Page.getLayoutMetrics (no page JS, works with allowScript off). */
3816
+ async function viewportSize(ctrl, tabId, signal) {
3817
+ try {
3818
+ const metrics = await cdpCall(ctrl, tabId, "Page.getLayoutMetrics", {}, signal);
3819
+ const viewport = metrics?.cssVisualViewport ?? metrics?.cssLayoutViewport;
3820
+ const width = Number(viewport?.clientWidth);
3821
+ const height = Number(viewport?.clientHeight);
3822
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) return {
3823
+ width,
3824
+ height
3825
+ };
3826
+ } catch {}
3827
+ }
3687
3828
  /** Whether the source declares the native HTML5 `draggable` attribute. */
3688
3829
  async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
3689
3830
  const attrs = await cdpCall(ctrl, tabId, "DOM.getAttributes", { nodeId }, signal);
@@ -3859,6 +4000,7 @@ function registerDragTool(ctx, controller) {
3859
4000
  controller.requireExtensionCommand("cdp", "1.7.2");
3860
4001
  if (controller.policy.readOnly) throw new Error("browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag");
3861
4002
  let tabId = args.tabId;
4003
+ if (tabId === void 0) tabId = await controller.sessionTab(exec.signal);
3862
4004
  if (tabId === void 0) {
3863
4005
  const listing = await controller.execute("tabs.list", {}, exec.signal);
3864
4006
  if (typeof listing?.activeTabId !== "number") throw new Error("could not resolve the active tab — pass tabId explicitly");
@@ -3871,56 +4013,92 @@ function registerDragTool(ctx, controller) {
3871
4013
  if (mode === "auto" && source?.nodeId !== void 0 && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) resolvedMode = "html5";
3872
4014
  if (resolvedMode === "mouse") {
3873
4015
  const target = await resolveDragPoint(controller, tabId, to, exec.signal);
4016
+ let sourcePoint = source;
4017
+ let targetPoint = target;
4018
+ if (source?.nodeId !== void 0 && target.nodeId !== void 0) {
4019
+ sourcePoint = await measureDragNode(controller, tabId, source.nodeId, String(from.selector), exec.signal);
4020
+ targetPoint = await measureDragNode(controller, tabId, target.nodeId, String(to.selector), exec.signal);
4021
+ }
4022
+ const viewport = await viewportSize(controller, tabId, exec.signal);
4023
+ if (viewport !== void 0) {
4024
+ const describe = (p) => `${p.selector !== void 0 ? JSON.stringify(p.selector) : "point"} at (${Math.round(p.x)},${Math.round(p.y)})`;
4025
+ const outside = (p) => p.x < 0 || p.y < 0 || p.x > viewport.width || p.y > viewport.height;
4026
+ if (outside(sourcePoint) || outside(targetPoint)) 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`);
4027
+ }
3874
4028
  const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === "number" ? args.steps : 12)));
3875
- const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
4029
+ const sx = sourcePoint.x, sy = sourcePoint.y, tx = targetPoint.x, ty = targetPoint.y;
3876
4030
  const dispatch = async (params) => {
3877
4031
  await cdpCall(controller, tabId, "Input.dispatchMouseEvent", params, exec.signal);
3878
4032
  };
3879
- await dispatch({
3880
- type: "mouseMoved",
3881
- x: sx,
3882
- y: sy,
3883
- button: "none",
3884
- buttons: 0
3885
- });
3886
- await dispatch({
3887
- type: "mousePressed",
3888
- x: sx,
3889
- y: sy,
3890
- button: "left",
3891
- buttons: 1,
3892
- clickCount: 1
3893
- });
3894
- for (let i = 1; i <= steps; i++) {
3895
- const f = i / steps;
4033
+ let pressedAt;
4034
+ try {
3896
4035
  await dispatch({
3897
4036
  type: "mouseMoved",
3898
- x: sx + (tx - sx) * f,
3899
- y: sy + (ty - sy) * f,
4037
+ x: sx,
4038
+ y: sy,
4039
+ button: "none",
4040
+ buttons: 0
4041
+ });
4042
+ await dispatch({
4043
+ type: "mousePressed",
4044
+ x: sx,
4045
+ y: sy,
3900
4046
  button: "left",
3901
- buttons: 1
4047
+ buttons: 1,
4048
+ clickCount: 1
4049
+ });
4050
+ pressedAt = {
4051
+ x: sx,
4052
+ y: sy
4053
+ };
4054
+ for (let i = 1; i <= steps; i++) {
4055
+ const f = i / steps;
4056
+ await dispatch({
4057
+ type: "mouseMoved",
4058
+ x: sx + (tx - sx) * f,
4059
+ y: sy + (ty - sy) * f,
4060
+ button: "left",
4061
+ buttons: 1
4062
+ });
4063
+ pressedAt = {
4064
+ x: sx + (tx - sx) * f,
4065
+ y: sy + (ty - sy) * f
4066
+ };
4067
+ }
4068
+ await dispatch({
4069
+ type: "mouseReleased",
4070
+ x: tx,
4071
+ y: ty,
4072
+ button: "left",
4073
+ buttons: 0,
4074
+ clickCount: 1
4075
+ });
4076
+ pressedAt = void 0;
4077
+ await dispatch({
4078
+ type: "mouseMoved",
4079
+ x: tx,
4080
+ y: ty,
4081
+ button: "none",
4082
+ buttons: 0
3902
4083
  });
4084
+ } catch (error) {
4085
+ if (pressedAt !== void 0) try {
4086
+ await cdpCall(controller, tabId, "Input.dispatchMouseEvent", {
4087
+ type: "mouseReleased",
4088
+ x: pressedAt.x,
4089
+ y: pressedAt.y,
4090
+ button: "left",
4091
+ buttons: 0,
4092
+ clickCount: 1
4093
+ }, new AbortController().signal);
4094
+ } catch {}
4095
+ throw error;
3903
4096
  }
3904
- await dispatch({
3905
- type: "mouseReleased",
3906
- x: tx,
3907
- y: ty,
3908
- button: "left",
3909
- buttons: 0,
3910
- clickCount: 1
3911
- });
3912
- await dispatch({
3913
- type: "mouseMoved",
3914
- x: tx,
3915
- y: ty,
3916
- button: "none",
3917
- buttons: 0
3918
- });
3919
4097
  return {
3920
4098
  tabId,
3921
4099
  mode: "mouse",
3922
- from: roundDragPoint(source),
3923
- to: roundDragPoint(target),
4100
+ from: roundDragPoint(sourcePoint),
4101
+ to: roundDragPoint(targetPoint),
3924
4102
  steps,
3925
4103
  dispatched: steps + 4,
3926
4104
  elapsedMs: Date.now() - started
@@ -3954,10 +4132,13 @@ function registerDragTool(ctx, controller) {
3954
4132
  }
3955
4133
  }));
3956
4134
  }
4135
+ function journalWarning(status) {
4136
+ return status.enabled ? "" : `\nJournal write: disabled${status.error === void 0 ? "" : ` — ${status.error}`} — this run is memory-only; entries are not being written to disk.`;
4137
+ }
3957
4138
  function registerAuditTool(ctx, controller) {
3958
4139
  ctx.tools.register(defineTool$1({
3959
4140
  name: "browser_audit",
3960
- description: "Read this plugin session's in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; eval expressions are truncated. Plugin-local read — works in read-only mode.",
4141
+ description: "Read this plugin session's in-memory audit trail: every command sent to the browser extension (and every policy/gate refusal), with redacted params, outcome code and duration. Newest last; capped at 200 entries and wiped when dsh restarts. Values typed into pages and cookie values are redacted; URL query values and URLs inside error text are redacted; eval expressions are truncated. The `journal` field says whether this run is also being written to disk (enabled:false + error = memory-only). Plugin-local read — works in read-only mode.",
3961
4142
  parameters: {
3962
4143
  command: {
3963
4144
  type: "string",
@@ -3989,6 +4170,17 @@ function registerAuditTool(ctx, controller) {
3989
4170
  type: "number",
3990
4171
  required: true
3991
4172
  },
4173
+ journal: {
4174
+ type: "object",
4175
+ additionalProperties: false,
4176
+ properties: {
4177
+ enabled: {
4178
+ type: "boolean",
4179
+ required: true
4180
+ },
4181
+ error: { type: "string" }
4182
+ }
4183
+ },
3992
4184
  entries: {
3993
4185
  type: "array",
3994
4186
  required: true,
@@ -4031,9 +4223,10 @@ function registerAuditTool(ctx, controller) {
4031
4223
  },
4032
4224
  render: (_args, value) => {
4033
4225
  const v = value;
4226
+ const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
4034
4227
  if (v.entries.length === 0) return [{
4035
4228
  type: "text",
4036
- text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.`
4229
+ text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.${warn}`
4037
4230
  }];
4038
4231
  const lines = v.entries.map((e) => {
4039
4232
  const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
@@ -4043,7 +4236,7 @@ function registerAuditTool(ctx, controller) {
4043
4236
  });
4044
4237
  return [{
4045
4238
  type: "text",
4046
- text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
4239
+ text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}${warn}`)
4047
4240
  }];
4048
4241
  }
4049
4242
  },
@@ -4062,7 +4255,8 @@ function registerAuditTool(ctx, controller) {
4062
4255
  return {
4063
4256
  total: controller.audit.size(),
4064
4257
  shown: entries.length,
4065
- entries
4258
+ entries,
4259
+ journal: controller.journalStatus
4066
4260
  };
4067
4261
  }
4068
4262
  }));
@@ -4070,7 +4264,7 @@ function registerAuditTool(ctx, controller) {
4070
4264
  function registerJournalTool(ctx, controller) {
4071
4265
  ctx.tools.register(defineTool$1({
4072
4266
  name: "browser_journal",
4073
- description: "Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover \"what was I doing\" after a restart.",
4267
+ description: "Read the persistent command journal across dsh restarts: every command from previous runs of this plugin (written to <stateDir>/journal.jsonl, last 1000 entries), grouped by run. Same redaction as browser_audit. The `journal` field says whether the CURRENT run is writing (enabled:false + error = this run is memory-only; what you see is from earlier runs). Plugin-local disk read — works with the extension offline and in read-only mode, so it is the way to recover \"what was I doing\" after a restart.",
4074
4268
  parameters: {
4075
4269
  command: {
4076
4270
  type: "string",
@@ -4102,6 +4296,17 @@ function registerJournalTool(ctx, controller) {
4102
4296
  type: "number",
4103
4297
  required: true
4104
4298
  },
4299
+ journal: {
4300
+ type: "object",
4301
+ additionalProperties: false,
4302
+ properties: {
4303
+ enabled: {
4304
+ type: "boolean",
4305
+ required: true
4306
+ },
4307
+ error: { type: "string" }
4308
+ }
4309
+ },
4105
4310
  entries: {
4106
4311
  type: "array",
4107
4312
  required: true,
@@ -4148,9 +4353,10 @@ function registerJournalTool(ctx, controller) {
4148
4353
  },
4149
4354
  render: (_args, value) => {
4150
4355
  const v = value;
4356
+ const warn = v.journal === void 0 ? "" : journalWarning(v.journal);
4151
4357
  if (v.entries.length === 0) return [{
4152
4358
  type: "text",
4153
- text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.`
4359
+ text: `Journal: ${v.total} entries on disk, none match — nothing recorded across runs yet.${warn}`
4154
4360
  }];
4155
4361
  const lines = [];
4156
4362
  let run = "";
@@ -4165,7 +4371,7 @@ function registerJournalTool(ctx, controller) {
4165
4371
  }
4166
4372
  return [{
4167
4373
  type: "text",
4168
- text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}`)
4374
+ text: clampRendered(`Journal: ${v.total} entries in ${v.runs} run(s) (showing ${v.shown})\n${lines.join("\n")}${warn}`)
4169
4375
  }];
4170
4376
  }
4171
4377
  },
@@ -4186,7 +4392,8 @@ function registerJournalTool(ctx, controller) {
4186
4392
  total,
4187
4393
  shown: entries.length,
4188
4394
  runs: new Set(entries.map((e) => e.run)).size,
4189
- entries
4395
+ entries,
4396
+ journal: controller.journalStatus
4190
4397
  };
4191
4398
  }
4192
4399
  }));
@@ -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 文本——审计工具原样把它交给模型。