@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/README.en.md +6 -4
- package/README.md +7 -5
- package/lib/index.js +612 -12
- package/lib/types/audit.d.ts +72 -0
- package/lib/types/audit.js +107 -0
- package/lib/types/index.js +409 -15
- package/llms.txt +1 -1
- package/package.json +2 -2
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
|
-
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|