@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.
Files changed (48) hide show
  1. package/PRIVACY.md +29 -3
  2. package/README.md +10 -3
  3. package/SECURITY.md +12 -0
  4. package/package.json +7 -1
  5. package/packages/agent-client/src/cli-args.js +70 -6
  6. package/packages/agent-client/src/cli-batch.js +127 -59
  7. package/packages/agent-client/src/cli-helpers.js +8 -0
  8. package/packages/agent-client/src/cli-output.js +8 -1
  9. package/packages/agent-client/src/cli-setup-commands.js +3 -1
  10. package/packages/agent-client/src/cli.js +254 -26
  11. package/packages/agent-client/src/client.js +156 -3
  12. package/packages/agent-client/src/command-registry.js +14 -4
  13. package/packages/agent-client/src/config.js +195 -0
  14. package/packages/agent-client/src/mcp-config.js +8 -8
  15. package/packages/agent-client/src/npm-self-update.js +305 -0
  16. package/packages/agent-client/src/remotes.js +4 -2
  17. package/packages/agent-client/src/runtime.js +99 -7
  18. package/packages/agent-client/src/types.ts +38 -0
  19. package/packages/mcp-server/src/guidance.js +37 -6
  20. package/packages/mcp-server/src/handlers-capture.js +29 -2
  21. package/packages/mcp-server/src/handlers-dom.js +54 -3
  22. package/packages/mcp-server/src/handlers-page.js +333 -60
  23. package/packages/mcp-server/src/handlers-utils.js +141 -151
  24. package/packages/mcp-server/src/handlers.js +17 -4
  25. package/packages/mcp-server/src/server.js +269 -51
  26. package/packages/mcp-server/src/toolset.js +48 -0
  27. package/packages/native-host/src/artifact-store.js +353 -0
  28. package/packages/native-host/src/config.js +9 -1
  29. package/packages/native-host/src/daemon-logger.js +3 -1
  30. package/packages/native-host/src/daemon.js +457 -27
  31. package/packages/native-host/src/dom-baseline-owners.js +210 -0
  32. package/packages/native-host/src/native-host.js +38 -29
  33. package/packages/protocol/src/budget-presets.js +182 -0
  34. package/packages/protocol/src/capability-values.js +2 -0
  35. package/packages/protocol/src/defaults.js +28 -0
  36. package/packages/protocol/src/errors.js +48 -5
  37. package/packages/protocol/src/har.js +34 -0
  38. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  39. package/packages/protocol/src/index.js +4 -0
  40. package/packages/protocol/src/json-lines.js +3 -2
  41. package/packages/protocol/src/protocol.js +316 -6
  42. package/packages/protocol/src/recovery-telemetry.js +302 -0
  43. package/packages/protocol/src/registry.js +226 -6
  44. package/packages/protocol/src/summary.js +116 -25
  45. package/packages/protocol/src/types.ts +490 -4
  46. package/skills/browser-bridge/SKILL.md +26 -8
  47. package/skills/browser-bridge/references/protocol.md +125 -15
  48. package/skills/browser-bridge/references/token-efficiency.md +17 -7
