@browserbridge/bbx 1.8.0 → 1.10.0
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/PRIVACY.md +29 -3
- package/README.md +10 -3
- package/SECURITY.md +12 -0
- package/package.json +7 -1
- package/packages/agent-client/src/cli-args.js +70 -6
- package/packages/agent-client/src/cli-batch.js +127 -59
- package/packages/agent-client/src/cli-helpers.js +8 -0
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli-setup-commands.js +3 -1
- package/packages/agent-client/src/cli.js +254 -26
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +14 -4
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/mcp-config.js +8 -8
- package/packages/agent-client/src/npm-self-update.js +305 -0
- package/packages/agent-client/src/remotes.js +4 -2
- package/packages/agent-client/src/runtime.js +99 -7
- package/packages/agent-client/src/types.ts +38 -0
- package/packages/mcp-server/src/guidance.js +37 -6
- package/packages/mcp-server/src/handlers-capture.js +29 -2
- package/packages/mcp-server/src/handlers-dom.js +54 -3
- package/packages/mcp-server/src/handlers-page.js +333 -60
- package/packages/mcp-server/src/handlers-utils.js +141 -151
- package/packages/mcp-server/src/handlers.js +17 -4
- package/packages/mcp-server/src/server.js +269 -51
- package/packages/mcp-server/src/toolset.js +48 -0
- package/packages/native-host/src/artifact-store.js +353 -0
- package/packages/native-host/src/config.js +9 -1
- package/packages/native-host/src/daemon-logger.js +3 -1
- package/packages/native-host/src/daemon.js +457 -27
- package/packages/native-host/src/dom-baseline-owners.js +210 -0
- package/packages/native-host/src/native-host.js +38 -29
- package/packages/protocol/src/budget-presets.js +182 -0
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +28 -0
- package/packages/protocol/src/errors.js +48 -5
- package/packages/protocol/src/har.js +34 -0
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +4 -0
- package/packages/protocol/src/json-lines.js +3 -2
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +226 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +490 -4
- package/skills/browser-bridge/SKILL.md +26 -8
- package/skills/browser-bridge/references/protocol.md +125 -15
- package/skills/browser-bridge/references/token-efficiency.md +17 -7
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { DOM_BASELINE_TTL_MS, MAX_DOM_BASELINES_GLOBAL } from '../../protocol/src/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ownership bookkeeping for retained semantic DOM baselines.
|
|
7
|
+
*
|
|
8
|
+
* A baseline lives in the extension's memory, but the daemon has to know which
|
|
9
|
+
* extension socket owns it so a later `compare`/`describe`/`release` is routed
|
|
10
|
+
* back to the same document, and so the state is dropped when that socket goes
|
|
11
|
+
* away. This module owns only that mapping and its expiry rules; sending the
|
|
12
|
+
* release request over the wire stays with the daemon, which owns transport.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {import('./daemon.js').ClientSocket} ClientSocket
|
|
15
|
+
* @typedef {{ socket: ClientSocket, expiresAt: number }} BaselineOwner
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Baseline IDs are minted by the extension. The daemon re-validates the shape
|
|
20
|
+
* before trusting one as a map key so a malformed response cannot grow state.
|
|
21
|
+
*/
|
|
22
|
+
const BASELINE_ID_PATTERN = /^baseline_[A-Za-z0-9_-]{32,64}$/u;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Expiry timestamps are allowed a small skew over the nominal TTL to absorb
|
|
26
|
+
* clock jitter between the extension and the daemon.
|
|
27
|
+
*/
|
|
28
|
+
const EXPIRY_SKEW_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
/** Cap on tracked mappings per socket, and on abandoned-create entries. */
|
|
31
|
+
const MAX_OWNER_MAPPINGS = MAX_DOM_BASELINES_GLOBAL * 4;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} baselineId
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isValidBaselineId(baselineId) {
|
|
38
|
+
return typeof baselineId === 'string' && BASELINE_ID_PATTERN.test(baselineId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class DomBaselineOwnerRegistry {
|
|
42
|
+
/**
|
|
43
|
+
* @param {{ isCurrentExtensionSocket: (socket: ClientSocket) => boolean }} options
|
|
44
|
+
*/
|
|
45
|
+
constructor({ isCurrentExtensionSocket }) {
|
|
46
|
+
/** @type {Map<string, BaselineOwner>} */
|
|
47
|
+
this.owners = new Map();
|
|
48
|
+
/**
|
|
49
|
+
* Creates whose agent went away before the extension replied. The response
|
|
50
|
+
* still arrives later, and the baseline it reports has to be released.
|
|
51
|
+
* @type {Map<string, BaselineOwner>}
|
|
52
|
+
*/
|
|
53
|
+
this.abandonedCreates = new Map();
|
|
54
|
+
this.isCurrentExtensionSocket = isCurrentExtensionSocket;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {number} */
|
|
58
|
+
get size() {
|
|
59
|
+
return this.owners.size;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop every mapping whose TTL has passed. */
|
|
63
|
+
prune() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [baselineId, owner] of this.owners) {
|
|
66
|
+
if (owner.expiresAt <= now) this.owners.delete(baselineId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the socket that owns a baseline, or null when the owner is gone,
|
|
72
|
+
* has lost access, or has been replaced by a newer socket for the same
|
|
73
|
+
* extension.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} baselineId
|
|
76
|
+
* @returns {ClientSocket | null}
|
|
77
|
+
*/
|
|
78
|
+
get(baselineId) {
|
|
79
|
+
this.prune();
|
|
80
|
+
const owner = this.owners.get(baselineId);
|
|
81
|
+
if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
|
|
82
|
+
if (!this.isCurrentExtensionSocket(owner.socket)) {
|
|
83
|
+
this.owners.delete(baselineId);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return owner.socket;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record ownership for a freshly created baseline.
|
|
91
|
+
*
|
|
92
|
+
* Returns false when the reported ID or expiry is unusable, or when another
|
|
93
|
+
* socket already claims the ID; the caller releases the orphan in that case.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} baselineId
|
|
96
|
+
* @param {ClientSocket} socket
|
|
97
|
+
* @param {unknown} expiresAt
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
register(baselineId, socket, expiresAt) {
|
|
101
|
+
this.prune();
|
|
102
|
+
const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (
|
|
105
|
+
!isValidBaselineId(baselineId) ||
|
|
106
|
+
!socket.__extensionId ||
|
|
107
|
+
!Number.isFinite(parsedExpiry) ||
|
|
108
|
+
parsedExpiry <= now ||
|
|
109
|
+
parsedExpiry > now + DOM_BASELINE_TTL_MS + EXPIRY_SKEW_MS
|
|
110
|
+
) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const existing = this.owners.get(baselineId);
|
|
114
|
+
if (existing && existing.socket !== socket) return false;
|
|
115
|
+
while (
|
|
116
|
+
!existing &&
|
|
117
|
+
[...this.owners.values()].filter((owner) => owner.socket === socket).length >=
|
|
118
|
+
MAX_OWNER_MAPPINGS
|
|
119
|
+
) {
|
|
120
|
+
const oldest = [...this.owners].find(([, owner]) => owner.socket === socket);
|
|
121
|
+
if (!oldest) break;
|
|
122
|
+
this.owners.delete(oldest[0]);
|
|
123
|
+
}
|
|
124
|
+
this.owners.set(baselineId, { socket, expiresAt: parsedExpiry });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} baselineId
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
delete(baselineId) {
|
|
133
|
+
return this.owners.delete(baselineId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drop a mapping only when the given socket still owns it, so one extension
|
|
138
|
+
* cannot evict another's baseline by reporting its ID.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} baselineId
|
|
141
|
+
* @param {ClientSocket} socket
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
deleteIfOwnedBy(baselineId, socket) {
|
|
145
|
+
if (this.owners.get(baselineId)?.socket !== socket) return false;
|
|
146
|
+
return this.owners.delete(baselineId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Forget every baseline and abandoned create belonging to a socket.
|
|
151
|
+
*
|
|
152
|
+
* @param {ClientSocket} socket
|
|
153
|
+
*/
|
|
154
|
+
clearForSocket(socket) {
|
|
155
|
+
for (const [baselineId, owner] of this.owners) {
|
|
156
|
+
if (owner.socket === socket) this.owners.delete(baselineId);
|
|
157
|
+
}
|
|
158
|
+
for (const [requestId, abandoned] of this.abandonedCreates) {
|
|
159
|
+
if (abandoned.socket === socket) this.abandonedCreates.delete(requestId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Remember a `dom.baseline.create` whose agent disconnected before the
|
|
165
|
+
* extension replied, so the eventual response can be released rather than
|
|
166
|
+
* leaking a retained snapshot in the page.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} requestId
|
|
169
|
+
* @param {{ method?: string, targets: Set<ClientSocket> }} pending
|
|
170
|
+
*/
|
|
171
|
+
markAbandonedCreate(requestId, pending) {
|
|
172
|
+
if (pending.method !== 'dom.baseline.create') return;
|
|
173
|
+
const target = pending.targets.values().next().value;
|
|
174
|
+
if (!target) return;
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
for (const [id, entry] of this.abandonedCreates) {
|
|
177
|
+
if (entry.expiresAt <= now) this.abandonedCreates.delete(id);
|
|
178
|
+
}
|
|
179
|
+
this.abandonedCreates.set(requestId, {
|
|
180
|
+
socket: target,
|
|
181
|
+
expiresAt: now + DOM_BASELINE_TTL_MS,
|
|
182
|
+
});
|
|
183
|
+
while (this.abandonedCreates.size > MAX_OWNER_MAPPINGS) {
|
|
184
|
+
const oldestId = this.abandonedCreates.keys().next().value;
|
|
185
|
+
if (typeof oldestId !== 'string') break;
|
|
186
|
+
this.abandonedCreates.delete(oldestId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Claim an abandoned create for the socket that is answering it. Returns
|
|
192
|
+
* false when the request was not abandoned or belongs to another socket.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} requestId
|
|
195
|
+
* @param {ClientSocket} socket
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
takeAbandonedCreate(requestId, socket) {
|
|
199
|
+
const abandoned = this.abandonedCreates.get(requestId);
|
|
200
|
+
if (abandoned?.socket !== socket) return false;
|
|
201
|
+
this.abandonedCreates.delete(requestId);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop all tracked state, used on daemon shutdown. */
|
|
206
|
+
clear() {
|
|
207
|
+
this.owners.clear();
|
|
208
|
+
this.abandonedCreates.clear();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createFailure,
|
|
7
|
+
ERROR_CODES,
|
|
8
|
+
parseJsonLines,
|
|
9
|
+
sanitizeIncidentalText,
|
|
10
|
+
} from '../../protocol/src/index.js';
|
|
6
11
|
import { readBridgeAuthToken } from './auth-token.js';
|
|
7
12
|
import { createSocketBridgeTransport, getBridgeTransport } from './config.js';
|
|
8
13
|
import { spawnBridgeDaemonProcess } from './daemon-process.js';
|
|
@@ -110,6 +115,16 @@ function isHostActivity(message) {
|
|
|
110
115
|
);
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/** @param {unknown} message */
|
|
119
|
+
function isHostArtifactMessage(message) {
|
|
120
|
+
return Boolean(
|
|
121
|
+
message &&
|
|
122
|
+
typeof message === 'object' &&
|
|
123
|
+
typeof (/** @type {Record<string, unknown>} */ (message).type) === 'string' &&
|
|
124
|
+
String(/** @type {Record<string, unknown>} */ (message).type).startsWith('host.artifact.')
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
/**
|
|
114
129
|
* @param {{ transport?: BridgeTransport, socketPath?: string }} [options={}]
|
|
115
130
|
* @returns {Promise<void>}
|
|
@@ -130,7 +145,7 @@ export async function runNativeHost({
|
|
|
130
145
|
response: createFailure(
|
|
131
146
|
'native_bootstrap',
|
|
132
147
|
ERROR_CODES.NATIVE_HOST_UNAVAILABLE,
|
|
133
|
-
error instanceof Error ? error.message : String(error)
|
|
148
|
+
sanitizeIncidentalText(error instanceof Error ? error.message : String(error))
|
|
134
149
|
),
|
|
135
150
|
});
|
|
136
151
|
return;
|
|
@@ -155,32 +170,10 @@ export async function runNativeHost({
|
|
|
155
170
|
...(authToken ? { authToken } : {}),
|
|
156
171
|
});
|
|
157
172
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
163
|
-
socket.destroy();
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
while (lineBuffer.includes('\n')) {
|
|
167
|
-
const index = lineBuffer.indexOf('\n');
|
|
168
|
-
const line = lineBuffer.slice(0, index).trim();
|
|
169
|
-
lineBuffer = lineBuffer.slice(index + 1);
|
|
170
|
-
if (!line) {
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
|
|
174
|
-
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
175
|
-
socket.destroy();
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
let message;
|
|
179
|
-
try {
|
|
180
|
-
message = JSON.parse(line);
|
|
181
|
-
} catch {
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
173
|
+
parseJsonLines(
|
|
174
|
+
socket,
|
|
175
|
+
(raw) => {
|
|
176
|
+
const message = /** @type {Record<string, unknown>} */ (raw);
|
|
184
177
|
void (async () => {
|
|
185
178
|
if (message.type === 'extension.request') {
|
|
186
179
|
await writeNativeMessageQueued(message.request);
|
|
@@ -213,8 +206,16 @@ export async function runNativeHost({
|
|
|
213
206
|
err instanceof Error ? err.message : err
|
|
214
207
|
);
|
|
215
208
|
});
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
onProtocolError: (error) => {
|
|
212
|
+
console.error(`native-host: ${error.message}`);
|
|
213
|
+
},
|
|
214
|
+
onInvalidLine: (error) => {
|
|
215
|
+
console.error(`native-host: ignoring malformed daemon JSON line: ${error.message}`);
|
|
216
|
+
},
|
|
216
217
|
}
|
|
217
|
-
|
|
218
|
+
);
|
|
218
219
|
|
|
219
220
|
createNativeMessageReader(
|
|
220
221
|
process.stdin,
|
|
@@ -257,6 +258,14 @@ export async function runNativeHost({
|
|
|
257
258
|
});
|
|
258
259
|
return;
|
|
259
260
|
}
|
|
261
|
+
if (isHostArtifactMessage(message)) {
|
|
262
|
+
const record = /** @type {Record<string, unknown>} */ (message);
|
|
263
|
+
await writeJsonLine(socket, {
|
|
264
|
+
...record,
|
|
265
|
+
type: String(record.type).replace(/^host\./u, 'extension.'),
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
260
269
|
await writeJsonLine(socket, {
|
|
261
270
|
type: 'extension.response',
|
|
262
271
|
response: message,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared budget-preset merge helpers. Presets fill gaps in method params;
|
|
5
|
+
* explicit per-call params always win. Used by both the MCP server and the
|
|
6
|
+
* agent CLI so `budgetPreset`/`--preset` behave identically.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_CONSOLE_LIMIT,
|
|
11
|
+
DEFAULT_HAR_LIMIT,
|
|
12
|
+
DEFAULT_LOG_TAIL_LIMIT,
|
|
13
|
+
DEFAULT_MAX_HTML_LENGTH,
|
|
14
|
+
DEFAULT_NETWORK_LIMIT,
|
|
15
|
+
DEFAULT_PAGE_TEXT_BUDGET,
|
|
16
|
+
getBudgetPreset,
|
|
17
|
+
isBudgetPresetName,
|
|
18
|
+
} from './defaults.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {import('./defaults.js').BudgetPresetName | null}
|
|
23
|
+
*/
|
|
24
|
+
export function getBudgetPresetName(value) {
|
|
25
|
+
return isBudgetPresetName(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @template {{ budgetPreset?: unknown, maxNodes?: unknown, maxDepth?: unknown, textBudget?: unknown }} T
|
|
30
|
+
* @param {T} args
|
|
31
|
+
* @returns {T}
|
|
32
|
+
*/
|
|
33
|
+
export function applyTreeBudgetPreset(args) {
|
|
34
|
+
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
35
|
+
if (!presetName) {
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
const preset = getBudgetPreset(presetName);
|
|
39
|
+
return /** @type {T} */ ({
|
|
40
|
+
...args,
|
|
41
|
+
maxNodes: args.maxNodes ?? preset.maxNodes,
|
|
42
|
+
maxDepth: args.maxDepth ?? preset.maxDepth,
|
|
43
|
+
textBudget: args.textBudget ?? preset.textBudget,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
|
|
49
|
+
* @param {T} args
|
|
50
|
+
* @returns {T}
|
|
51
|
+
*/
|
|
52
|
+
export function applyTextBudgetPreset(args) {
|
|
53
|
+
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
54
|
+
if (!presetName) {
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
const preset = getBudgetPreset(presetName);
|
|
58
|
+
return /** @type {T} */ ({
|
|
59
|
+
...args,
|
|
60
|
+
textBudget: args.textBudget ?? preset.textBudget,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @template {{ budgetPreset?: unknown, textBudget?: unknown }} T
|
|
66
|
+
* @param {T} args
|
|
67
|
+
* @returns {T}
|
|
68
|
+
*/
|
|
69
|
+
export function applyPageTextBudgetPreset(args) {
|
|
70
|
+
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
71
|
+
if (!presetName) {
|
|
72
|
+
return args;
|
|
73
|
+
}
|
|
74
|
+
const textBudgetByPreset = {
|
|
75
|
+
quick: 2000,
|
|
76
|
+
normal: DEFAULT_PAGE_TEXT_BUDGET,
|
|
77
|
+
deep: DEFAULT_PAGE_TEXT_BUDGET * 2,
|
|
78
|
+
};
|
|
79
|
+
return /** @type {T} */ ({
|
|
80
|
+
...args,
|
|
81
|
+
textBudget: args.textBudget ?? textBudgetByPreset[presetName],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @template {{ budgetPreset?: unknown, limit?: unknown }} T
|
|
87
|
+
* @param {T} args
|
|
88
|
+
* @param {{ quick: number, normal: number, deep: number }} defaults
|
|
89
|
+
* @returns {T}
|
|
90
|
+
*/
|
|
91
|
+
export function applyLimitBudgetPreset(args, defaults) {
|
|
92
|
+
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
93
|
+
if (!presetName) {
|
|
94
|
+
return args;
|
|
95
|
+
}
|
|
96
|
+
return /** @type {T} */ ({
|
|
97
|
+
...args,
|
|
98
|
+
limit: args.limit ?? defaults[presetName],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @template {{ budgetPreset?: unknown, maxLength?: unknown }} T
|
|
104
|
+
* @param {T} args
|
|
105
|
+
* @returns {T}
|
|
106
|
+
*/
|
|
107
|
+
export function applyHtmlBudgetPreset(args) {
|
|
108
|
+
const presetName = getBudgetPresetName(args.budgetPreset);
|
|
109
|
+
if (!presetName) {
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
const maxLengthByPreset = {
|
|
113
|
+
quick: 600,
|
|
114
|
+
normal: DEFAULT_MAX_HTML_LENGTH,
|
|
115
|
+
deep: 6000,
|
|
116
|
+
};
|
|
117
|
+
return /** @type {T} */ ({
|
|
118
|
+
...args,
|
|
119
|
+
maxLength: args.maxLength ?? maxLengthByPreset[presetName],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply a preset to method parameters before dispatch. Explicit method params
|
|
125
|
+
* always win over preset defaults.
|
|
126
|
+
*
|
|
127
|
+
* @param {import('./types.js').BridgeMethod} method
|
|
128
|
+
* @param {Record<string, unknown>} params
|
|
129
|
+
* @param {unknown} budgetPreset
|
|
130
|
+
* @returns {Record<string, unknown>}
|
|
131
|
+
*/
|
|
132
|
+
export function applyMethodBudgetPreset(method, params, budgetPreset) {
|
|
133
|
+
const args = { ...params, budgetPreset };
|
|
134
|
+
/** @type {Record<string, unknown>} */
|
|
135
|
+
let normalized = args;
|
|
136
|
+
if (
|
|
137
|
+
method === 'dom.query' ||
|
|
138
|
+
method === 'dom.get_accessibility_tree' ||
|
|
139
|
+
method === 'dom.baseline.create'
|
|
140
|
+
) {
|
|
141
|
+
normalized = applyTreeBudgetPreset(args);
|
|
142
|
+
} else if (method === 'dom.baseline.compare') {
|
|
143
|
+
const preset = getBudgetPresetName(budgetPreset);
|
|
144
|
+
const defaults = { quick: 10, normal: 50, deep: 100 };
|
|
145
|
+
normalized = {
|
|
146
|
+
...args,
|
|
147
|
+
maxChanges: params.maxChanges ?? (preset ? defaults[preset] : undefined),
|
|
148
|
+
};
|
|
149
|
+
} else if (method === 'dom.get_text') {
|
|
150
|
+
normalized = applyTextBudgetPreset(args);
|
|
151
|
+
} else if (method === 'dom.get_html') {
|
|
152
|
+
normalized = applyHtmlBudgetPreset(args);
|
|
153
|
+
} else if (method === 'page.get_text' || method === 'page.extract_content') {
|
|
154
|
+
normalized = applyPageTextBudgetPreset(args);
|
|
155
|
+
} else if (method === 'page.get_console') {
|
|
156
|
+
normalized = applyLimitBudgetPreset(args, {
|
|
157
|
+
quick: 10,
|
|
158
|
+
normal: DEFAULT_CONSOLE_LIMIT,
|
|
159
|
+
deep: 100,
|
|
160
|
+
});
|
|
161
|
+
} else if (method === 'page.get_network') {
|
|
162
|
+
normalized = applyLimitBudgetPreset(args, {
|
|
163
|
+
quick: 10,
|
|
164
|
+
normal: DEFAULT_NETWORK_LIMIT,
|
|
165
|
+
deep: 100,
|
|
166
|
+
});
|
|
167
|
+
} else if (method === 'network.export_har') {
|
|
168
|
+
normalized = applyLimitBudgetPreset(args, {
|
|
169
|
+
quick: 20,
|
|
170
|
+
normal: DEFAULT_HAR_LIMIT,
|
|
171
|
+
deep: 100,
|
|
172
|
+
});
|
|
173
|
+
} else if (method === 'log.tail') {
|
|
174
|
+
normalized = applyLimitBudgetPreset(args, {
|
|
175
|
+
quick: 10,
|
|
176
|
+
normal: DEFAULT_LOG_TAIL_LIMIT,
|
|
177
|
+
deep: 100,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const { budgetPreset: _budgetPreset, ...methodParams } = normalized;
|
|
181
|
+
return methodParams;
|
|
182
|
+
}
|
|
@@ -20,6 +20,7 @@ export const CAPABILITIES = Object.freeze({
|
|
|
20
20
|
PERFORMANCE_READ: 'performance.read',
|
|
21
21
|
NETWORK_READ: 'network.read',
|
|
22
22
|
NETWORK_INTERCEPT: 'network.intercept',
|
|
23
|
+
SENSITIVE_READ: 'sensitive.read',
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
export const DEFAULT_CAPABILITIES = Object.freeze([
|
|
@@ -42,4 +43,5 @@ export const DEFAULT_CAPABILITIES = Object.freeze([
|
|
|
42
43
|
CAPABILITIES.PERFORMANCE_READ,
|
|
43
44
|
CAPABILITIES.NETWORK_READ,
|
|
44
45
|
CAPABILITIES.NETWORK_INTERCEPT,
|
|
46
|
+
CAPABILITIES.SENSITIVE_READ,
|
|
45
47
|
]);
|
|
@@ -21,6 +21,9 @@ export const DEFAULT_MAX_HTML_LENGTH = 2000;
|
|
|
21
21
|
export const DEFAULT_A11Y_MAX_NODES = 500;
|
|
22
22
|
export const DEFAULT_A11Y_MAX_DEPTH = 6;
|
|
23
23
|
export const DEFAULT_NETWORK_LIMIT = 50;
|
|
24
|
+
export const DEFAULT_HAR_LIMIT = 50;
|
|
25
|
+
export const MAX_HAR_ENTRIES = 200;
|
|
26
|
+
export const HAR_AUTO_INLINE_BYTES = 262_144;
|
|
24
27
|
export const DEFAULT_CONSOLE_LIMIT = 50;
|
|
25
28
|
export const DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
26
29
|
export const DEFAULT_VIEWPORT_HEIGHT = 720;
|
|
@@ -34,6 +37,31 @@ export const MAX_NATIVE_MESSAGE_BYTES = 1_048_576;
|
|
|
34
37
|
/** Maximum size of one newline-delimited daemon socket message in bytes. */
|
|
35
38
|
export const MAX_JSON_LINE_BYTES = MAX_NATIVE_MESSAGE_BYTES;
|
|
36
39
|
|
|
40
|
+
/** Maximum UTF-8 size of an exact sensitive value returned atomically. */
|
|
41
|
+
export const MAX_SENSITIVE_VALUE_BYTES = 262_144;
|
|
42
|
+
|
|
43
|
+
/** Maximum calls accepted by any read-only batch surface. */
|
|
44
|
+
export const MAX_BATCH_CALLS = 20;
|
|
45
|
+
|
|
46
|
+
/** Maximum read-only batch calls executing concurrently. */
|
|
47
|
+
export const MAX_BATCH_CONCURRENCY = 5;
|
|
48
|
+
export const SCREENSHOT_AUTO_INLINE_BYTES = 262_144;
|
|
49
|
+
export const SCREENSHOT_MAX_INLINE_BYTES = 524_288;
|
|
50
|
+
export const ARTIFACT_CHUNK_BYTES = 196_608;
|
|
51
|
+
export const MAX_ARTIFACT_BYTES = 33_554_432;
|
|
52
|
+
export const MAX_ARTIFACT_CLIENT_BYTES = 67_108_864;
|
|
53
|
+
export const MAX_ARTIFACT_TOTAL_BYTES = 268_435_456;
|
|
54
|
+
export const MAX_ARTIFACTS_PER_CLIENT = 16;
|
|
55
|
+
export const ARTIFACT_TTL_MS = 300_000;
|
|
56
|
+
|
|
57
|
+
/** DOM baseline retention and storage limits. */
|
|
58
|
+
export const DOM_BASELINE_TTL_MS = 300_000;
|
|
59
|
+
export const MAX_DOM_BASELINES_PER_TAB = 8;
|
|
60
|
+
export const MAX_DOM_BASELINES_GLOBAL = 32;
|
|
61
|
+
export const MAX_DOM_BASELINE_BYTES_PER_TAB = 1_048_576;
|
|
62
|
+
export const MAX_DOM_BASELINE_BYTES_GLOBAL = 4_194_304;
|
|
63
|
+
export const MAX_DOM_BASELINE_BYTES = 262_144;
|
|
64
|
+
|
|
37
65
|
/** Default timeout for a bridge request awaiting an extension response (ms). */
|
|
38
66
|
export const DEFAULT_DAEMON_PENDING_TIMEOUT_MS = 30_000;
|
|
39
67
|
|
|
@@ -16,7 +16,15 @@ export const ERROR_CODES = Object.freeze({
|
|
|
16
16
|
DIALOG_NOT_OPEN: 'DIALOG_NOT_OPEN',
|
|
17
17
|
DIALOG_ACTION_CONFLICT: 'DIALOG_ACTION_CONFLICT',
|
|
18
18
|
RESULT_TRUNCATED: 'RESULT_TRUNCATED',
|
|
19
|
-
|
|
19
|
+
RESULT_TOO_LARGE: 'RESULT_TOO_LARGE',
|
|
20
|
+
SENSITIVE_TARGET_NOT_FOUND: 'SENSITIVE_TARGET_NOT_FOUND',
|
|
21
|
+
CONTENT_SCRIPT_UNAVAILABLE: 'CONTENT_SCRIPT_UNAVAILABLE',
|
|
22
|
+
ARTIFACT_NOT_FOUND: 'ARTIFACT_NOT_FOUND',
|
|
23
|
+
ARTIFACT_QUOTA_EXCEEDED: 'ARTIFACT_QUOTA_EXCEEDED',
|
|
24
|
+
ARTIFACT_TRANSFER_INVALID: 'ARTIFACT_TRANSFER_INVALID',
|
|
25
|
+
DOM_BASELINE_NOT_FOUND: 'DOM_BASELINE_NOT_FOUND',
|
|
26
|
+
DOM_BASELINE_INVALIDATED: 'DOM_BASELINE_INVALIDATED',
|
|
27
|
+
DOM_BASELINE_QUOTA_EXCEEDED: 'DOM_BASELINE_QUOTA_EXCEEDED',
|
|
20
28
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
21
29
|
INVALID_REQUEST: 'INVALID_REQUEST',
|
|
22
30
|
NATIVE_HOST_UNAVAILABLE: 'NATIVE_HOST_UNAVAILABLE',
|
|
@@ -38,6 +46,15 @@ export const ERROR_RECOVERY = Object.freeze({
|
|
|
38
46
|
retry: false,
|
|
39
47
|
hint: 'Result was truncated to fit the response budget. Narrow the query or raise the relevant budget if more detail is required.',
|
|
40
48
|
},
|
|
49
|
+
[ERROR_CODES.RESULT_TOO_LARGE]: {
|
|
50
|
+
retry: false,
|
|
51
|
+
hint: 'The result exceeds its atomic delivery limit. For screenshots, use artifact delivery, a smaller region, lower scale, or lossy compression. For sensitive reads, use a narrower exact target; partial values are never returned.',
|
|
52
|
+
},
|
|
53
|
+
[ERROR_CODES.SENSITIVE_TARGET_NOT_FOUND]: {
|
|
54
|
+
retry: false,
|
|
55
|
+
alternativeMethod: 'page.get_storage',
|
|
56
|
+
hint: 'The exact storage key does not exist. Use page.get_storage to inspect metadata before another deliberate sensitive read.',
|
|
57
|
+
},
|
|
41
58
|
[ERROR_CODES.ELEMENT_STALE]: {
|
|
42
59
|
retry: false,
|
|
43
60
|
alternativeMethod: 'dom.query',
|
|
@@ -97,10 +114,36 @@ export const ERROR_RECOVERY = Object.freeze({
|
|
|
97
114
|
retryAfterMs: 1000,
|
|
98
115
|
hint: 'Operation exceeded the time limit. Retry once, or simplify the request (smaller maxNodes, narrower selector).',
|
|
99
116
|
},
|
|
100
|
-
[ERROR_CODES.
|
|
101
|
-
retry:
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
[ERROR_CODES.CONTENT_SCRIPT_UNAVAILABLE]: {
|
|
118
|
+
retry: false,
|
|
119
|
+
hint: 'The page cannot host the Browser Bridge content script. Switch to a normal http(s) page in the enabled window.',
|
|
120
|
+
},
|
|
121
|
+
[ERROR_CODES.ARTIFACT_NOT_FOUND]: {
|
|
122
|
+
retry: false,
|
|
123
|
+
hint: 'The artifact is missing, expired, deleted, or owned by another client. Capture it again.',
|
|
124
|
+
},
|
|
125
|
+
[ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED]: {
|
|
126
|
+
retry: false,
|
|
127
|
+
hint: 'Artifact storage quota was exceeded. Delete unneeded artifacts or retry after expiry.',
|
|
128
|
+
},
|
|
129
|
+
[ERROR_CODES.ARTIFACT_TRANSFER_INVALID]: {
|
|
130
|
+
retry: false,
|
|
131
|
+
hint: 'Artifact transfer validation failed. Capture the screenshot again.',
|
|
132
|
+
},
|
|
133
|
+
[ERROR_CODES.DOM_BASELINE_NOT_FOUND]: {
|
|
134
|
+
retry: false,
|
|
135
|
+
alternativeMethod: 'dom.baseline.create',
|
|
136
|
+
hint: 'The DOM baseline is missing, expired, or released. Create a new baseline before comparing or describing it.',
|
|
137
|
+
},
|
|
138
|
+
[ERROR_CODES.DOM_BASELINE_INVALIDATED]: {
|
|
139
|
+
retry: false,
|
|
140
|
+
alternativeMethod: 'dom.baseline.create',
|
|
141
|
+
hint: 'The page document or baseline scope changed. Create a new baseline in the current document.',
|
|
142
|
+
},
|
|
143
|
+
[ERROR_CODES.DOM_BASELINE_QUOTA_EXCEEDED]: {
|
|
144
|
+
retry: false,
|
|
145
|
+
alternativeMethod: 'dom.baseline.release',
|
|
146
|
+
hint: 'DOM baseline storage quota was exceeded. Release unneeded baselines, narrow the selector, or reduce maxNodes and textBudget before creating another baseline.',
|
|
104
147
|
},
|
|
105
148
|
[ERROR_CODES.EXTENSION_DISCONNECTED]: {
|
|
106
149
|
retry: true,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal structural validation for HAR 1.2 documents shared by the CLI and
|
|
5
|
+
* the MCP server. Only the envelope is validated (`log.version` and the
|
|
6
|
+
* `entries` array); deeper field checks are left to HAR consumers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {unknown} value
|
|
11
|
+
* @returns {value is import('./types.js').HarLog}
|
|
12
|
+
*/
|
|
13
|
+
export function isHarLog(value) {
|
|
14
|
+
return Boolean(
|
|
15
|
+
value &&
|
|
16
|
+
typeof value === 'object' &&
|
|
17
|
+
!Array.isArray(value) &&
|
|
18
|
+
Reflect.get(value, 'version') === '1.2' &&
|
|
19
|
+
Array.isArray(Reflect.get(value, 'entries'))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @returns {value is import('./types.js').HarDocument}
|
|
26
|
+
*/
|
|
27
|
+
export function isHarDocument(value) {
|
|
28
|
+
return Boolean(
|
|
29
|
+
value &&
|
|
30
|
+
typeof value === 'object' &&
|
|
31
|
+
!Array.isArray(value) &&
|
|
32
|
+
isHarLog(Reflect.get(value, 'log'))
|
|
33
|
+
);
|
|
34
|
+
}
|