@browserbridge/bbx 1.7.8 → 1.9.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 (44) hide show
  1. package/PRIVACY.md +57 -12
  2. package/README.md +19 -8
  3. package/SECURITY.md +12 -0
  4. package/package.json +15 -6
  5. package/packages/agent-client/src/cli-args.js +59 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +272 -70
  9. package/packages/agent-client/src/client.js +296 -8
  10. package/packages/agent-client/src/command-registry.js +64 -55
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/content-extract.js +198 -0
  13. package/packages/agent-client/src/npm-self-update.js +305 -0
  14. package/packages/agent-client/src/remotes.js +4 -2
  15. package/packages/agent-client/src/runtime.js +864 -39
  16. package/packages/agent-client/src/types.ts +169 -6
  17. package/packages/mcp-server/src/guidance.js +1 -1
  18. package/packages/mcp-server/src/handlers-capture.js +69 -6
  19. package/packages/mcp-server/src/handlers-dom.js +61 -4
  20. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  21. package/packages/mcp-server/src/handlers-page.js +211 -54
  22. package/packages/mcp-server/src/handlers-utils.js +101 -10
  23. package/packages/mcp-server/src/handlers.js +14 -7
  24. package/packages/mcp-server/src/server.js +176 -11
  25. package/packages/native-host/src/artifact-store.js +353 -0
  26. package/packages/native-host/src/config.js +9 -1
  27. package/packages/native-host/src/daemon-logger.js +3 -1
  28. package/packages/native-host/src/daemon.js +701 -33
  29. package/packages/native-host/src/native-host.js +28 -3
  30. package/packages/protocol/src/capability-values.js +2 -0
  31. package/packages/protocol/src/defaults.js +31 -0
  32. package/packages/protocol/src/errors.js +102 -6
  33. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  34. package/packages/protocol/src/index.js +2 -0
  35. package/packages/protocol/src/protocol.js +612 -6
  36. package/packages/protocol/src/recovery-telemetry.js +302 -0
  37. package/packages/protocol/src/registry.js +206 -38
  38. package/packages/protocol/src/summary.js +203 -30
  39. package/packages/protocol/src/types.ts +721 -2
  40. package/skills/browser-bridge/SKILL.md +54 -19
  41. package/skills/browser-bridge/references/interaction.md +55 -11
  42. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  43. package/skills/browser-bridge/references/protocol.md +265 -86
  44. package/skills/browser-bridge/references/token-efficiency.md +29 -13
