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