@@ -0,0 +1,353 @@
1
+ // @ts-check
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ import {
8
+ ARTIFACT_CHUNK_BYTES,
9
+ ARTIFACT_TTL_MS,
10
+ BridgeError,
11
+ ERROR_CODES,
12
+ MAX_ARTIFACT_BYTES,
13
+ MAX_ARTIFACT_CLIENT_BYTES,
14
+ MAX_ARTIFACT_TOTAL_BYTES,
15
+ MAX_ARTIFACTS_PER_CLIENT,
16
+ } from '../../protocol/src/index.js';
17
+
18
+ /** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
19
+ /** @typedef {{ artifactId: string, requestId: string, ownerId: string, extensionId: string, kind: ArtifactKind, mimeType: string, totalBytes: number, sha256: string, chunkCount: number, createdAt: string, expiresAt: string, fd: number, temporaryPath: string, finalPath: string, written: number, nextChunk: number, hash: import('node:crypto').Hash }} ArtifactTransfer */
20
+ /** @typedef {{ artifactId: string, requestId: string, ownerId: string, extensionId: string, kind: ArtifactKind, mimeType: string, byteLength: number, sha256: string, chunkCount: number, createdAt: string, expiresAt: string, filePath: string }} ArtifactRecord */
21
+
22
+ export class ArtifactStore {
23
+ /** @param {string} rootPath @param {() => number} [now] */
24
+ constructor(rootPath, now = Date.now) {
25
+ this.rootPath = rootPath;
26
+ this.now = now;
27
+ /** @type {Map<string, ArtifactTransfer>} */
28
+ this.transfers = new Map();
29
+ /** @type {Map<string, ArtifactRecord>} */
30
+ this.artifacts = new Map();
31
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
32
+ this.expiryTimers = new Map();
33
+ }
34
+
35
+ reset() {
36
+ for (const transfer of this.transfers.values()) {
37
+ try {
38
+ fs.closeSync(transfer.fd);
39
+ } catch {}
40
+ }
41
+ for (const timer of this.expiryTimers.values()) clearTimeout(timer);
42
+ fs.rmSync(this.rootPath, { recursive: true, force: true });
43
+ fs.mkdirSync(this.rootPath, { recursive: true, mode: 0o700 });
44
+ if (process.platform !== 'win32') fs.chmodSync(this.rootPath, 0o700);
45
+ this.transfers.clear();
46
+ this.artifacts.clear();
47
+ this.expiryTimers.clear();
48
+ }
49
+
50
+ /** @param {Omit<ArtifactTransfer, 'fd' | 'temporaryPath' | 'finalPath' | 'written' | 'nextChunk' | 'hash'>} input */
51
+ begin(input) {
52
+ this.pruneExpired();
53
+ if (!/^art_[A-Za-z0-9_-]{32,64}$/u.test(input.artifactId)) {
54
+ throw new BridgeError(
55
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
56
+ 'Artifact identifier is invalid.'
57
+ );
58
+ }
59
+ if (
60
+ !Number.isInteger(input.totalBytes) ||
61
+ input.totalBytes < 1 ||
62
+ input.totalBytes > MAX_ARTIFACT_BYTES ||
63
+ input.chunkCount !== Math.ceil(input.totalBytes / ARTIFACT_CHUNK_BYTES)
64
+ ) {
65
+ throw new BridgeError(ERROR_CODES.ARTIFACT_TRANSFER_INVALID, 'Artifact size is invalid.');
66
+ }
67
+ const now = this.now();
68
+ const createdAt = Date.parse(input.createdAt);
69
+ const expiresAt = Date.parse(input.expiresAt);
70
+ if (
71
+ !/^[a-f0-9]{64}$/u.test(input.sha256) ||
72
+ !isValidKindAndMimeType(input.kind, input.mimeType) ||
73
+ !Number.isFinite(createdAt) ||
74
+ !Number.isFinite(expiresAt) ||
75
+ createdAt > now + 10_000 ||
76
+ createdAt < now - ARTIFACT_TTL_MS ||
77
+ expiresAt <= now ||
78
+ expiresAt <= createdAt ||
79
+ expiresAt > now + ARTIFACT_TTL_MS
80
+ ) {
81
+ throw new BridgeError(ERROR_CODES.ARTIFACT_TRANSFER_INVALID, 'Artifact metadata is invalid.');
82
+ }
83
+ if (this.transfers.has(input.artifactId) || this.artifacts.has(input.artifactId)) {
84
+ throw new BridgeError(
85
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
86
+ 'Artifact identifier is already in use.'
87
+ );
88
+ }
89
+ const ownedArtifacts = [...this.artifacts.values()].filter(
90
+ (item) => item.ownerId === input.ownerId
91
+ );
92
+ const ownedTransfers = [...this.transfers.values()].filter(
93
+ (item) => item.ownerId === input.ownerId
94
+ );
95
+ const clientBytes =
96
+ ownedArtifacts.reduce((total, item) => total + item.byteLength, 0) +
97
+ ownedTransfers.reduce((total, item) => total + item.totalBytes, 0);
98
+ const totalBytes =
99
+ [...this.artifacts.values()].reduce((total, item) => total + item.byteLength, 0) +
100
+ [...this.transfers.values()].reduce((total, item) => total + item.totalBytes, 0);
101
+ if (
102
+ ownedArtifacts.length + ownedTransfers.length >= MAX_ARTIFACTS_PER_CLIENT ||
103
+ clientBytes + input.totalBytes > MAX_ARTIFACT_CLIENT_BYTES ||
104
+ totalBytes + input.totalBytes > MAX_ARTIFACT_TOTAL_BYTES
105
+ ) {
106
+ throw new BridgeError(ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED, 'Artifact quota exceeded.');
107
+ }
108
+ const temporaryPath = path.join(this.rootPath, `${input.artifactId}.part`);
109
+ const finalPath = path.join(this.rootPath, `${input.artifactId}.bin`);
110
+ const fd = fs.openSync(temporaryPath, 'wx', 0o600);
111
+ this.transfers.set(input.artifactId, {
112
+ ...input,
113
+ fd,
114
+ temporaryPath,
115
+ finalPath,
116
+ written: 0,
117
+ nextChunk: 0,
118
+ hash: createHash('sha256'),
119
+ });
120
+ }
121
+
122
+ /** @param {string} artifactId @param {number} chunkIndex @param {string} data */
123
+ writeChunk(artifactId, chunkIndex, data) {
124
+ const transfer = this.transfers.get(artifactId);
125
+ if (!transfer || chunkIndex !== transfer.nextChunk) {
126
+ throw new BridgeError(
127
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
128
+ 'Artifact chunk order is invalid.'
129
+ );
130
+ }
131
+ const bytes = decodeBase64(data);
132
+ if (bytes.length < 1 || bytes.length > ARTIFACT_CHUNK_BYTES) {
133
+ throw new BridgeError(
134
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
135
+ 'Artifact chunk size is invalid.'
136
+ );
137
+ }
138
+ if (transfer.written + bytes.length > transfer.totalBytes) {
139
+ throw new BridgeError(
140
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
141
+ 'Artifact exceeds declared size.'
142
+ );
143
+ }
144
+ fs.writeSync(transfer.fd, bytes);
145
+ transfer.hash.update(bytes);
146
+ transfer.written += bytes.length;
147
+ transfer.nextChunk += 1;
148
+ }
149
+
150
+ /** @param {string} artifactId @returns {ArtifactRecord} */
151
+ commit(artifactId) {
152
+ const transfer = this.transfers.get(artifactId);
153
+ if (!transfer) {
154
+ throw new BridgeError(ERROR_CODES.ARTIFACT_TRANSFER_INVALID, 'Artifact transfer is missing.');
155
+ }
156
+ const digest = transfer.hash.digest('hex');
157
+ if (
158
+ transfer.written !== transfer.totalBytes ||
159
+ transfer.nextChunk !== transfer.chunkCount ||
160
+ digest !== transfer.sha256
161
+ ) {
162
+ this.abort(artifactId);
163
+ throw new BridgeError(ERROR_CODES.ARTIFACT_TRANSFER_INVALID, 'Artifact checksum is invalid.');
164
+ }
165
+ fs.closeSync(transfer.fd);
166
+ fs.renameSync(transfer.temporaryPath, transfer.finalPath);
167
+ if (process.platform !== 'win32') fs.chmodSync(transfer.finalPath, 0o600);
168
+ const record = {
169
+ artifactId: transfer.artifactId,
170
+ requestId: transfer.requestId,
171
+ ownerId: transfer.ownerId,
172
+ extensionId: transfer.extensionId,
173
+ kind: transfer.kind,
174
+ mimeType: transfer.mimeType,
175
+ byteLength: transfer.totalBytes,
176
+ sha256: digest,
177
+ chunkCount: transfer.chunkCount,
178
+ createdAt: transfer.createdAt,
179
+ expiresAt: transfer.expiresAt,
180
+ filePath: transfer.finalPath,
181
+ };
182
+ this.transfers.delete(artifactId);
183
+ this.artifacts.set(artifactId, record);
184
+ const timer = setTimeout(
185
+ () => this.removeCommitted(artifactId),
186
+ Math.max(0, Date.parse(record.expiresAt) - this.now())
187
+ );
188
+ timer.unref?.();
189
+ this.expiryTimers.set(artifactId, timer);
190
+ return record;
191
+ }
192
+
193
+ /** @param {string} artifactId @param {string} ownerId @param {number} offset @param {number} maxBytes */
194
+ read(artifactId, ownerId, offset, maxBytes) {
195
+ const record = this.getOwned(artifactId, ownerId);
196
+ const start = Math.min(offset, record.byteLength);
197
+ const byteLength = Math.min(maxBytes, record.byteLength - start);
198
+ const bytes = Buffer.alloc(byteLength);
199
+ const fd = fs.openSync(record.filePath, 'r');
200
+ try {
201
+ fs.readSync(fd, bytes, 0, byteLength, start);
202
+ } finally {
203
+ fs.closeSync(fd);
204
+ }
205
+ const nextOffset = start + byteLength < record.byteLength ? start + byteLength : null;
206
+ return {
207
+ artifactId,
208
+ data: bytes.toString('base64'),
209
+ offset: start,
210
+ byteLength,
211
+ chunkIndex: Math.floor(start / ARTIFACT_CHUNK_BYTES),
212
+ chunkCount: record.chunkCount,
213
+ nextOffset,
214
+ totalBytes: record.byteLength,
215
+ sha256: record.sha256,
216
+ expiresAt: record.expiresAt,
217
+ };
218
+ }
219
+
220
+ /** @param {string} artifactId @param {string} ownerId */
221
+ delete(artifactId, ownerId) {
222
+ this.getOwned(artifactId, ownerId);
223
+ this.removeCommitted(artifactId);
224
+ return { artifactId, deleted: true };
225
+ }
226
+
227
+ /** @param {string} artifactId @param {string} ownerId @param {string} requestId @param {ArtifactKind} [kind] */
228
+ ownsCommitted(artifactId, ownerId, requestId, kind) {
229
+ const record = this.artifacts.get(artifactId);
230
+ return (
231
+ record?.ownerId === ownerId &&
232
+ record.requestId === requestId &&
233
+ (kind === undefined || record.kind === kind)
234
+ );
235
+ }
236
+
237
+ /**
238
+ * @param {string} artifactId
239
+ * @param {string} ownerId
240
+ * @param {string} requestId
241
+ * @param {Record<string, unknown>} descriptor
242
+ * @param {ArtifactKind | undefined} expectedKind
243
+ */
244
+ matchesCommitted(artifactId, ownerId, requestId, descriptor, expectedKind) {
245
+ const record = this.artifacts.get(artifactId);
246
+ return Boolean(
247
+ record &&
248
+ record.ownerId === ownerId &&
249
+ record.requestId === requestId &&
250
+ (expectedKind === undefined || record.kind === expectedKind) &&
251
+ descriptor.kind === record.kind &&
252
+ descriptor.mimeType === record.mimeType &&
253
+ descriptor.byteLength === record.byteLength &&
254
+ descriptor.sha256 === record.sha256 &&
255
+ descriptor.chunkSize === ARTIFACT_CHUNK_BYTES &&
256
+ descriptor.chunkCount === record.chunkCount &&
257
+ descriptor.createdAt === record.createdAt &&
258
+ descriptor.expiresAt === record.expiresAt
259
+ );
260
+ }
261
+
262
+ /** @param {string} requestId */
263
+ abortRequest(requestId) {
264
+ for (const transfer of this.transfers.values()) {
265
+ if (transfer.requestId === requestId) this.abort(transfer.artifactId);
266
+ }
267
+ }
268
+
269
+ /** @param {string} requestId */
270
+ deleteRequest(requestId) {
271
+ this.abortRequest(requestId);
272
+ for (const record of [...this.artifacts.values()]) {
273
+ if (record.requestId === requestId) this.removeCommitted(record.artifactId);
274
+ }
275
+ }
276
+
277
+ /** @param {string} extensionId */
278
+ deleteByExtension(extensionId) {
279
+ for (const transfer of [...this.transfers.values()]) {
280
+ if (transfer.extensionId === extensionId) this.abort(transfer.artifactId);
281
+ }
282
+ for (const record of this.artifacts.values()) {
283
+ if (record.extensionId === extensionId) {
284
+ this.removeCommitted(record.artifactId);
285
+ }
286
+ }
287
+ }
288
+
289
+ pruneExpired() {
290
+ const now = this.now();
291
+ for (const transfer of [...this.transfers.values()]) {
292
+ if (Date.parse(transfer.expiresAt) <= now) this.abort(transfer.artifactId);
293
+ }
294
+ for (const record of this.artifacts.values()) {
295
+ if (Date.parse(record.expiresAt) <= now) this.removeCommitted(record.artifactId);
296
+ }
297
+ }
298
+
299
+ /** @param {string} artifactId */
300
+ abort(artifactId) {
301
+ const transfer = this.transfers.get(artifactId);
302
+ if (!transfer) return;
303
+ this.transfers.delete(artifactId);
304
+ try {
305
+ fs.closeSync(transfer.fd);
306
+ } catch {}
307
+ fs.rmSync(transfer.temporaryPath, { force: true });
308
+ }
309
+
310
+ /** @param {string} artifactId */
311
+ removeCommitted(artifactId) {
312
+ const record = this.artifacts.get(artifactId);
313
+ if (!record) return;
314
+ this.artifacts.delete(artifactId);
315
+ const timer = this.expiryTimers.get(artifactId);
316
+ if (timer) clearTimeout(timer);
317
+ this.expiryTimers.delete(artifactId);
318
+ fs.rmSync(record.filePath, { force: true });
319
+ }
320
+
321
+ /** @param {string} artifactId @param {string} ownerId */
322
+ getOwned(artifactId, ownerId) {
323
+ this.pruneExpired();
324
+ const record = this.artifacts.get(artifactId);
325
+ if (!record || record.ownerId !== ownerId) {
326
+ throw new BridgeError(ERROR_CODES.ARTIFACT_NOT_FOUND, 'Artifact was not found.');
327
+ }
328
+ return record;
329
+ }
330
+ }
331
+
332
+ /** @param {ArtifactKind} kind @param {string} mimeType */
333
+ function isValidKindAndMimeType(kind, mimeType) {
334
+ return (
335
+ (kind === 'screenshot' && mimeType.startsWith('image/')) ||
336
+ (kind === 'har' && mimeType === 'application/json')
337
+ );
338
+ }
339
+
340
+ /** @param {string} value */
341
+ function decodeBase64(value) {
342
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
343
+ throw new BridgeError(ERROR_CODES.ARTIFACT_TRANSFER_INVALID, 'Artifact chunk is not base64.');
344
+ }
345
+ const bytes = Buffer.from(value, 'base64');
346
+ if (bytes.toString('base64') !== value) {
347
+ throw new BridgeError(
348
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
349
+ 'Artifact chunk is not canonical base64.'
350
+ );
351
+ }
352
+ return bytes;
353
+ }
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
+ import { sanitizeIncidentalPath, sanitizeIncidentalText } from '../../protocol/src/index.js';
7
8
 