@@ -19,6 +19,7 @@ import {
19
19
  handleLogTool,
20
20
  handleNavigationTool,
21
21
  handlePageTool,
22
+ handleSensitiveReadTool,
22
23
  handlePatchTool,
23
24
  handleRawCallTool,
24
25
  handleSetupTool,
@@ -33,6 +34,7 @@ import {
33
34
  BUDGET_PRESETS,
34
35
  DEFAULT_CONSOLE_LIMIT,
35
36
  DEFAULT_EVAL_TIMEOUT_MS,
37
+ DEFAULT_HAR_LIMIT,
36
38
  DEFAULT_LOG_TAIL_LIMIT,
37
39
  DEFAULT_MAX_DEPTH,
38
40
  DEFAULT_MAX_HTML_LENGTH,
@@ -41,6 +43,7 @@ import {
41
43
  DEFAULT_TEXT_BUDGET,
42
44
  DEFAULT_NAV_TIMEOUT_MS,
43
45
  DEFAULT_WAIT_TIMEOUT_MS,
46
+ MAX_HAR_ENTRIES,
44
47
  getMethodsByMaxComplexity,
45
48
  } from '../../protocol/src/index.js';
46
49
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
@@ -169,6 +172,10 @@ export function createBridgeMcpServer() {
169
172
  description: 'Ping the bridge to verify daemon and extension connectivity.',
170
173
  inputSchema: {
171
174
  destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
175
+ intent: z
176
+ .enum(['inspect', 'interact', 'capture', 'navigate', 'debugger', 'general'])
177
+ .optional()
178
+ .describe('Reported operation family shown in the access prompt (defaults to general).'),
172
179
  },
173
180
  },
174
181
  handleHealthTool
@@ -206,11 +213,15 @@ export function createBridgeMcpServer() {
206
213
  {
207
214
  title: 'Browser DOM',
208
215
  description:
209
- 'Query, describe, read, search, or wait for DOM elements. Reuse elementRef from prior results. For full-page text, use browser_page action "text". accessibility_tree is debugger-backed - use query/find first.',
216
+ 'Query, describe, read, search, or wait for DOM elements. Reuse elementRef from prior results. For full-page text, use browser_page action "text". accessibility_tree is debugger-backed and depth-limited - use query/find first.',
210
217
  inputSchema: {
211
218
  action: z
212
219
  .enum([
213
220
  'query',
221
+ 'baseline_create',
222
+ 'baseline_compare',
223
+ 'baseline_describe',
224
+ 'baseline_release',
214
225
  'describe',
215
226
  'text',
216
227
  'attributes',
@@ -230,7 +241,17 @@ export function createBridgeMcpServer() {
230
241
  selector: z
231
242
  .string()
232
243
  .optional()
233
- .describe('CSS selector (used if no elementRef; resolves to first match)'),
244
+ .describe(
245
+ 'CSS selector (used if no elementRef; accessibility_tree requires exactly one match)'
246
+ ),
247
+ baselineId: z.string().optional().describe('Opaque semantic DOM baseline identifier'),
248
+ maxChanges: z
249
+ .number()
250
+ .int()
251
+ .positive()
252
+ .max(200)
253
+ .optional()
254
+ .describe('Maximum returned change examples; exact counts are always retained'),
234
255
  elementRef: z
235
256
  .string()
236
257
  .optional()
@@ -248,6 +269,16 @@ export function createBridgeMcpServer() {
248
269
  .positive()
249
270
  .optional()
250
271
  .describe(`Maximum tree depth (default: ${DEFAULT_MAX_DEPTH})`),
272
+ compact: z
273
+ .boolean()
274
+ .optional()
275
+ .describe('Remove ignored, empty, and decorative AX nodes while preserving descendants'),
276
+ interactiveOnly: z
277
+ .boolean()
278
+ .optional()
279
+ .describe(
280
+ 'Return semantically interactive AX nodes; actionable state is reported separately'
281
+ ),
251
282
  textBudget: z
252
283
  .number()
253
284
  .int()
@@ -327,7 +358,9 @@ export function createBridgeMcpServer() {
327
358
  properties: z
328
359
  .array(z.string())
329
360
  .optional()
330
- .describe('Style properties to fetch (omitting returns all - expensive)'),
361
+ .describe(
362
+ 'Style properties to fetch (omitting returns display, position, width, height, and color)'
363
+ ),
331
364
  x: z
332
365
  .number()
333
366
  .nonnegative()
@@ -343,25 +376,49 @@ export function createBridgeMcpServer() {
343
376
  handleStylesLayoutTool
344
377
  );
345
378
 
379
+ server.registerTool(
380
+ 'browser_sensitive_read',
381
+ {
382
+ title: 'Sensitive Browser Read',
383
+ description:
384
+ 'High-risk exact-value read for one local or session storage key. This operation is logged as Sensitive access, is never batched or retried, and returns the whole value or fails atomically.',
385
+ inputSchema: {
386
+ source: z.enum(['local_storage', 'session_storage']).describe('Exact storage source'),
387
+ key: z
388
+ .string()
389
+ .max(2_048)
390
+ .describe('Exact storage key, including an empty key if intended'),
391
+ tabId: z.number().int().positive().optional().describe(TAB_ID_DESCRIPTION),
392
+ destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
393
+ },
394
+ },
395
+ handleSensitiveReadTool
396
+ );
397
+
346
398
  server.registerTool(
347
399
  'browser_page',
348
400
  {
349
401
  title: 'Browser Page State',
350
402
  description:
351
- 'Read page-level data: state (URL/title), evaluate (JS), console, storage, text, network, or performance. For element-level reads, use browser_dom. evaluate and performance are debugger-backed - prefer lighter reads first.',
403
+ 'Read page-level data, export captured network evidence as HAR 1.2, wait for load/URL conditions, or explicitly inspect/handle JavaScript dialogs. The performance action returns a raw CDP Performance.getMetrics point sample: Chrome defines names and units, which vary, while BBX sets no navigation observation window and does not measure LCP, CLS, or INP. For element-level reads, use browser_dom. evaluate, performance, handle_dialog, and source=cdp network capture are debugger-backed.',
352
404
  inputSchema: {
353
405
  action: z
354
406
  .enum([
355
407
  'state',
356
408
  'evaluate',
357
409
  'console',
410
+ 'handle_dialog',
358
411
  'wait_for_load',
359
412
  'storage',
360
413
  'text',
414
+ 'extract_content',
361
415
  'network',
416
+ 'har',
362
417
  'performance',
363
418
  ])
364
- .describe('Page operation to perform'),
419
+ .describe(
420
+ 'Page operation to perform; performance reads raw Chrome/CDP counters, not LCP, CLS, or INP'
421
+ ),
365
422
  tabId: z.number().int().positive().optional().describe(TAB_ID_DESCRIPTION),
366
423
  destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
367
424
  budgetPreset: z
@@ -373,6 +430,23 @@ export function createBridgeMcpServer() {
373
430
  .optional()
374
431
  .describe('JavaScript expression to evaluate (for evaluate action)'),
375
432
  awaitPromise: z.boolean().optional().describe('Await returned promises (default: false)'),
433
+ dialogAction: z
434
+ .enum(['inspect', 'accept', 'dismiss'])
435
+ .optional()
436
+ .describe('Dialog operation on the current dialog (default: inspect)'),
437
+ promptText: z
438
+ .string()
439
+ .max(10_000)
440
+ .optional()
441
+ .describe('Prompt response text; only valid when dialogAction is accept'),
442
+ expectedDialogId: z
443
+ .string()
444
+ .min(1)
445
+ .max(128)
446
+ .optional()
447
+ .describe(
448
+ 'Optional stale-decision check immediately before CDP dispatch; Chrome cannot atomically bind the action to this identifier'
449
+ ),
376
450
  timeoutMs: z
377
451
  .number()
378
452
  .int()
@@ -397,7 +471,9 @@ export function createBridgeMcpServer() {
397
471
  .int()
398
472
  .positive()
399
473
  .optional()
400
- .describe(`Maximum entries to return (default: ${DEFAULT_CONSOLE_LIMIT})`),
474
+ .describe(
475
+ `Maximum entries to return (console default: ${DEFAULT_CONSOLE_LIMIT}; HAR default: ${DEFAULT_HAR_LIMIT}, max: ${MAX_HAR_ENTRIES})`
476
+ ),
401
477
  type: z
402
478
  .enum(['local', 'session'])
403
479
  .optional()
@@ -412,7 +488,51 @@ export function createBridgeMcpServer() {
412
488
  .positive()
413
489
  .optional()
414
490
  .describe(`Max chars for page text (default: ${DEFAULT_PAGE_TEXT_BUDGET})`),
415
- urlPattern: z.string().optional().describe('Filter network entries by URL pattern'),
491
+ format: z
492
+ .enum(['text', 'markdown'])
493
+ .optional()
494
+ .describe('Semantic content format (default: text)'),
495
+ selector: z.string().optional().describe('Scope semantic extraction to one CSS selector'),
496
+ includeMetadata: z
497
+ .boolean()
498
+ .optional()
499
+ .describe('Include bounded article metadata (default: true)'),
500
+ consistency: z
501
+ .enum(['best_effort', 'settled'])
502
+ .optional()
503
+ .describe('Optionally wait for a short mutation-quiet interval'),
504
+ settleTimeoutMs: z
505
+ .number()
506
+ .int()
507
+ .positive()
508
+ .optional()
509
+ .describe('Maximum semantic extraction settlement wait (default: 2000)'),
510
+ urlPattern: z
511
+ .string()
512
+ .max(2_048)
513
+ .optional()
514
+ .describe('Bounded URL pattern used to filter network entries or HAR export evidence'),
515
+ source: z
516
+ .enum(['fetch-xhr', 'cdp'])
517
+ .optional()
518
+ .describe('Network source (default: fetch-xhr; cdp captures all resource types)'),
519
+ capture: z
520
+ .enum(['read', 'start', 'clear', 'stop'])
521
+ .optional()
522
+ .describe('Explicit CDP capture lifecycle action (default: read)'),
523
+ delivery: z
524
+ .enum(['auto', 'inline', 'artifact'])
525
+ .optional()
526
+ .describe('HAR delivery mode: auto chooses inline or a daemon-owned artifact'),
527
+ waitForLoad: z
528
+ .boolean()
529
+ .optional()
530
+ .describe('For wait_for_load, also require tab status complete (default: true)'),
531
+ url: z.string().optional().describe('URL condition for wait_for_load'),
532
+ urlMatch: z
533
+ .enum(['exact', 'contains', 'regex'])
534
+ .optional()
535
+ .describe('URL match mode for wait_for_load (default: exact)'),
416
536
  },
417
537
  },
418
538
  handlePageTool
@@ -467,7 +587,7 @@ export function createBridgeMcpServer() {
467
587
  {
468
588
  title: 'Browser Input',
469
589
  description:
470
- 'Simulate user input: click, focus, type, press keys, CDP key events, set checked, select options, hover, drag, or scroll into view. Reuse elementRef from prior queries.',
590
+ 'Dispatch browser input. Targeted click, focus, type, fill, press_key, set_checked, select_option, hover, and drag actions perform actionability checks and report resolution/execution metadata. cdp_press_key and scroll_into_view use separate contracts. Reuse elementRef values and verify application state after mutations.',
471
591
  inputSchema: {
472
592
  action: z
473
593
  .enum([
@@ -512,6 +632,18 @@ export function createBridgeMcpServer() {
512
632
  .enum(['auto', 'setter', 'keystrokes'])
513
633
  .optional()
514
634
  .describe('Fill strategy (default: auto)'),
635
+ executionMode: z
636
+ .enum(['dom', 'cdp'])
637
+ .optional()
638
+ .describe(
639
+ 'Input execution path (default: dom; CDP supports click, hover, drag, type, fill)'
640
+ ),
641
+ recoverStale: z
642
+ .boolean()
643
+ .optional()
644
+ .describe(
645
+ 'Strict bounded same-document stale elementRef recovery for targeted input actions (default: false; not used by cdp_press_key or scroll_into_view)'
646
+ ),
515
647
  clear: z.boolean().optional().describe('Clear field before typing (default: false)'),
516
648
  submit: z.boolean().optional().describe('Press Enter after typing (default: false)'),
517
649
  key: z
@@ -541,7 +673,7 @@ export function createBridgeMcpServer() {
541
673
  .int()
542
674
  .nonnegative()
543
675
  .optional()
544
- .describe('Hover duration in ms (default: 0)'),
676
+ .describe('Delay after hover dispatch in ms; does not end hover (default: 0)'),
545
677
  sourceElementRef: z.string().optional().describe('Drag source element (for drag action)'),
546
678
  sourceSelector: z
547
679
  .string()
@@ -567,7 +699,7 @@ export function createBridgeMcpServer() {
567
699
  {
568
700
  title: 'Browser Patch',
569
701
  description:
570
- 'Apply or rollback reversible style and DOM patches for live prototyping before editing source. Set verify=true to get computed results inline without a follow-up query.',
702
+ 'Apply or rollback document-local style and DOM patches for live prototyping before editing source. commit_baseline keeps current changes but discards rollback history. Set verify=true to get computed results inline.',
571
703
  inputSchema: {
572
704
  action: z
573
705
  .enum(['apply_styles', 'apply_dom', 'list', 'rollback', 'commit_baseline'])
@@ -589,7 +721,14 @@ export function createBridgeMcpServer() {
589
721
  .describe('CSS property: value pairs (for apply_styles)'),
590
722
  important: z.boolean().optional().describe('Add !important flag (default: false)'),
591
723
  operation: z
592
- .enum(['setAttribute', 'removeAttribute', 'addClass', 'removeClass', 'setTextContent'])
724
+ .enum([
725
+ 'setAttribute',
726
+ 'removeAttribute',
727
+ 'addClass',
728
+ 'removeClass',
729
+ 'toggleClass',
730
+ 'setTextContent',
731
+ ])
593
732
  .optional()
594
733
  .describe('DOM mutation type'),
595
734
  value: z.unknown().optional().describe('Value for the DOM operation'),
@@ -643,6 +782,11 @@ export function createBridgeMcpServer() {
643
782
  .positive()
644
783
  .optional()
645
784
  .describe('CDP node id for cdp_box_model/cdp_computed_styles'),
785
+ computedStyles: z
786
+ .array(z.string().min(1).max(128))
787
+ .max(100)
788
+ .optional()
789
+ .describe('CSS property names to include for cdp_dom_snapshot'),
646
790
  rect: z
647
791
  .object({
648
792
  x: z.number().nonnegative().describe('Region left edge (viewport pixels)'),
@@ -652,6 +796,27 @@ export function createBridgeMcpServer() {
652
796
  })
653
797
  .optional()
654
798
  .describe('Viewport region for region action (keep crop tight)'),
799
+ format: z
800
+ .enum(['png', 'jpeg', 'webp'])
801
+ .optional()
802
+ .describe('Screenshot encoding (default: png)'),
803
+ quality: z
804
+ .number()
805
+ .int()
806
+ .min(0)
807
+ .max(100)
808
+ .optional()
809
+ .describe('JPEG/WebP quality from 0 to 100; ignored for PNG'),
810
+ delivery: z
811
+ .enum(['auto', 'inline', 'artifact'])
812
+ .optional()
813
+ .describe('Delivery mode: auto chooses inline or a daemon-owned artifact'),
814
+ scale: z
815
+ .number()
816
+ .min(0.1)
817
+ .max(4)
818
+ .optional()
819
+ .describe('Screenshot capture scale (0.1 to 4)'),
655
820
  },
656
821
  },
657
822
  handleCaptureTool
@@ -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
+ }