@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.
- package/PRIVACY.md +57 -12
- package/README.md +19 -8
- package/SECURITY.md +12 -0
- package/package.json +15 -6
- package/packages/agent-client/src/cli-args.js +59 -0
- package/packages/agent-client/src/cli-batch.js +121 -58
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli.js +272 -70
- package/packages/agent-client/src/client.js +296 -8
- package/packages/agent-client/src/command-registry.js +64 -55
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/content-extract.js +198 -0
- 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 +864 -39
- package/packages/agent-client/src/types.ts +169 -6
- package/packages/mcp-server/src/guidance.js +1 -1
- package/packages/mcp-server/src/handlers-capture.js +69 -6
- package/packages/mcp-server/src/handlers-dom.js +61 -4
- package/packages/mcp-server/src/handlers-navigation.js +2 -1
- package/packages/mcp-server/src/handlers-page.js +211 -54
- package/packages/mcp-server/src/handlers-utils.js +101 -10
- package/packages/mcp-server/src/handlers.js +14 -7
- package/packages/mcp-server/src/server.js +176 -11
- 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 +701 -33
- package/packages/native-host/src/native-host.js +28 -3
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +31 -0
- package/packages/protocol/src/errors.js +102 -6
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +612 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +206 -38
- package/packages/protocol/src/summary.js +203 -30
- package/packages/protocol/src/types.ts +721 -2
- package/skills/browser-bridge/SKILL.md +54 -19
- package/skills/browser-bridge/references/interaction.md +55 -11
- package/skills/browser-bridge/references/patch-workflow.md +2 -0
- package/skills/browser-bridge/references/protocol.md +265 -86
- 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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
'
|
|
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('
|
|
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
|
|
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([
|
|
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
|
+
}
|