@angelky7/dsh-browser-control 1.7.2 → 1.7.4

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
@@ -926,6 +926,92 @@ function policyRefusal(command, flags, params) {
926
926
  if (flags.readOnly && PAGE_MUTATING_COMMANDS.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会往页面里插入一层接管浮层(改页面 DOM),已被拒绝,没有向浏览器发出任何操作。浮层是给用户看的提示层,不是安全边界;只读档里连这种「只是提示」的改动也一并拒绝。要开浮层请由用户关掉 readOnly,${CONFIG_HINT}不要原地重试本命令。`;
927
927
  if (flags.readOnly && !READ_ONLY_ALLOWED.has(command) && !isReadOnlyAction) return `只读模式已开启(readOnly: true):命令 ${command} 会改变页面或浏览器状态,已被拒绝,没有向浏览器发出任何操作。只读模式下可用的工具是读页面与读日志那几类;要放开写操作,${CONFIG_HINT}不要原地重试本命令。`;
928
928
  }
929
+ /** 单条参数摘要上限:审计要的是形状,不是全文。 */
930
+ const AUDIT_PARAMS_MAX_CHARS = 300;
931
+ /** 失败描述上限。 */
932
+ const AUDIT_ERROR_MAX_CHARS = 200;
933
+ /** eval 表达式只留前 120 字符:里面可能有凭据。 */
934
+ const AUDIT_EXPRESSION_MAX_CHARS = 120;
935
+ /** 参数里这些键的值一律打码(大小写不敏感)。 */
936
+ const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
937
+ /**
938
+ * 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
939
+ * `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
940
+ */
941
+ const VALUE_REDACTED_COMMANDS = /* @__PURE__ */ new Set(["input", "cookies.set"]);
942
+ function redactValue(value) {
943
+ return `[redacted:${typeof value === "string" ? [...value].length : 0} chars]`;
944
+ }
945
+ /**
946
+ * 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
947
+ * 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
948
+ */
949
+ function summarizeAuditParams(command, params) {
950
+ let text;
951
+ try {
952
+ text = JSON.stringify(params, (key, value) => {
953
+ if (SECRET_KEY.test(key)) return "[redacted]";
954
+ if (key === "value" && VALUE_REDACTED_COMMANDS.has(command)) return redactValue(value);
955
+ 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]`;
956
+ return value;
957
+ }) ?? "{}";
958
+ } catch {
959
+ text = "[unserializable params]";
960
+ }
961
+ return [...text].length > AUDIT_PARAMS_MAX_CHARS ? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join("")}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]` : text;
962
+ }
963
+ /** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
964
+ var AuditLog = class {
965
+ entries = [];
966
+ nextSeq = 1;
967
+ now;
968
+ constructor(now = Date.now) {
969
+ this.now = now;
970
+ }
971
+ /**
972
+ * 记一笔。
973
+ * @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
974
+ */
975
+ record(input) {
976
+ const entry = {
977
+ seq: this.nextSeq++,
978
+ t: this.now(),
979
+ command: input.command,
980
+ params: input.params,
981
+ ok: input.ok,
982
+ durationMs: Math.max(0, Math.round(input.durationMs))
983
+ };
984
+ if (input.tabId !== void 0) entry.tabId = input.tabId;
985
+ if (input.code !== void 0) entry.code = input.code;
986
+ if (input.effectState !== void 0) entry.effectState = input.effectState;
987
+ 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;
988
+ this.entries.push(entry);
989
+ if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
990
+ return entry;
991
+ }
992
+ /** 现有条目数(被挤掉的不算)。 */
993
+ size() {
994
+ return this.entries.length;
995
+ }
996
+ /**
997
+ * 过滤并取最新的一批。
998
+ * @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
999
+ */
1000
+ list(filter = {}) {
1001
+ let matched = this.entries;
1002
+ if (filter.tabId !== void 0) matched = matched.filter((e) => e.tabId === filter.tabId);
1003
+ if (filter.command !== void 0 && filter.command.length > 0) {
1004
+ const needle = filter.command.toLowerCase();
1005
+ matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
1006
+ }
1007
+ if (filter.sinceSeq !== void 0) {
1008
+ const floor = filter.sinceSeq;
1009
+ matched = matched.filter((e) => e.seq > floor);
1010
+ }
1011
+ const limit = Math.min(200, Math.max(1, Math.trunc(filter.limit ?? 50)));
1012
+ return matched.slice(Math.max(0, matched.length - limit));
1013
+ }
1014
+ };
929
1015
  //#endregion
930
1016
  //#region lib/types/index.js
931
1017
  /**
@@ -1054,6 +1140,8 @@ var BridgeController = class {
1054
1140
  lastError;
1055
1141
  chain = Promise.resolve();
1056
1142
  current;
1143
+ /** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
1144
+ audit = new AuditLog();
1057
1145
  constructor(log) {
1058
1146
  this.log = log;
1059
1147
  }
@@ -1061,6 +1149,17 @@ var BridgeController = class {
1061
1149
  get shotsDir() {
1062
1150
  return this.current === void 0 ? void 0 : path.resolve(this.current.shotsDir);
1063
1151
  }
1152
+ /**
1153
+ * Current policy flags. Tools that compose several commands (browser_drag
1154
+ * runs `cdp`/`eval`) read this to refuse with a tool-level message; the
1155
+ * per-command gate in execute() stays as the enforcement backstop.
1156
+ */
1157
+ get policy() {
1158
+ return {
1159
+ readOnly: this.current?.readOnly === true,
1160
+ allowScript: this.current?.allowScript !== false
1161
+ };
1162
+ }
1064
1163
  /** Version the connected extension reported in its hello frame, if any. */
1065
1164
  extensionVersion() {
1066
1165
  return this.server?.status.hello?.version;
@@ -1087,6 +1186,15 @@ var BridgeController = class {
1087
1186
  */
1088
1187
  requireExtensionCommand(command, since) {
1089
1188
  if (this.hasExtensionCommand(command)) return;
1189
+ this.audit.record({
1190
+ command,
1191
+ params: "",
1192
+ ok: false,
1193
+ code: "extension_outdated",
1194
+ effectState: "not_started",
1195
+ error: `connected extension v${this.extensionVersion() ?? "?"} lacks ${command} (added in ${since})`,
1196
+ durationMs: 0
1197
+ });
1090
1198
  throw extensionOutdatedError(this.extensionVersion(), command, since);
1091
1199
  }
1092
1200
  /**
@@ -1148,13 +1256,40 @@ var BridgeController = class {
1148
1256
  * @returns the extension's result payload verbatim.
1149
1257
  */
1150
1258
  async execute(command, params, signal, timeoutMs) {
1151
- this.policyGate(command, params);
1152
- const server = this.server;
1153
- if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1154
- return server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1155
- signal,
1156
- timeoutMs
1157
- });
1259
+ const started = Date.now();
1260
+ const summary = summarizeAuditParams(command, params);
1261
+ const tabId = typeof params.tabId === "number" ? params.tabId : void 0;
1262
+ try {
1263
+ this.policyGate(command, params);
1264
+ const server = this.server;
1265
+ if (server === void 0) throw classify(command, new Error(this.lastError ?? "浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)"));
1266
+ const result = await server.execute(command, params, timeoutMs === void 0 ? { signal } : {
1267
+ signal,
1268
+ timeoutMs
1269
+ });
1270
+ this.audit.record({
1271
+ command,
1272
+ tabId,
1273
+ params: summary,
1274
+ ok: true,
1275
+ durationMs: Date.now() - started
1276
+ });
1277
+ return result;
1278
+ } catch (error) {
1279
+ const code = error.code;
1280
+ const effectState = error.effectState;
1281
+ this.audit.record({
1282
+ command,
1283
+ tabId,
1284
+ params: summary,
1285
+ ok: false,
1286
+ code: typeof code === "string" ? code : void 0,
1287
+ effectState: typeof effectState === "string" ? effectState : void 0,
1288
+ error: errorMessage(error),
1289
+ durationMs: Date.now() - started
1290
+ });
1291
+ throw error;
1292
+ }
1158
1293
  }
1159
1294
  /**
1160
1295
  * Refuse a command the local policy forbids, before anything is sent or deleted.
@@ -1179,13 +1314,36 @@ var BridgeController = class {
1179
1314
  * @returns counts and names of what was removed.
1180
1315
  */
1181
1316
  async cleanup() {
1182
- this.policyGate("cleanup");
1183
- const dir = this.shotsDir;
1184
- if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1317
+ const started = Date.now();
1185
1318
  try {
1186
- return await cleanupArtifacts({ shotsDir: dir });
1319
+ this.policyGate("cleanup");
1320
+ const dir = this.shotsDir;
1321
+ if (dir === void 0) throw classify("cleanup", /* @__PURE__ */ new Error("browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录"));
1322
+ try {
1323
+ const result = await cleanupArtifacts({ shotsDir: dir });
1324
+ this.audit.record({
1325
+ command: "cleanup",
1326
+ params: "{}",
1327
+ ok: true,
1328
+ durationMs: Date.now() - started
1329
+ });
1330
+ return result;
1331
+ } catch (error) {
1332
+ throw classify("cleanup", error);
1333
+ }
1187
1334
  } catch (error) {
1188
- throw classify("cleanup", error);
1335
+ const code = error.code;
1336
+ const effectState = error.effectState;
1337
+ this.audit.record({
1338
+ command: "cleanup",
1339
+ params: "{}",
1340
+ ok: false,
1341
+ code: typeof code === "string" ? code : void 0,
1342
+ effectState: typeof effectState === "string" ? effectState : void 0,
1343
+ error: errorMessage(error),
1344
+ durationMs: Date.now() - started
1345
+ });
1346
+ throw error;
1189
1347
  }
1190
1348
  }
1191
1349
  /** Stop the listener; safe to call repeatedly and during teardown. */
@@ -3135,6 +3293,448 @@ function applyBrowserTools(ctx, controller) {
3135
3293
  return await controller.execute("metrics", params, exec.signal);
3136
3294
  }
3137
3295
  }));
