@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 操作审计(v1.7.4):插件这一侧到底发过哪些命令、被拒了几次、各自用了多久。
|
|
3
|
+
*
|
|
4
|
+
* 存在的理由不是「日志」,而是回答两个现实问题:出事后「刚才谁动了这个页面」,
|
|
5
|
+
* 以及做「会话恢复」之前先得有一份可靠的行为底账。所以:
|
|
6
|
+
*
|
|
7
|
+
* - **记录在命令层**(controller.execute),不是工具层:模型看不到的内部重试、
|
|
8
|
+
* browser_drag 一次拖拽打出的 N 次 cdp 往返,都在账上,不美化。
|
|
9
|
+
* - **只存内存**(环形缓冲,200 条):跨 dsh 重启的持久化是「会话恢复」那一版的事;
|
|
10
|
+
* 这一版先把账记对,别把磁盘格式写死。
|
|
11
|
+
* - **参数摘要是脱敏后的**:输入框的值、cookie 的值、令牌类字段一律打码;
|
|
12
|
+
* 表达式截断。审计的用途是「做了什么」,不是「内容是什么」。
|
|
13
|
+
*/
|
|
14
|
+
export interface AuditEntry {
|
|
15
|
+
seq: number;
|
|
16
|
+
/** epoch 毫秒 */
|
|
17
|
+
t: number;
|
|
18
|
+
command: string;
|
|
19
|
+
tabId?: number;
|
|
20
|
+
/** 脱敏后的参数摘要(JSON 文本,已截断) */
|
|
21
|
+
params: string;
|
|
22
|
+
ok: boolean;
|
|
23
|
+
code?: string;
|
|
24
|
+
effectState?: string;
|
|
25
|
+
/** 失败描述,截断 */
|
|
26
|
+
error?: string;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AuditFilter {
|
|
30
|
+
tabId?: number;
|
|
31
|
+
/** 命令名子串匹配(如 "nav" 命中 nav / browser.info? 不需要——命令名如 tabs.open) */
|
|
32
|
+
command?: string;
|
|
33
|
+
/** 只返回 seq 严格大于它的条目(分页) */
|
|
34
|
+
sinceSeq?: number;
|
|
35
|
+
/** 最多返回多少条(取最新的 N 条,按 seq 升序给出) */
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
38
|
+
/** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
|
|
39
|
+
export declare const AUDIT_MAX_ENTRIES = 200;
|
|
40
|
+
/**
|
|
41
|
+
* 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
|
|
42
|
+
* 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
|
|
43
|
+
*/
|
|
44
|
+
export declare function summarizeAuditParams(command: string, params: Record<string, unknown>): string;
|
|
45
|
+
/** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
|
|
46
|
+
export declare class AuditLog {
|
|
47
|
+
private readonly entries;
|
|
48
|
+
private nextSeq;
|
|
49
|
+
private readonly now;
|
|
50
|
+
constructor(now?: () => number);
|
|
51
|
+
/**
|
|
52
|
+
* 记一笔。
|
|
53
|
+
* @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
|
|
54
|
+
*/
|
|
55
|
+
record(input: {
|
|
56
|
+
command: string;
|
|
57
|
+
tabId?: number;
|
|
58
|
+
params: string;
|
|
59
|
+
ok: boolean;
|
|
60
|
+
code?: string;
|
|
61
|
+
effectState?: string;
|
|
62
|
+
error?: string;
|
|
63
|
+
durationMs: number;
|
|
64
|
+
}): AuditEntry;
|
|
65
|
+
/** 现有条目数(被挤掉的不算)。 */
|
|
66
|
+
size(): number;
|
|
67
|
+
/**
|
|
68
|
+
* 过滤并取最新的一批。
|
|
69
|
+
* @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
|
|
70
|
+
*/
|
|
71
|
+
list(filter?: AuditFilter): AuditEntry[];
|
|
72
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** 环形缓冲上限。200 条足够覆盖一次排障现场,又不至于让内存跟着会话长度涨。 */
|
|
2
|
+
export const AUDIT_MAX_ENTRIES = 200;
|
|
3
|
+
/** 单条参数摘要上限:审计要的是形状,不是全文。 */
|
|
4
|
+
const AUDIT_PARAMS_MAX_CHARS = 300;
|
|
5
|
+
/** 失败描述上限。 */
|
|
6
|
+
const AUDIT_ERROR_MAX_CHARS = 200;
|
|
7
|
+
/** eval 表达式只留前 120 字符:里面可能有凭据。 */
|
|
8
|
+
const AUDIT_EXPRESSION_MAX_CHARS = 120;
|
|
9
|
+
/** 参数里这些键的值一律打码(大小写不敏感)。 */
|
|
10
|
+
const SECRET_KEY = /^(token|authorization|password|passwd|secret|apikey|api_key|cookie)$/i;
|
|
11
|
+
/**
|
|
12
|
+
* 哪个命令的 `value` 字段是敏感内容:`input` 是往页面里打的字(可能是密码),
|
|
13
|
+
* `cookies.set` 直接写凭据。其余命令的 value 字段(如果有)不动。
|
|
14
|
+
*/
|
|
15
|
+
const VALUE_REDACTED_COMMANDS = new Set(['input', 'cookies.set']);
|
|
16
|
+
function redactValue(value) {
|
|
17
|
+
const length = typeof value === 'string' ? [...value].length : 0;
|
|
18
|
+
return `[redacted:${length} chars]`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 把一次调用的参数压成可审计的摘要:敏感键打码、特定命令的 value 打码、
|
|
22
|
+
* 长表达式截断、整体限长。返回 JSON 文本——审计工具原样把它交给模型。
|
|
23
|
+
*/
|
|
24
|
+
export function summarizeAuditParams(command, params) {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = JSON.stringify(params, (key, value) => {
|
|
28
|
+
if (SECRET_KEY.test(key))
|
|
29
|
+
return '[redacted]';
|
|
30
|
+
if (key === 'value' && VALUE_REDACTED_COMMANDS.has(command))
|
|
31
|
+
return redactValue(value);
|
|
32
|
+
if (key === 'expression' && typeof value === 'string' && [...value].length > AUDIT_EXPRESSION_MAX_CHARS) {
|
|
33
|
+
return `${[...value].slice(0, AUDIT_EXPRESSION_MAX_CHARS).join('')}…[+${[...value].length - AUDIT_EXPRESSION_MAX_CHARS} chars]`;
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}) ?? '{}';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
text = '[unserializable params]';
|
|
40
|
+
}
|
|
41
|
+
return [...text].length > AUDIT_PARAMS_MAX_CHARS
|
|
42
|
+
? `${[...text].slice(0, AUDIT_PARAMS_MAX_CHARS).join('')}…[+${[...text].length - AUDIT_PARAMS_MAX_CHARS} chars]`
|
|
43
|
+
: text;
|
|
44
|
+
}
|
|
45
|
+
/** 记账器。一个桥(一个插件实例)一份;`record` 只追加,`list` 只读。 */
|
|
46
|
+
export class AuditLog {
|
|
47
|
+
entries = [];
|
|
48
|
+
nextSeq = 1;
|
|
49
|
+
now;
|
|
50
|
+
constructor(now = Date.now) {
|
|
51
|
+
this.now = now;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 记一笔。
|
|
55
|
+
* @param input - 命令、摘要与结果;seq/t 由账本自己盖,durationMs 由调用方量。
|
|
56
|
+
*/
|
|
57
|
+
record(input) {
|
|
58
|
+
const entry = {
|
|
59
|
+
seq: this.nextSeq++,
|
|
60
|
+
t: this.now(),
|
|
61
|
+
command: input.command,
|
|
62
|
+
params: input.params,
|
|
63
|
+
ok: input.ok,
|
|
64
|
+
durationMs: Math.max(0, Math.round(input.durationMs)),
|
|
65
|
+
};
|
|
66
|
+
if (input.tabId !== undefined)
|
|
67
|
+
entry.tabId = input.tabId;
|
|
68
|
+
if (input.code !== undefined)
|
|
69
|
+
entry.code = input.code;
|
|
70
|
+
if (input.effectState !== undefined)
|
|
71
|
+
entry.effectState = input.effectState;
|
|
72
|
+
if (input.error !== undefined) {
|
|
73
|
+
entry.error = [...input.error].length > AUDIT_ERROR_MAX_CHARS
|
|
74
|
+
? `${[...input.error].slice(0, AUDIT_ERROR_MAX_CHARS).join('')}…`
|
|
75
|
+
: input.error;
|
|
76
|
+
}
|
|
77
|
+
this.entries.push(entry);
|
|
78
|
+
// 环形缓冲:超出上限从最旧的一端丢。seq 不回收——调用方拿 seq 分页时
|
|
79
|
+
// 「中间缺了号」本身就是「被挤掉了」的机器可读信号。
|
|
80
|
+
if (this.entries.length > AUDIT_MAX_ENTRIES)
|
|
81
|
+
this.entries.splice(0, this.entries.length - AUDIT_MAX_ENTRIES);
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
/** 现有条目数(被挤掉的不算)。 */
|
|
85
|
+
size() {
|
|
86
|
+
return this.entries.length;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 过滤并取最新的一批。
|
|
90
|
+
* @param filter - tabId / 命令子串 / sinceSeq / limit;返回按 seq 升序。
|
|
91
|
+
*/
|
|
92
|
+
list(filter = {}) {
|
|
93
|
+
let matched = this.entries;
|
|
94
|
+
if (filter.tabId !== undefined)
|
|
95
|
+
matched = matched.filter((e) => e.tabId === filter.tabId);
|
|
96
|
+
if (filter.command !== undefined && filter.command.length > 0) {
|
|
97
|
+
const needle = filter.command.toLowerCase();
|
|
98
|
+
matched = matched.filter((e) => e.command.toLowerCase().includes(needle));
|
|
99
|
+
}
|
|
100
|
+
if (filter.sinceSeq !== undefined) {
|
|
101
|
+
const floor = filter.sinceSeq;
|
|
102
|
+
matched = matched.filter((e) => e.seq > floor);
|
|
103
|
+
}
|
|
104
|
+
const limit = Math.min(AUDIT_MAX_ENTRIES, Math.max(1, Math.trunc(filter.limit ?? 50)));
|
|
105
|
+
return matched.slice(Math.max(0, matched.length - limit));
|
|
106
|
+
}
|
|
107
|
+
}
|
package/lib/types/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
24
24
|
import { BridgeServer, cleanupArtifacts } from "./server.js";
|
|
25
25
|
import { BridgeError, classify } from "./errors.js";
|
|
26
26
|
import { policyRefusal } from "./policy.js";
|
|
27
|
+
import { AUDIT_MAX_ENTRIES, AuditLog, summarizeAuditParams } from "./audit.js";
|
|
27
28
|
/** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
|
|
28
29
|
const NAV_MAX_MS = 120_000;
|
|
29
30
|
/**
|
|
@@ -116,6 +117,8 @@ class BridgeController {
|
|
|
116
117
|
lastError;
|
|
117
118
|
chain = Promise.resolve();
|
|
118
119
|
current;
|
|
120
|
+
/** 操作审计账本(内存环形缓冲,200 条;命令层记录,见 audit.ts 顶部注释)。 */
|
|
121
|
+
audit = new AuditLog();
|
|
119
122
|
constructor(log) {
|
|
120
123
|
this.log = log;
|
|
121
124
|
}
|
|
@@ -123,6 +126,14 @@ class BridgeController {
|
|
|
123
126
|
get shotsDir() {
|
|
124
127
|
return this.current === undefined ? undefined : path.resolve(this.current.shotsDir);
|
|
125
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Current policy flags. Tools that compose several commands (browser_drag
|
|
131
|
+
* runs `cdp`/`eval`) read this to refuse with a tool-level message; the
|
|
132
|
+
* per-command gate in execute() stays as the enforcement backstop.
|
|
133
|
+
*/
|
|
134
|
+
get policy() {
|
|
135
|
+
return { readOnly: this.current?.readOnly === true, allowScript: this.current?.allowScript !== false };
|
|
136
|
+
}
|
|
126
137
|
/** Version the connected extension reported in its hello frame, if any. */
|
|
127
138
|
extensionVersion() {
|
|
128
139
|
return this.server?.status.hello?.version;
|
|
@@ -153,6 +164,16 @@ class BridgeController {
|
|
|
153
164
|
requireExtensionCommand(command, since) {
|
|
154
165
|
if (this.hasExtensionCommand(command))
|
|
155
166
|
return;
|
|
167
|
+
// 预检拒绝也要上账:这是「升级插件后模型发现功能不可用」的最常见现场。
|
|
168
|
+
this.audit.record({
|
|
169
|
+
command,
|
|
170
|
+
params: '',
|
|
171
|
+
ok: false,
|
|
172
|
+
code: 'extension_outdated',
|
|
173
|
+
effectState: 'not_started',
|
|
174
|
+
error: `connected extension v${this.extensionVersion() ?? '?'} lacks ${command} (added in ${since})`,
|
|
175
|
+
durationMs: 0,
|
|
176
|
+
});
|
|
156
177
|
throw extensionOutdatedError(this.extensionVersion(), command, since);
|
|
157
178
|
}
|
|
158
179
|
/**
|
|
@@ -213,12 +234,34 @@ class BridgeController {
|
|
|
213
234
|
* @returns the extension's result payload verbatim.
|
|
214
235
|
*/
|
|
215
236
|
async execute(command, params, signal, timeoutMs) {
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
237
|
+
const started = Date.now();
|
|
238
|
+
const summary = summarizeAuditParams(command, params);
|
|
239
|
+
const tabId = typeof params.tabId === 'number' ? params.tabId : undefined;
|
|
240
|
+
try {
|
|
241
|
+
this.policyGate(command, params);
|
|
242
|
+
const server = this.server;
|
|
243
|
+
if (server === undefined) {
|
|
244
|
+
throw classify(command, new Error(this.lastError ?? '浏览器控制未启用 —— 在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true(dsh 0.1.7+ 的宿主也会在设置 → 插件 自动列出本插件开关;0.1.2 及更早的宿主没有这张卡片,只能改配置文件)'));
|
|
245
|
+
}
|
|
246
|
+
const result = await server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
|
|
247
|
+
this.audit.record({ command, tabId, params: summary, ok: true, durationMs: Date.now() - started });
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const code = error.code;
|
|
252
|
+
const effectState = error.effectState;
|
|
253
|
+
this.audit.record({
|
|
254
|
+
command,
|
|
255
|
+
tabId,
|
|
256
|
+
params: summary,
|
|
257
|
+
ok: false,
|
|
258
|
+
code: typeof code === 'string' ? code : undefined,
|
|
259
|
+
effectState: typeof effectState === 'string' ? effectState : undefined,
|
|
260
|
+
error: errorMessage(error),
|
|
261
|
+
durationMs: Date.now() - started,
|
|
262
|
+
});
|
|
263
|
+
throw error;
|
|
220
264
|
}
|
|
221
|
-
return server.execute(command, params, timeoutMs === undefined ? { signal } : { signal, timeoutMs });
|
|
222
265
|
}
|
|
223
266
|
/**
|
|
224
267
|
* Refuse a command the local policy forbids, before anything is sent or deleted.
|
|
@@ -244,19 +287,38 @@ class BridgeController {
|
|
|
244
287
|
* @returns counts and names of what was removed.
|
|
245
288
|
*/
|
|
246
289
|
async cleanup() {
|
|
247
|
-
|
|
248
|
-
// 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
|
|
249
|
-
this.policyGate('cleanup');
|
|
250
|
-
const dir = this.shotsDir;
|
|
251
|
-
if (dir === undefined)
|
|
252
|
-
throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
|
|
290
|
+
const started = Date.now();
|
|
253
291
|
try {
|
|
254
|
-
|
|
292
|
+
// cleanup() 不经过 execute(),所以它必须自己站一次闸门:否则只读档下
|
|
293
|
+
// 模型仍能用 browser_cleanup 删掉用户的整个截图目录。
|
|
294
|
+
this.policyGate('cleanup');
|
|
295
|
+
const dir = this.shotsDir;
|
|
296
|
+
if (dir === undefined)
|
|
297
|
+
throw classify('cleanup', new Error('browser-bridge is not configured yet —— 浏览器控制尚未加载配置,无法确定清理目录'));
|
|
298
|
+
try {
|
|
299
|
+
const result = await cleanupArtifacts({ shotsDir: dir });
|
|
300
|
+
this.audit.record({ command: 'cleanup', params: '{}', ok: true, durationMs: Date.now() - started });
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// 删除是逐条进行的,中途失败时前面的文件已经没了;不包一层分类,
|
|
305
|
+
// 模型拿到的就是一句没有 code / effectState 的裸 fs 错误。
|
|
306
|
+
throw classify('cleanup', error);
|
|
307
|
+
}
|
|
255
308
|
}
|
|
256
309
|
catch (error) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
310
|
+
const code = error.code;
|
|
311
|
+
const effectState = error.effectState;
|
|
312
|
+
this.audit.record({
|
|
313
|
+
command: 'cleanup',
|
|
314
|
+
params: '{}',
|
|
315
|
+
ok: false,
|
|
316
|
+
code: typeof code === 'string' ? code : undefined,
|
|
317
|
+
effectState: typeof effectState === 'string' ? effectState : undefined,
|
|
318
|
+
error: errorMessage(error),
|
|
319
|
+
durationMs: Date.now() - started,
|
|
320
|
+
});
|
|
321
|
+
throw error;
|
|
260
322
|
}
|
|
261
323
|
}
|
|
262
324
|
/** Stop the listener; safe to call repeatedly and during teardown. */
|
|
@@ -1683,6 +1745,338 @@ function applyBrowserTools(ctx, controller) {
|
|
|
1683
1745
|
return await controller.execute('metrics', params, exec.signal);
|
|
1684
1746
|
},
|
|
1685
1747
|
}));
|
|
1748
|
+
registerDragTool(ctx, controller);
|
|
1749
|
+
registerAuditTool(ctx, controller);
|
|
1750
|
+
}
|
|
1751
|
+
/* ------------------- browser_drag: composed plugin-side over the cdp channel ------------------- */
|
|
1752
|
+
/** One CDP round-trip over the embedded `cdp` passthrough (extension v1.7.2+). */
|
|
1753
|
+
async function cdpCall(ctrl, tabId, method, params, signal) {
|
|
1754
|
+
return await ctrl.execute('cdp', { method, params, tabId }, signal);
|
|
1755
|
+
}
|
|
1756
|
+
function dragTargetArgs(selector, x, y, side) {
|
|
1757
|
+
const hasSel = typeof selector === 'string' && selector.length > 0;
|
|
1758
|
+
const hasX = typeof x === 'number' && Number.isFinite(x);
|
|
1759
|
+
const hasY = typeof y === 'number' && Number.isFinite(y);
|
|
1760
|
+
if (hasSel && (hasX || hasY))
|
|
1761
|
+
throw new Error(`${side}: give either "${side}" (CSS selector) or "${side}X"/"${side}Y" coordinates, not both`);
|
|
1762
|
+
if (!hasSel && !(hasX && hasY))
|
|
1763
|
+
throw new Error(`${side}: needs a CSS selector ("${side}") or both "${side}X" and "${side}Y" coordinates`);
|
|
1764
|
+
if (hasSel)
|
|
1765
|
+
return { selector };
|
|
1766
|
+
return { x, y };
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Resolve a drag endpoint. Selector endpoints become viewport coordinates via
|
|
1770
|
+
* the CDP DOM domain — no page JavaScript, so mouse mode also works with
|
|
1771
|
+
* `allowScript: false`. Top document only; cross-origin iframes are a later
|
|
1772
|
+
* iteration.
|
|
1773
|
+
*/
|
|
1774
|
+
async function resolveDragPoint(ctrl, tabId, target, signal) {
|
|
1775
|
+
if (target.selector === undefined)
|
|
1776
|
+
return { x: target.x, y: target.y };
|
|
1777
|
+
const selector = target.selector;
|
|
1778
|
+
const doc = await cdpCall(ctrl, tabId, 'DOM.getDocument', { depth: 0 }, signal);
|
|
1779
|
+
const rootId = doc?.root?.nodeId;
|
|
1780
|
+
if (typeof rootId !== 'number')
|
|
1781
|
+
throw new Error('DOM.getDocument returned no root node');
|
|
1782
|
+
const found = await cdpCall(ctrl, tabId, 'DOM.querySelector', { nodeId: rootId, selector }, signal);
|
|
1783
|
+
const nodeId = found?.nodeId ?? 0;
|
|
1784
|
+
if (nodeId === 0)
|
|
1785
|
+
throw new Error(`no element matches selector ${JSON.stringify(selector)} (top document only)`);
|
|
1786
|
+
await cdpCall(ctrl, tabId, 'DOM.scrollIntoViewIfNeeded', { nodeId }, signal);
|
|
1787
|
+
let box;
|
|
1788
|
+
try {
|
|
1789
|
+
box = await cdpCall(ctrl, tabId, 'DOM.getBoxModel', { nodeId }, signal);
|
|
1790
|
+
}
|
|
1791
|
+
catch (error) {
|
|
1792
|
+
throw new Error(`cannot measure ${JSON.stringify(selector)} — hidden, zero-size or detached? (${error instanceof Error ? error.message : String(error)})`);
|
|
1793
|
+
}
|
|
1794
|
+
const q = box?.model?.content;
|
|
1795
|
+
if (!Array.isArray(q) || q.length < 8)
|
|
1796
|
+
throw new Error(`DOM.getBoxModel returned no quad for ${JSON.stringify(selector)}`);
|
|
1797
|
+
const x = (q[0] + q[2] + q[4] + q[6]) / 4;
|
|
1798
|
+
const y = (q[1] + q[3] + q[5] + q[7]) / 4;
|
|
1799
|
+
const width = typeof box?.model?.width === 'number' ? box.model.width : Math.hypot(q[2] - q[0], q[3] - q[1]);
|
|
1800
|
+
const height = typeof box?.model?.height === 'number' ? box.model.height : Math.hypot(q[4] - q[2], q[5] - q[3]);
|
|
1801
|
+
return { selector, x, y, width, height, nodeId };
|
|
1802
|
+
}
|
|
1803
|
+
/** Whether the source declares the native HTML5 `draggable` attribute. */
|
|
1804
|
+
async function sourceIsDraggable(ctrl, tabId, nodeId, signal) {
|
|
1805
|
+
const attrs = await cdpCall(ctrl, tabId, 'DOM.getAttributes', { nodeId }, signal);
|
|
1806
|
+
const list = Array.isArray(attrs?.attributes) ? attrs.attributes.map(String) : [];
|
|
1807
|
+
const at = list.indexOf('draggable');
|
|
1808
|
+
return at >= 0 && list[at + 1] === 'true';
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Synthesize a native-order HTML5 drag in the page: dragstart on the source,
|
|
1812
|
+
* dragenter/dragover on the target, drop only when dragover was prevented
|
|
1813
|
+
* (the browser's own rule), dragend on the source, all sharing one DataTransfer.
|
|
1814
|
+
* Untrusted events — pages that gate on `event.isTrusted` will ignore them,
|
|
1815
|
+
* which the tool description states instead of pretending otherwise.
|
|
1816
|
+
*/
|
|
1817
|
+
function html5DragExpression(fromSelector, to) {
|
|
1818
|
+
const toExpr = to.selector !== undefined
|
|
1819
|
+
? `document.querySelector(${JSON.stringify(to.selector)})`
|
|
1820
|
+
: `document.elementFromPoint(${to.x}, ${to.y})`;
|
|
1821
|
+
return `(() => {
|
|
1822
|
+
const src = document.querySelector(${JSON.stringify(fromSelector)});
|
|
1823
|
+
if (!src) return { error: 'no element matches from selector ' + ${JSON.stringify(fromSelector)} };
|
|
1824
|
+
const dst = ${toExpr};
|
|
1825
|
+
if (!dst) return { error: 'target element not found' };
|
|
1826
|
+
const dt = new DataTransfer();
|
|
1827
|
+
const log = [];
|
|
1828
|
+
const sr = src.getBoundingClientRect();
|
|
1829
|
+
const dr = dst.getBoundingClientRect();
|
|
1830
|
+
const sx = sr.left + sr.width / 2, sy = sr.top + sr.height / 2;
|
|
1831
|
+
const tx = dr.left + dr.width / 2, ty = dr.top + dr.height / 2;
|
|
1832
|
+
const fire = (type, target, cx, cy) => {
|
|
1833
|
+
const ev = new DragEvent(type, { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: cx, clientY: cy });
|
|
1834
|
+
target.dispatchEvent(ev);
|
|
1835
|
+
log.push({ type, defaultPrevented: ev.defaultPrevented });
|
|
1836
|
+
return ev.defaultPrevented;
|
|
1837
|
+
};
|
|
1838
|
+
const startCancelled = fire('dragstart', src, sx, sy);
|
|
1839
|
+
let dragenterDefaultPrevented = false, overPrevented = false, dropFired = false, dropPrevented = false;
|
|
1840
|
+
if (!startCancelled) {
|
|
1841
|
+
dragenterDefaultPrevented = fire('dragenter', dst, tx, ty);
|
|
1842
|
+
overPrevented = fire('dragover', dst, tx, ty);
|
|
1843
|
+
if (overPrevented) {
|
|
1844
|
+
dropFired = true;
|
|
1845
|
+
dropPrevented = fire('drop', dst, tx, ty);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
fire('dragend', src, sx, sy);
|
|
1849
|
+
return { startCancelled, dragenterDefaultPrevented, overPrevented, dropFired, dropPrevented, events: log.length, log };
|
|
1850
|
+
})()`;
|
|
1851
|
+
}
|
|
1852
|
+
function roundDragPoint(p) {
|
|
1853
|
+
const out = {
|
|
1854
|
+
x: Math.round(p.x * 100) / 100,
|
|
1855
|
+
y: Math.round(p.y * 100) / 100,
|
|
1856
|
+
};
|
|
1857
|
+
if (p.selector !== undefined)
|
|
1858
|
+
out.selector = p.selector;
|
|
1859
|
+
if (p.width !== undefined)
|
|
1860
|
+
out.width = Math.round(p.width * 100) / 100;
|
|
1861
|
+
if (p.height !== undefined)
|
|
1862
|
+
out.height = Math.round(p.height * 100) / 100;
|
|
1863
|
+
return out;
|
|
1864
|
+
}
|
|
1865
|
+
function registerDragTool(ctx, controller) {
|
|
1866
|
+
ctx.tools.register(defineTool({
|
|
1867
|
+
name: 'browser_drag',
|
|
1868
|
+
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).',
|
|
1869
|
+
parameters: {
|
|
1870
|
+
from: { type: 'string', description: 'CSS selector of the drag source; use fromX/fromY instead for raw coordinates.' },
|
|
1871
|
+
to: { type: 'string', description: 'CSS selector of the drop target; use toX/toY instead for raw coordinates.' },
|
|
1872
|
+
fromX: { type: 'number', description: 'Viewport x of the drag start point (with fromY).' },
|
|
1873
|
+
fromY: { type: 'number', description: 'Viewport y of the drag start point (with fromX).' },
|
|
1874
|
+
toX: { type: 'number', description: 'Viewport x of the drop point (with toY).' },
|
|
1875
|
+
toY: { type: 'number', description: 'Viewport y of the drop point (with toX).' },
|
|
1876
|
+
tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
|
|
1877
|
+
mode: { type: 'string', description: '"auto" (default), "mouse" or "html5" — see the tool description.' },
|
|
1878
|
+
steps: { type: 'number', description: 'Intermediate mouse moves in mouse mode, 1–50 (default 12).' },
|
|
1879
|
+
},
|
|
1880
|
+
output: {
|
|
1881
|
+
schema: {
|
|
1882
|
+
type: 'object',
|
|
1883
|
+
additionalProperties: false,
|
|
1884
|
+
properties: {
|
|
1885
|
+
tabId: { type: 'number', required: true },
|
|
1886
|
+
mode: { type: 'string', required: true },
|
|
1887
|
+
from: {
|
|
1888
|
+
type: 'object',
|
|
1889
|
+
additionalProperties: false,
|
|
1890
|
+
properties: {
|
|
1891
|
+
selector: { type: 'string' },
|
|
1892
|
+
x: { type: 'number' },
|
|
1893
|
+
y: { type: 'number' },
|
|
1894
|
+
width: { type: 'number' },
|
|
1895
|
+
height: { type: 'number' },
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
to: {
|
|
1899
|
+
type: 'object',
|
|
1900
|
+
additionalProperties: false,
|
|
1901
|
+
properties: {
|
|
1902
|
+
selector: { type: 'string' },
|
|
1903
|
+
x: { type: 'number' },
|
|
1904
|
+
y: { type: 'number' },
|
|
1905
|
+
width: { type: 'number' },
|
|
1906
|
+
height: { type: 'number' },
|
|
1907
|
+
},
|
|
1908
|
+
},
|
|
1909
|
+
steps: { type: 'number' },
|
|
1910
|
+
dispatched: { type: 'number' },
|
|
1911
|
+
startCancelled: { type: 'boolean' },
|
|
1912
|
+
dragenterDefaultPrevented: { type: 'boolean' },
|
|
1913
|
+
overPrevented: { type: 'boolean' },
|
|
1914
|
+
dropFired: { type: 'boolean' },
|
|
1915
|
+
dropPrevented: { type: 'boolean' },
|
|
1916
|
+
events: { type: 'number' },
|
|
1917
|
+
elapsedMs: { type: 'number', required: true },
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
render: (_args, value) => {
|
|
1921
|
+
const v = value;
|
|
1922
|
+
const d = (p) => p?.selector !== undefined ? p.selector : `(${p?.x ?? '?'},${p?.y ?? '?'})`;
|
|
1923
|
+
if (v.mode === 'mouse') {
|
|
1924
|
+
return [{ type: 'text', text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with mouse input (${v.steps} steps, ${v.dispatched} dispatches, ${v.elapsedMs}ms)` }];
|
|
1925
|
+
}
|
|
1926
|
+
const verdict = v.startCancelled === true
|
|
1927
|
+
? 'dragstart was cancelled by the page — nothing dropped'
|
|
1928
|
+
: v.overPrevented === true
|
|
1929
|
+
? (v.dropPrevented === true ? 'drop fired and was handled' : 'drop fired but no drop handler ran')
|
|
1930
|
+
: 'target did not accept the drop (no dragover preventDefault)';
|
|
1931
|
+
return [{ type: 'text', text: `Tab ${v.tabId}: dragged ${d(v.from)} → ${d(v.to)} with html5 events (${v.events} events, ${v.elapsedMs}ms) — ${verdict}` }];
|
|
1932
|
+
},
|
|
1933
|
+
},
|
|
1934
|
+
presentCall: args => ({
|
|
1935
|
+
card: 'generic',
|
|
1936
|
+
title: `Drag ${args.from ?? `(${args.fromX},${args.fromY})`} → ${args.to ?? `(${args.toX},${args.toY})`}`,
|
|
1937
|
+
kind: 'other',
|
|
1938
|
+
}),
|
|
1939
|
+
async execute(args, exec) {
|
|
1940
|
+
const mode = args.mode === undefined ? 'auto' : args.mode;
|
|
1941
|
+
if (mode !== 'auto' && mode !== 'mouse' && mode !== 'html5')
|
|
1942
|
+
throw new Error(`mode must be "auto", "mouse" or "html5" — got ${JSON.stringify(mode)}`);
|
|
1943
|
+
const from = dragTargetArgs(args.from, args.fromX, args.fromY, 'from');
|
|
1944
|
+
const to = dragTargetArgs(args.to, args.toX, args.toY, 'to');
|
|
1945
|
+
controller.requireExtensionCommand('cdp', '1.7.2');
|
|
1946
|
+
if (controller.policy.readOnly)
|
|
1947
|
+
throw new Error('browser_drag changes page state and read-only mode is on — turn readOnly off in the browser-bridge config to drag');
|
|
1948
|
+
let tabId = args.tabId;
|
|
1949
|
+
if (tabId === undefined) {
|
|
1950
|
+
const listing = await controller.execute('tabs.list', {}, exec.signal);
|
|
1951
|
+
if (typeof listing?.activeTabId !== 'number')
|
|
1952
|
+
throw new Error('could not resolve the active tab — pass tabId explicitly');
|
|
1953
|
+
tabId = listing.activeTabId;
|
|
1954
|
+
}
|
|
1955
|
+
const started = Date.now();
|
|
1956
|
+
let resolvedMode = mode === 'auto' ? 'mouse' : mode;
|
|
1957
|
+
let source;
|
|
1958
|
+
if (mode !== 'html5')
|
|
1959
|
+
source = await resolveDragPoint(controller, tabId, from, exec.signal);
|
|
1960
|
+
if (mode === 'auto' && source?.nodeId !== undefined && await sourceIsDraggable(controller, tabId, source.nodeId, exec.signal)) {
|
|
1961
|
+
resolvedMode = 'html5';
|
|
1962
|
+
}
|
|
1963
|
+
if (resolvedMode === 'mouse') {
|
|
1964
|
+
const target = await resolveDragPoint(controller, tabId, to, exec.signal);
|
|
1965
|
+
const steps = Math.min(50, Math.max(1, Math.trunc(typeof args.steps === 'number' ? args.steps : 12)));
|
|
1966
|
+
const sx = source.x, sy = source.y, tx = target.x, ty = target.y;
|
|
1967
|
+
const dispatch = async (params) => {
|
|
1968
|
+
await cdpCall(controller, tabId, 'Input.dispatchMouseEvent', params, exec.signal);
|
|
1969
|
+
};
|
|
1970
|
+
await dispatch({ type: 'mouseMoved', x: sx, y: sy, button: 'none', buttons: 0 });
|
|
1971
|
+
await dispatch({ type: 'mousePressed', x: sx, y: sy, button: 'left', buttons: 1, clickCount: 1 });
|
|
1972
|
+
for (let i = 1; i <= steps; i++) {
|
|
1973
|
+
const f = i / steps;
|
|
1974
|
+
await dispatch({ type: 'mouseMoved', x: sx + (tx - sx) * f, y: sy + (ty - sy) * f, button: 'left', buttons: 1 });
|
|
1975
|
+
}
|
|
1976
|
+
await dispatch({ type: 'mouseReleased', x: tx, y: ty, button: 'left', buttons: 0, clickCount: 1 });
|
|
1977
|
+
await dispatch({ type: 'mouseMoved', x: tx, y: ty, button: 'none', buttons: 0 });
|
|
1978
|
+
const receipt = {
|
|
1979
|
+
tabId,
|
|
1980
|
+
mode: 'mouse',
|
|
1981
|
+
from: roundDragPoint(source),
|
|
1982
|
+
to: roundDragPoint(target),
|
|
1983
|
+
steps,
|
|
1984
|
+
dispatched: steps + 4,
|
|
1985
|
+
elapsedMs: Date.now() - started,
|
|
1986
|
+
};
|
|
1987
|
+
return receipt;
|
|
1988
|
+
}
|
|
1989
|
+
if (from.selector === undefined)
|
|
1990
|
+
throw new Error('html5 mode needs a CSS selector for "from" (a point has no draggable element)');
|
|
1991
|
+
if (!controller.policy.allowScript)
|
|
1992
|
+
throw new Error('html5 drag synthesizes DOM events and needs page scripting — allowScript is off; use mode:"mouse" or re-enable the script switch');
|
|
1993
|
+
const raw = await controller.execute('eval', { expression: html5DragExpression(from.selector, to), tabId }, exec.signal);
|
|
1994
|
+
const outcome = raw?.value;
|
|
1995
|
+
if (outcome === undefined || typeof outcome !== 'object')
|
|
1996
|
+
throw new Error('html5 drag expression returned no outcome — page may have navigated mid-drag');
|
|
1997
|
+
if (typeof outcome.error === 'string')
|
|
1998
|
+
throw new Error(outcome.error);
|
|
1999
|
+
const receipt = {
|
|
2000
|
+
tabId: typeof raw?.tabId === 'number' ? raw.tabId : tabId,
|
|
2001
|
+
mode: 'html5',
|
|
2002
|
+
from: from.selector !== undefined ? { selector: from.selector } : roundDragPoint(from),
|
|
2003
|
+
to: to.selector !== undefined ? { selector: to.selector } : { x: to.x, y: to.y },
|
|
2004
|
+
startCancelled: outcome.startCancelled === true,
|
|
2005
|
+
dragenterDefaultPrevented: outcome.dragenterDefaultPrevented === true,
|
|
2006
|
+
overPrevented: outcome.overPrevented === true,
|
|
2007
|
+
dropFired: outcome.dropFired === true,
|
|
2008
|
+
dropPrevented: outcome.dropPrevented === true,
|
|
2009
|
+
events: typeof outcome.events === 'number' ? outcome.events : 0,
|
|
2010
|
+
elapsedMs: Date.now() - started,
|
|
2011
|
+
};
|
|
2012
|
+
return receipt;
|
|
2013
|
+
},
|
|
2014
|
+
}));
|
|
2015
|
+
}
|
|
2016
|
+
function registerAuditTool(ctx, controller) {
|
|
2017
|
+
ctx.tools.register(defineTool({
|
|
2018
|
+
name: 'browser_audit',
|
|
2019
|
+
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.',
|
|
2020
|
+
parameters: {
|
|
2021
|
+
command: { type: 'string', description: 'Only entries whose command contains this substring (e.g. "nav", "cookies", "tabs").' },
|
|
2022
|
+
tabId: { type: 'number', description: 'Only entries that carried this tabId.' },
|
|
2023
|
+
sinceSeq: { type: 'number', description: 'Only entries with seq greater than this (paging).' },
|
|
2024
|
+
limit: { type: 'number', description: `Max entries to return (newest last), 1–${AUDIT_MAX_ENTRIES}; default 50.` },
|
|
2025
|
+
},
|
|
2026
|
+
output: {
|
|
2027
|
+
schema: {
|
|
2028
|
+
type: 'object',
|
|
2029
|
+
additionalProperties: false,
|
|
2030
|
+
properties: {
|
|
2031
|
+
total: { type: 'number', required: true },
|
|
2032
|
+
shown: { type: 'number', required: true },
|
|
2033
|
+
entries: {
|
|
2034
|
+
type: 'array',
|
|
2035
|
+
required: true,
|
|
2036
|
+
items: {
|
|
2037
|
+
type: 'object',
|
|
2038
|
+
additionalProperties: false,
|
|
2039
|
+
properties: {
|
|
2040
|
+
seq: { type: 'number', required: true },
|
|
2041
|
+
t: { type: 'number', required: true },
|
|
2042
|
+
command: { type: 'string', required: true },
|
|
2043
|
+
tabId: { type: 'number' },
|
|
2044
|
+
params: { type: 'string', required: true },
|
|
2045
|
+
ok: { type: 'boolean', required: true },
|
|
2046
|
+
code: { type: 'string' },
|
|
2047
|
+
effectState: { type: 'string' },
|
|
2048
|
+
error: { type: 'string' },
|
|
2049
|
+
durationMs: { type: 'number', required: true },
|
|
2050
|
+
},
|
|
2051
|
+
},
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
render: (_args, value) => {
|
|
2056
|
+
const v = value;
|
|
2057
|
+
if (v.entries.length === 0) {
|
|
2058
|
+
return [{ type: 'text', text: `Audit: ${v.total} entries, none match — the trail starts when a command is executed.` }];
|
|
2059
|
+
}
|
|
2060
|
+
const lines = v.entries.map((e) => {
|
|
2061
|
+
const tab = e.tabId === undefined ? '' : ` tab ${e.tabId}`;
|
|
2062
|
+
const outcome = e.ok ? 'ok' : `FAIL ${e.code ?? 'error'}${e.effectState ? `/${e.effectState}` : ''}`;
|
|
2063
|
+
const err = e.ok || e.error === undefined ? '' : ` — ${e.error}`;
|
|
2064
|
+
return `#${e.seq} ${formatClock(e.t)} ${e.command}${tab} ${outcome} ${e.durationMs}ms ${e.params}${err}`;
|
|
2065
|
+
});
|
|
2066
|
+
return [{ type: 'text', text: clampRendered(`Audit: ${v.total} entries (showing ${v.shown})\n${lines.join('\n')}`) }];
|
|
2067
|
+
},
|
|
2068
|
+
},
|
|
2069
|
+
presentCall: () => ({ card: 'generic', title: 'Browser audit', kind: 'other' }),
|
|
2070
|
+
async execute(args) {
|
|
2071
|
+
const entries = controller.audit.list({
|
|
2072
|
+
tabId: args.tabId,
|
|
2073
|
+
command: args.command,
|
|
2074
|
+
sinceSeq: args.sinceSeq,
|
|
2075
|
+
limit: args.limit,
|
|
2076
|
+
});
|
|
2077
|
+
return { total: controller.audit.size(), shown: entries.length, entries };
|
|
2078
|
+
},
|
|
2079
|
+
}));
|
|
1686
2080
|
}
|
|
1687
2081
|
/** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
|
|
1688
2082
|
export function apply(ctx, config) {
|
package/llms.txt
CHANGED
|
@@ -42,7 +42,7 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
|
|
|
42
42
|
|
|
43
43
|
## 关键文件
|
|
44
44
|
|
|
45
|
-
- `src/index.ts` -
|
|
45
|
+
- `src/index.ts` - 27 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
|
|
46
46
|
- `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
|
|
47
47
|
- `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
|
|
48
48
|
- `src/ws.ts` - RFC 6455 帧编解码
|