8
9
  export const APP_NAME = 'com.browserbridge.browser_bridge';
9
10
  export const BRIDGE_HOME_ENV = 'BROWSER_BRIDGE_HOME';
@@ -58,6 +59,11 @@ export function getBridgeDir(env = process.env) {
58
59
  return path.join(xdgDataHome, 'browser-bridge');
59
60
  }
60
61
 
62
+ /** @param {NodeJS.ProcessEnv} [env=process.env] */
63
+ export function getArtifactStorePath(env = process.env) {
64
+ return path.join(getBridgeDir(env), 'artifacts');
65
+ }
66
+
61
67
  /**
62
68
  * Resolve the IPC endpoint the daemon listens on and the CLI / native host
63
69
  * connect to. On Windows we use a Named Pipe by default because Node's AF_UNIX
@@ -125,7 +131,9 @@ function normalizePort(value) {
125
131
  * @returns {void}
126
132
  */
127
133
  function warnProxyConfigIgnored(configPath, reason) {
128
- console.error(`browser-bridge: ignoring proxy config at ${configPath}: ${reason}`);
134
+ console.error(
135
+ `browser-bridge: ignoring proxy config at ${sanitizeIncidentalPath(configPath)}: ${sanitizeIncidentalText(reason)}`
136
+ );
129
137
  }
130
138
 
131
139
  /**
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import { sanitizeIncidentalValue } from '../../protocol/src/index.js';
4
+
3
5
  /** @typedef {{ write: (chunk: string) => void }} LogStream */
4
6
 
5
7
  /**
@@ -62,7 +64,7 @@ export class DaemonLogger {
62
64
  message,
63
65
  ...extra,
64
66
  };
65
- this.#stream.write(`${JSON.stringify(entry)}\n`);
67
+ this.#stream.write(`${JSON.stringify(sanitizeIncidentalValue(entry))}\n`);
66
68
  }
67
69
 
68
70
  /**