3296
+ registerDragTool(ctx, controller);
3297
+ registerAuditTool(ctx, controller);
3298
+ }
3299
+ /** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
3300
+ async function cdpCall(ctrl, tabId, method, params, signal) {
3301
+ return await ctrl.execute("cdp", {
3302
+ method,
3303
+ params,
3304
+ tabId
3305
+ }, signal);
3306
+ }
3307
+ function dragTargetArgs(selector, x, y, side) {
3308
+ const hasSel = typeof selector === "string" && selector.length > 0;
3309
+ const hasX = typeof x === "number" && Number.isFinite(x);
3310
+ const hasY = typeof y === "number" && Number.isFinite(y);
3311
+ if (hasSel && (hasX || hasY)) throw new Error(`${side}: give either "${side}" (CSS selector) or "${side}X"/"${side}Y" coordinates, not both`);
3312
+ if (!hasSel && !(hasX && hasY)) throw new Error(`${side}: needs a CSS selector ("${side}") or both "${side}X" and "${side}Y" coordinates`);
3313
+ if (hasSel) return { selector };
3314
+ return {
3315
+ x,
3316
+ y
3317
+ };
3318
+ }
3319
+ /**
3320
+ * Resolve a drag endpoint. Selector endpoints become viewport coordinates via
3321
+ * the CDP DOM domain — no page JavaScript, so mouse mode also works with
3322
+ * `allowScript: false`. Top document only; cross-origin iframes are a later
3323
+ * iteration.
3324
+ */
3325
+ async function resolveDragPoint(ctrl, tabId, target, signal) {
3326
+ if (target.selector === void 0) return {
3327
+ x: target.x,
3328
+ y: target.y
3329
+ };
3330
+ const selector = target.selector;
3331
+ const rootId = (await cdpCall(ctrl, tabId, "DOM.getDocument", { depth: 0 }, signal))?.root?.nodeId;
3332
+ if (typeof rootId !== "number") throw new Error("DOM.getDocument returned no root node");
3333
+ const nodeId = (await cdpCall(ctrl, tabId, "DOM.querySelector", {
3334
+ nodeId: rootId,
3335
+ selector
3336
+ }, signal))?.nodeId ?? 0;
3337
+ if (nodeId === 0) throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
3338
+ await cdpCall(ctrl, tabId, "DOM.scrollIntoViewIfNeeded", { nodeId }, signal);
3339
+ let box;
3340
+ try {
3341
+ box = await cdpCall(ctrl, tabId, "DOM.getBoxModel", { nodeId }, signal);
3342
+ } catch (error) {
3343
+ throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
3344
+ }
3345
+ const q = box?.model?.content;
3346
+ if (!Array.isArray(q) || q.length < 8) throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
3347
+ return {
3348
+ selector,
3349
+ x: (q[0] + q[2] + q[4] + q[6]) / 4,
3350
+ y: (q[1] + q[3] + q[5] + q[7]) / 4,
3351
+ width: typeof box?.model?.width === "number" ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]),
3352
+ height: typeof box?.model?.height === "number" ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]),
3353
+ nodeId
3354
+ };
3355
+ }
3356
+ /** Whether the source declares the native HTML5 `draggable` attribute. */
3357
+ async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
3358
+ const attrs = await cdpCall(ctrl, tabId, "DOM.getAttributes", { nodeId }, signal);
3359
+ const list = Array.isArray(attrs?.attributes) ? attrs.attributes.map(String) : [];
3360
+ const at = list.indexOf("draggable");
3361
+ return at >= 0 && list[at + 1] === "true";
3362
+ }
3363
+ /**
3364
+ * Synthesize a native-order HTML5 drag in the page: dragstart on the source,
3365
+ * dragenter/dragover on the target, drop only when dragover was prevented
3366
+ * (the browser's own rule), dragend on the source, all sharing one DataTransfer.
3367
+ * Untrusted events — pages that gate on `event.isTrusted` will ignore them,
3368
+ * which the tool description states instead of pretending otherwise.
3369
+ */
3370
+ function html5DragExpression(fromSelector, to) {
3371
+ const toExpr = to.selector !== void 0 ? `document.querySelector(${JSON.stringify(to.selector)})` : `document.elementFromPoint(${to.x}, ${to.y})`;
3372
+ return `(() => {
3373
+ const src = document.querySelector(${JSON.stringify(fromSelector)});
3374
+ if (!src) return { error: 'no element matches from selector ' + ${JSON.stringify(fromSelector)} };
3375
+ const dst = ${toExpr};
3376
+ if (!dst) return { error: 'target element not found' };
3377
+ const dt = new DataTransfer();
3378
+ const log = [];
3379
+ const sr = src.getBoundingClientRect();
3380
+ const dr = dst.getBoundingClientRect();
3381
+ const sx = sr.left + sr.width / 2, sy = sr.top + sr.height / 2;
3382
+ const tx = dr.left + dr.width / 2, ty = dr.top + dr.height / 2;
3383
+ const fire = (type, target, cx, cy) => {
3384
+ const ev = new DragEvent(type, { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: cx, clientY: cy });
3385
+ target.dispatchEvent(ev);
3386
+ log.push({ type, defaultPrevented: ev.defaultPrevented });
3387
+ return ev.defaultPrevented;
3388
+ };
3389
+ const startCancelled = fire('dragstart', src, sx, sy);
3390
+ let dragenterDefaultPrevented = false, overPrevented = false, dropFired = false, dropPrevented = false;
3391
+ if (!startCancelled) {
3392
+ dragenterDefaultPrevented = fire('dragenter', dst, tx, ty);
3393
+ overPrevented = fire('dragover', dst, tx, ty);
3394
+ if (overPrevented) {
3395
+ dropFired = true;
3396
+ dropPrevented = fire('drop', dst, tx, ty);
3397
+ }
3398
+ }
3399
+ fire('dragend', src, sx, sy);
3400
+ return { startCancelled, dragenterDefaultPrevented, overPrevented, dropFired, dropPrevented, events: log.length, log };
3401
+ })()`;
3402
+ }
3403
+ function roundDragPoint(p) {
3404
+ const out = {
3405
+ x: Math.round(p.x * 100) / 100,
3406
+ y: Math.round(p.y * 100) / 100
3407
+ };
3408
+ if (p.selector !== void 0) out.selector = p.selector;
3409
+ if (p.width !== void 0) out.width = Math.round(p.width * 100) / 100;
3410
+ if (p.height !== void 0) out.height = Math.round(p.height * 100) / 100;
3411
+ return out;
3412
+ }
3413
+ function registerDragTool(ctx, controller) {
3414
+ ctx.tools.register(defineTool({
3415
+ name: "browser_drag",
3416
+ description: "Drag a page element or point onto another with real input. mode \"mouse\" sends trusted press→moves→release input via CDP (canvas, sliders, sortable widgets); mode \"html5\" synthesizes dragstart/dragover/drop in the page for [draggable] elements and needs page scripting; \"auto\" (default) picks html5 only when the source declares draggable=\"true\". Endpoints are CSS selectors (top document) or fromX/fromY + toX/toY viewport coordinates. Needs browser extension v1.7.2+ (embedded cdp channel).",
3417
+ parameters: {
3418
+ from: {
3419
+ type: "string",
3420
+ description: "CSS selector of the drag source; use fromX/fromY instead for raw coordinates."
3421
+ },
3422
+ to: {
3423
+ type: "string",
3424
+ description: "CSS selector of the drop target; use toX/toY instead for raw coordinates."
3425
+ },
3426
+ fromX: {
3427
+ type: "number",
3428
+ description: "Viewport x of the drag start point (with fromY)."
3429
+ },
3430
+ fromY: {
3431
+ type: "number",
3432
+ description: "Viewport y of the drag start point (with fromX)."
3433
+ },
3434
+ toX: {
3435
+ type: "number",
3436
+ description: "Viewport x of the drop point (with toY)."
3437
+ },
3438
+ toY: {
3439
+ type: "number",
3440
+ description: "Viewport y of the drop point (with toX)."
3441
+ },
3442
+ tabId: {
3443
+ type: "number",
3444
+ description: "Target tab; defaults to the active tab."
3445
+ },
3446
+ mode: {
3447
+ type: "string",
3448
+ description: "\"auto\" (default), \"mouse\" or \"html5\" — see the tool description."
3449
+ },
3450
+ steps: {
3451
+ type: "number",
3452
+ description: "Intermediate mouse moves in mouse mode, 1–50 (default 12)."
3453
+ }
3454
+ },
3455
+ output: {
3456
+ schema: {
3457
+ type: "object",
3458
+ additionalProperties: false,
3459
+ properties: {
3460
+ tabId: {
3461
+ type: "number",
3462
+ required: true
3463
+ },
3464
+ mode: {
3465
+ type: "string",
3466
+ required: true
3467
+ },
3468
+ from: {
3469
+ type: "object",
3470
+ additionalProperties: false,
3471
+ properties: {
3472
+ selector: { type: "string" },
3473
+ x: { type: "number" },
3474
+ y: { type: "number" },
3475
+ width: { type: "number" },
3476
+ height: { type: "number" }
3477
+ }
3478
+ },
3479
+ to: {
3480
+ type: "object",
3481
+ additionalProperties: false,
3482
+ properties: {
3483
+ selector: { type: "string" },
3484
+ x: { type: "number" },
3485
+ y: { type: "number" },
3486
+ width: { type: "number" },
3487
+ height: { type: "number" }
3488
+ }
3489
+ },
3490
+ steps: { type: "number" },
3491
+ dispatched: { type: "number" },
3492
+ startCancelled: { type: "boolean" },
3493
+ dragenterDefaultPrevented: { type: "boolean" },
3494
+ overPrevented: { type: "boolean" },
3495
+ dropFired: { type: "boolean" },
3496
+ dropPrevented: { type: "boolean" },
3497
+ events: { type: "number" },
3498
+ elapsedMs: {
3499
+ type: "number",
3500
+ required: true
3501
+ }
3502
+ }
3503
+ },
3504
+ render: (_args, value) => {
3505
+ const v = value;
3506
+ const d = (p) => p?.selector !== void 0 ? p.selector : `(${p?.x ?? "?"},${p?.y ?? "?"})`;
3507
+ if (v.mode === "mouse") return [{
3508
+ type: "text",
3509
+ text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with mouse input (${v.steps} steps, ${v.dispatched} dispatches, ${v.elapsedMs}ms)`
3510
+ }];
3511
+ const verdict = v.startCancelled === true ? "dragstart was cancelled by the page — nothing dropped" : v.overPrevented === true ? v.dropPrevented === true ? "drop fired and was handled" : "drop fired but no drop handler ran" : "target did not accept the drop (no dragover preventDefault)";
3512
+ return [{
3513
+ type: "text",
3514
+ text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with html5 events (${v.events} events, ${v.elapsedMs}ms) — ${verdict}`
3515
+ }];
3516
+ }
3517
+ },
3518
+ presentCall: (args) => ({
3519
+ card: "generic",
3520
+ title: `Drag ${args.from ?? `(${args.fromX},${args.fromY})`} → ${args.to ?? `(${args.toX},${args.toY})`}`,
3521
+ kind: "other"
3522
+ }),
3523
+ async execute(args, exec) {
3524
+ const mode = args.mode === void 0 ? "auto" : args.mode;
3525
+ if (mode !== "auto" && mode !== "mouse" && mode !== "html5") throw new Error(`mode must be "auto", "mouse" or "html5" — got ${JSON.stringify(mode)}`);
3526
+ const from = dragTargetArgs(args.from, args.fromX, args.fromY, "from");
3527
+ const to = dragTargetArgs(args.to, args.toX, args.toY, "to");
3528
+ controller.requireExtensionCommand("cdp", "1.7.2");
3529
+ 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");
3530
+ let tabId = args.tabId;
3531
+ if (tabId === void 0) {
3532
+ const listing = await controller.execute("tabs.list", {}, exec.signal);
3533
+ if (typeof listing?.activeTabId !== "number") throw new Error("could not resolve the active tab — pass tabId explicitly");
3534
+ tabId = listing.activeTabId;
3535
+ }
3536
+ const started = Date.now();
3537
+ let resolvedMode = mode === "auto" ? "mouse" : mode;
3538
+ let source;
3539
+ if (mode !== "html5") source = await resolveDragPoint(controller, tabId, from, exec.signal);
3540
+ if (mode === "auto" && source?.nodeId !== void 0 && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) resolvedMode = "html5";
3541
+ if (resolvedMode === "mouse") {
3542
+ const target = await resolveDragPoint(controller, tabId, to, exec.signal);
3543
+ const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === "number" ? args.steps : 12)));
3544
+ const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
3545
+ const dispatch = async (params) => {
3546
+ await cdpCall(controller, tabId, "Input.dispatchMouseEvent", params, exec.signal);
3547
+ };
3548
+ await dispatch({
3549
+ type: "mouseMoved",
3550
+ x: sx,
3551
+ y: sy,
3552
+ button: "none",
3553
+ buttons: 0
3554
+ });
3555
+ await dispatch({
3556
+ type: "mousePressed",
3557
+ x: sx,
3558
+ y: sy,
3559
+ button: "left",
3560
+ buttons: 1,
3561
+ clickCount: 1
3562
+ });
3563
+ for (let i = 1; i <= steps; i++) {
3564
+ const f = i / steps;
3565
+ await dispatch({
3566
+ type: "mouseMoved",
3567
+ x: sx + (tx - sx) * f,
3568
+ y: sy + (ty - sy) * f,
3569
+ button: "left",
3570
+ buttons: 1
3571
+ });
3572
+ }
3573
+ await dispatch({
3574
+ type: "mouseReleased",
3575
+ x: tx,
3576
+ y: ty,
3577
+ button: "left",
3578
+ buttons: 0,
3579
+ clickCount: 1
3580
+ });
3581
+ await dispatch({
3582
+ type: "mouseMoved",
3583
+ x: tx,
3584
+ y: ty,
3585
+ button: "none",
3586
+ buttons: 0
3587
+ });
3588
+ return {
3589
+ tabId,
3590
+ mode: "mouse",
3591
+ from: roundDragPoint(source),
3592
+ to: roundDragPoint(target),
3593
+ steps,
3594
+ dispatched: steps + 4,
3595
+ elapsedMs: Date.now() - started
3596
+ };
3597
+ }
3598
+ if (from.selector === void 0) throw new Error("html5 mode needs a CSS selector for \"from\" (a point has no draggable element)");
3599
+ if (!controller.policy.allowScript) throw new Error("html5 drag synthesizes DOM events and needs page scripting — allowScript is off; use mode:\"mouse\" or re-enable the script switch");
3600
+ const raw = await controller.execute("eval", {
3601
+ expression: html5DragExpression(from.selector, to),
3602
+ tabId
3603
+ }, exec.signal);
3604
+ const outcome = raw?.value;
3605
+ if (outcome === void 0 || typeof outcome !== "object") throw new Error("html5 drag expression returned no outcome — page may have navigated mid-drag");
3606
+ if (typeof outcome.error === "string") throw new Error(outcome.error);
3607
+ return {
3608
+ tabId: typeof raw?.tabId === "number" ? raw.tabId : tabId,
3609
+ mode: "html5",
3610
+ from: from.selector !== void 0 ? { selector: from.selector } : roundDragPoint(from),
3611
+ to: to.selector !== void 0 ? { selector: to.selector } : {
3612
+ x: to.x,
3613
+ y: to.y
3614
+ },
3615
+ startCancelled: outcome.startCancelled === true,
3616
+ dragenterDefaultPrevented: outcome.dragenterDefaultPrevented === true,
3617
+ overPrevented: outcome.overPrevented === true,
3618
+ dropFired: outcome.dropFired === true,
3619
+ dropPrevented: outcome.dropPrevented === true,
3620
+ events: typeof outcome.events === "number" ? outcome.events : 0,
3621
+ elapsedMs: Date.now() - started
3622
+ };
3623
+ }
3624
+ }));
3625
+ }
3626
+ function registerAuditTool(ctx, controller) {
3627
+ ctx.tools.register(defineTool({
3628
+ name: "browser_audit",
3629
+ 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.",
3630
+ parameters: {
3631
+ command: {
3632
+ type: "string",
3633
+ description: "Only entries whose command contains this substring (e.g. \"nav\", \"cookies\", \"tabs\")."
3634
+ },
3635
+ tabId: {
3636
+ type: "number",
3637
+ description: "Only entries that carried this tabId."
3638
+ },
3639
+ sinceSeq: {
3640
+ type: "number",
3641
+ description: "Only entries with seq greater than this (paging)."
3642
+ },
3643
+ limit: {
3644
+ type: "number",
3645
+ description: `Max entries to return (newest last), 1–200; default 50.`
3646
+ }
3647
+ },
3648
+ output: {
3649
+ schema: {
3650
+ type: "object",
3651
+ additionalProperties: false,
3652
+ properties: {
3653
+ total: {
3654
+ type: "number",
3655
+ required: true
3656
+ },
3657
+ shown: {
3658
+ type: "number",
3659
+ required: true
3660
+ },
3661
+ entries: {
3662
+ type: "array",
3663
+ required: true,
3664
+ items: {
3665
+ type: "object",
3666
+ additionalProperties: false,
3667
+ properties: {
3668
+ seq: {
3669
+ type: "number",
3670
+ required: true
3671
+ },
3672
+ t: {
3673
+ type: "number",
3674
+ required: true
3675
+ },
3676
+ command: {
3677
+ type: "string",
3678
+ required: true
3679
+ },
3680
+ tabId: { type: "number" },
3681
+ params: {
3682
+ type: "string",
3683
+ required: true
3684
+ },
3685
+ ok: {
3686
+ type: "boolean",
3687
+ required: true
3688
+ },
3689
+ code: { type: "string" },
3690
+ effectState: { type: "string" },
3691
+ error: { type: "string" },
3692
+ durationMs: {
3693
+ type: "number",
3694
+ required: true
3695
+ }
3696
+ }
3697
+ }
3698
+ }
3699
+ }
3700
+ },
3701
+ render: (_args, value) => {
3702
+ const v = value;
3703
+ if (v.entries.length === 0) return [{
3704
+ type: "text",
3705
+ text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.`
3706
+ }];
3707
+ const lines = v.entries.map((e) => {
3708
+ const tab = e.tabId === void 0 ? "" : ` tab ${e.tabId}`;
3709
+ const outcome = e.ok ? "ok" : `FAIL ${e.code ?? "error"}${e.effectState ? `/${e.effectState}` : ""}`;
3710
+ const err = e.ok || e.error === void 0 ? "" : ` — ${e.error}`;
3711
+ return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
3712
+ });
3713
+ return [{
3714
+ type: "text",
3715
+ text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join("\n")}`)
3716
+ }];
3717
+ }
3718
+ },
3719
+ presentCall: () => ({
3720
+ card: "generic",
3721
+ title: "Browser audit",
3722
+ kind: "other"
3723
+ }),
3724
+ async execute(args) {
3725
+ const entries = controller.audit.list({
3726
+ tabId: args.tabId,
3727
+ command: args.command,
3728
+ sinceSeq: args.sinceSeq,
3729
+ limit: args.limit
3730
+ });
3731
+ return {
3732
+ total: controller.audit.size(),
3733
+ shown: entries.length,
3734
+ entries
3735
+ };
3736
+ }
3737
+ }));
3138
3738
  }
3139
3739
  /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
3140
3740
  function apply(ctx, config) {