@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
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
|
|
9
11
|
import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
|
|
@@ -16,6 +18,8 @@ import {
|
|
|
16
18
|
import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
|
|
17
19
|
import {
|
|
18
20
|
extractRemoteFlag,
|
|
21
|
+
extractHarFlags,
|
|
22
|
+
extractScreenshotFlags,
|
|
19
23
|
extractTabFlag,
|
|
20
24
|
parseCallCommand,
|
|
21
25
|
parseInterceptAddArgs,
|
|
@@ -31,12 +35,16 @@ import {
|
|
|
31
35
|
uninstallBrowserBridge,
|
|
32
36
|
} from './cli-setup-commands.js';
|
|
33
37
|
import { SHORTCUT_COMMANDS } from './command-registry.js';
|
|
38
|
+
import { getAutoUpdatePolicy, parseAutoUpdatePolicy, setAutoUpdatePolicy } from './config.js';
|
|
34
39
|
import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
|
|
35
40
|
import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
|
|
36
41
|
import { createBridgeClientForDestination } from './remotes.js';
|
|
42
|
+
import { atomicWriteFile } from './atomic-write.js';
|
|
37
43
|
|
|
38
44
|
/** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
|
|
39
45
|
/** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
|
|
46
|
+
/** @typedef {import('../../protocol/src/types.js').ArtifactDescriptor} ArtifactDescriptor */
|
|
47
|
+
/** @typedef {import('../../protocol/src/types.js').HarExportResult} HarExportResult */
|
|
40
48
|
|
|
41
49
|
const REQUEST_SOURCE = 'cli';
|
|
42
50
|
const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
|
|
@@ -63,6 +71,7 @@ const LOCAL_ONLY_COMMANDS = new Set([
|
|
|
63
71
|
'mcp',
|
|
64
72
|
'proxy',
|
|
65
73
|
'remote',
|
|
74
|
+
'config',
|
|
66
75
|
'doctor',
|
|
67
76
|
'restart',
|
|
68
77
|
]);
|
|
@@ -166,6 +175,11 @@ if (command === 'remote') {
|
|
|
166
175
|
process.exit();
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
if (command === 'config') {
|
|
179
|
+
await runLocalCommand(() => handleConfigCommand(rest));
|
|
180
|
+
process.exit();
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
const clientTimeoutMs = getClientTimeoutOverride();
|
|
170
184
|
applyWindowsTcpTransportDefaults();
|
|
171
185
|
const client = await createCliClient();
|
|
@@ -188,6 +202,26 @@ async function runLocalCommand(run) {
|
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
204
|
|
|
205
|
+
/**
|
|
206
|
+
* @param {string[]} args
|
|
207
|
+
* @returns {Promise<void>}
|
|
208
|
+
*/
|
|
209
|
+
async function handleConfigCommand(args) {
|
|
210
|
+
const [action, key, value] = args;
|
|
211
|
+
if (action === 'get' && key === 'auto-update' && value === undefined) {
|
|
212
|
+
process.stdout.write(`${await getAutoUpdatePolicy()}\n`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (action === 'set' && key === 'auto-update' && value !== undefined && args.length === 3) {
|
|
216
|
+
const policy = parseAutoUpdatePolicy(value);
|
|
217
|
+
const configPath = await setAutoUpdatePolicy(policy);
|
|
218
|
+
process.stdout.write(`Browser Bridge auto-update policy set to "${policy}".\n`);
|
|
219
|
+
process.stdout.write(`Config: ${configPath}\n`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
throw new Error('Usage: bbx config <get auto-update|set auto-update off|compatible>');
|
|
223
|
+
}
|
|
224
|
+
|
|
191
225
|
/**
|
|
192
226
|
* Create the bridge client for this invocation, honoring the --remote flag
|
|
193
227
|
* and BBX_REMOTE env var. Exits with a friendly error when the destination
|
|
@@ -197,10 +231,10 @@ async function runLocalCommand(run) {
|
|
|
197
231
|
*/
|
|
198
232
|
async function createCliClient() {
|
|
199
233
|
try {
|
|
200
|
-
return await createBridgeClientForDestination(
|
|
201
|
-
|
|
202
|
-
clientTimeoutMs ? { defaultTimeoutMs: clientTimeoutMs } : {}
|
|
203
|
-
);
|
|
234
|
+
return await createBridgeClientForDestination(remoteDestinationId, {
|
|
235
|
+
checkProtocolOnConnect: false,
|
|
236
|
+
...(clientTimeoutMs ? { defaultTimeoutMs: clientTimeoutMs } : {}),
|
|
237
|
+
});
|
|
204
238
|
} catch (error) {
|
|
205
239
|
printJson({
|
|
206
240
|
ok: false,
|
|
@@ -212,6 +246,7 @@ async function createCliClient() {
|
|
|
212
246
|
}
|
|
213
247
|
|
|
214
248
|
async function main() {
|
|
249
|
+
let relaunchAfterUpdate = false;
|
|
215
250
|
try {
|
|
216
251
|
if (command === 'status') {
|
|
217
252
|
const healthResponse = await requestBridge(
|
|
@@ -337,7 +372,7 @@ async function main() {
|
|
|
337
372
|
tabId,
|
|
338
373
|
source: REQUEST_SOURCE,
|
|
339
374
|
});
|
|
340
|
-
printCallResponse(response);
|
|
375
|
+
printCallResponse(response, method);
|
|
341
376
|
return;
|
|
342
377
|
}
|
|
343
378
|
|
|
@@ -356,68 +391,37 @@ async function main() {
|
|
|
356
391
|
tabId,
|
|
357
392
|
source: REQUEST_SOURCE,
|
|
358
393
|
});
|
|
359
|
-
printCallResponse(response);
|
|
394
|
+
printCallResponse(response, method);
|
|
360
395
|
return;
|
|
361
396
|
}
|
|
362
397
|
|
|
363
398
|
const shortcutCmd = SHORTCUT_COMMANDS[command];
|
|
364
399
|
if (shortcutCmd) {
|
|
365
|
-
// Allow `bbx <shortcut> --tab <id> ...` to target a specific tab.
|
|
366
|
-
// Without this, --tab gets eaten as a positional argument and the
|
|
367
|
-
// request hits whatever tab the bridge route happens to be pointing
|
|
368
|
-
// at (typically the active tab). For element-resolving shortcuts
|
|
369
|
-
// the ref must be resolved against the SAME tab the action targets,
|
|
370
|
-
// so we pass tabId to both resolveRef and the subsequent request.
|
|
371
400
|
const { tabId, rest: shortcutArgs } = extractTabFlag(rest);
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// selector and retry once. This handles the common case where the
|
|
380
|
-
// agent resolved an element, then the page re-rendered (React
|
|
381
|
-
// reconciliation, SPA navigation) before the action was dispatched.
|
|
382
|
-
const canRetry = typeof selectorInput === 'string' && !selectorInput.startsWith('el_');
|
|
383
|
-
const maxAttempts = canRetry ? 2 : 1;
|
|
384
|
-
/** @type {import('./runtime.js').BridgeResponse | undefined} */
|
|
385
|
-
let response;
|
|
386
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
387
|
-
let elementRef;
|
|
388
|
-
if (shortcutCmd.resolve && selectorInput) {
|
|
389
|
-
elementRef = await resolveRef(client, selectorInput, tabId, REQUEST_SOURCE);
|
|
390
|
-
}
|
|
391
|
-
response = await requestBridge(
|
|
392
|
-
client,
|
|
393
|
-
shortcutCmd.method,
|
|
394
|
-
shortcutCmd.build(shortcutArgs, elementRef),
|
|
395
|
-
{ source: REQUEST_SOURCE, tabId }
|
|
396
|
-
);
|
|
397
|
-
const isStale = !response.ok && response.error?.code === 'ELEMENT_STALE';
|
|
398
|
-
if (isStale && attempt < maxAttempts) {
|
|
399
|
-
process.stderr.write(
|
|
400
|
-
`bbx: ELEMENT_STALE on "${selectorInput}", re-resolving and retrying...\n`
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
if (!isStale || attempt >= maxAttempts) break;
|
|
404
|
-
}
|
|
405
|
-
if (response) await printSummary(response, shortcutCmd.printMethod);
|
|
401
|
+
const response = await requestBridge(
|
|
402
|
+
client,
|
|
403
|
+
shortcutCmd.method,
|
|
404
|
+
shortcutCmd.build(shortcutArgs),
|
|
405
|
+
{ source: REQUEST_SOURCE, tabId }
|
|
406
|
+
);
|
|
407
|
+
await printSummary(response, shortcutCmd.printMethod);
|
|
406
408
|
return;
|
|
407
409
|
}
|
|
408
410
|
|
|
409
411
|
if (command === 'press-key') {
|
|
410
412
|
const [key, refOrSelector] = rest;
|
|
411
413
|
if (!key) throw new Error('Usage: press-key <key> [ref|selector]');
|
|
412
|
-
const
|
|
413
|
-
?
|
|
414
|
+
const target = refOrSelector
|
|
415
|
+
? refOrSelector.startsWith('el_')
|
|
416
|
+
? { elementRef: refOrSelector }
|
|
417
|
+
: { selector: refOrSelector }
|
|
414
418
|
: undefined;
|
|
415
419
|
const response = await requestBridge(
|
|
416
420
|
client,
|
|
417
421
|
'input.press_key',
|
|
418
422
|
{
|
|
419
423
|
key,
|
|
420
|
-
target
|
|
424
|
+
target,
|
|
421
425
|
},
|
|
422
426
|
{ source: REQUEST_SOURCE }
|
|
423
427
|
);
|
|
@@ -447,15 +451,21 @@ async function main() {
|
|
|
447
451
|
|
|
448
452
|
if (command === 'screenshot') {
|
|
449
453
|
const parsed = extractTabFlag(rest);
|
|
450
|
-
const
|
|
454
|
+
const screenshotOptions = extractScreenshotFlags(parsed.rest);
|
|
455
|
+
const [refOrSelector, outputPath] = screenshotOptions.rest;
|
|
451
456
|
if (!refOrSelector)
|
|
452
|
-
throw new Error(
|
|
457
|
+
throw new Error(
|
|
458
|
+
'Usage: screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]'
|
|
459
|
+
);
|
|
453
460
|
const elementRef = await resolveRef(client, refOrSelector, parsed.tabId, REQUEST_SOURCE);
|
|
454
461
|
const response = await requestBridge(
|
|
455
462
|
client,
|
|
456
463
|
'screenshot.capture_element',
|
|
457
464
|
{
|
|
458
465
|
elementRef,
|
|
466
|
+
format: screenshotOptions.format,
|
|
467
|
+
quality: screenshotOptions.quality,
|
|
468
|
+
delivery: 'artifact',
|
|
459
469
|
},
|
|
460
470
|
{ tabId: parsed.tabId, source: REQUEST_SOURCE }
|
|
461
471
|
);
|
|
@@ -464,13 +474,79 @@ async function main() {
|
|
|
464
474
|
return;
|
|
465
475
|
}
|
|
466
476
|
const screenshotResult = /** @type {ScreenshotResult} */ (response.result);
|
|
467
|
-
const
|
|
468
|
-
const
|
|
469
|
-
|
|
477
|
+
const extension = screenshotOptions.format === 'jpeg' ? 'jpg' : screenshotOptions.format;
|
|
478
|
+
const acceptedExtensions =
|
|
479
|
+
screenshotOptions.format === 'jpeg'
|
|
480
|
+
? new Set(['.jpg', '.jpeg'])
|
|
481
|
+
: new Set([`.${extension}`]);
|
|
482
|
+
let filePath = outputPath || path.join(os.tmpdir(), `bbx-${Date.now()}.${extension}`);
|
|
483
|
+
if (outputPath && !path.extname(outputPath)) filePath = `${outputPath}.${extension}`;
|
|
484
|
+
if (
|
|
485
|
+
outputPath &&
|
|
486
|
+
path.extname(outputPath) &&
|
|
487
|
+
!acceptedExtensions.has(path.extname(outputPath))
|
|
488
|
+
) {
|
|
489
|
+
throw new Error(`Screenshot path extension must match ${screenshotOptions.format} format.`);
|
|
490
|
+
}
|
|
491
|
+
const imageBytes =
|
|
492
|
+
screenshotResult.delivery === 'artifact'
|
|
493
|
+
? await downloadArtifact(client, screenshotResult.artifact, 'screenshot')
|
|
494
|
+
: decodeScreenshotDataUrl(screenshotResult.image);
|
|
495
|
+
await atomicWriteFile(filePath, imageBytes);
|
|
470
496
|
printJson({
|
|
471
497
|
ok: true,
|
|
472
498
|
summary: `Screenshot saved to ${filePath}.`,
|
|
473
|
-
evidence: {
|
|
499
|
+
evidence: {
|
|
500
|
+
savedTo: filePath,
|
|
501
|
+
rect: screenshotResult.rect,
|
|
502
|
+
format: screenshotResult.format,
|
|
503
|
+
complete: screenshotResult.complete,
|
|
504
|
+
clipped: screenshotResult.clipped,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (command === 'har') {
|
|
511
|
+
const parsed = extractTabFlag(rest);
|
|
512
|
+
const harOptions = extractHarFlags(parsed.rest);
|
|
513
|
+
const [outputPath = 'browser-bridge.har', ...extra] = harOptions.rest;
|
|
514
|
+
if (extra.length > 0) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
'Usage: har [--tab <tabId>] [--limit 1-200] [--url-pattern <pattern>] [--delivery inline|artifact|auto] [outPath]'
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
if (path.extname(outputPath) !== '.har') {
|
|
520
|
+
throw new Error('HAR output path must use the .har extension.');
|
|
521
|
+
}
|
|
522
|
+
const response = await requestBridge(
|
|
523
|
+
client,
|
|
524
|
+
'network.export_har',
|
|
525
|
+
{
|
|
526
|
+
limit: harOptions.limit,
|
|
527
|
+
urlPattern: harOptions.urlPattern,
|
|
528
|
+
delivery: harOptions.delivery,
|
|
529
|
+
},
|
|
530
|
+
{ tabId: parsed.tabId, source: REQUEST_SOURCE }
|
|
531
|
+
);
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
await printSummary(response);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const result = /** @type {HarExportResult} */ (response.result);
|
|
537
|
+
let harBytes;
|
|
538
|
+
if (result.delivery === 'artifact') {
|
|
539
|
+
harBytes = await downloadArtifact(client, result.artifact, 'har');
|
|
540
|
+
assertValidHarBytes(harBytes);
|
|
541
|
+
} else {
|
|
542
|
+
assertValidHarDocument(result.har);
|
|
543
|
+
harBytes = Buffer.from(`${JSON.stringify(result.har, null, 2)}\n`, 'utf8');
|
|
544
|
+
}
|
|
545
|
+
await atomicWriteFile(outputPath, harBytes);
|
|
546
|
+
printJson({
|
|
547
|
+
ok: true,
|
|
548
|
+
summary: `HAR saved to ${outputPath}.`,
|
|
549
|
+
evidence: { savedTo: outputPath, delivery: result.delivery },
|
|
474
550
|
});
|
|
475
551
|
return;
|
|
476
552
|
}
|
|
@@ -565,25 +641,151 @@ async function main() {
|
|
|
565
641
|
} catch (error) {
|
|
566
642
|
const message = error instanceof Error ? error.message : String(error);
|
|
567
643
|
const raw = getErrorCode(error);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
644
|
+
if (raw === 'BBX_NPM_UPDATED') {
|
|
645
|
+
relaunchAfterUpdate = true;
|
|
646
|
+
} else {
|
|
647
|
+
let code = 'ERROR';
|
|
648
|
+
if (raw === 'ENOENT' || raw === 'ECONNREFUSED' || raw === 'EINVAL') {
|
|
649
|
+
code = 'DAEMON_OFFLINE';
|
|
650
|
+
} else if (raw === 'BRIDGE_TIMEOUT') {
|
|
651
|
+
code = 'BRIDGE_TIMEOUT';
|
|
652
|
+
} else if (/socket closed/i.test(message)) {
|
|
653
|
+
code = 'CONNECTION_LOST';
|
|
654
|
+
} else if (raw) {
|
|
655
|
+
code = String(raw);
|
|
656
|
+
}
|
|
657
|
+
printJson({
|
|
658
|
+
ok: false,
|
|
659
|
+
summary: `${code}: ${message}`,
|
|
660
|
+
evidence: null,
|
|
661
|
+
});
|
|
662
|
+
process.exitCode = 1;
|
|
577
663
|
}
|
|
578
|
-
printJson({
|
|
579
|
-
ok: false,
|
|
580
|
-
summary: `${code}: ${message}`,
|
|
581
|
-
evidence: null,
|
|
582
|
-
});
|
|
583
|
-
process.exitCode = 1;
|
|
584
664
|
} finally {
|
|
585
665
|
await client.close();
|
|
586
666
|
}
|
|
667
|
+
if (relaunchAfterUpdate) {
|
|
668
|
+
relaunchCli();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** @param {string} value */
|
|
673
|
+
function decodeScreenshotDataUrl(value) {
|
|
674
|
+
const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(value);
|
|
675
|
+
if (!match) throw new Error('Screenshot response contains invalid base64 image data.');
|
|
676
|
+
const bytes = Buffer.from(match[1], 'base64');
|
|
677
|
+
if (bytes.toString('base64') !== match[1]) {
|
|
678
|
+
throw new Error('Screenshot response contains non-canonical base64 image data.');
|
|
679
|
+
}
|
|
680
|
+
return bytes;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* @param {import('./client.js').BridgeClient} client
|
|
685
|
+
* @param {ArtifactDescriptor} artifact
|
|
686
|
+
* @param {'screenshot' | 'har'} [expectedKind]
|
|
687
|
+
*/
|
|
688
|
+
async function downloadArtifact(client, artifact, expectedKind) {
|
|
689
|
+
const chunks = [];
|
|
690
|
+
let offset = 0;
|
|
691
|
+
try {
|
|
692
|
+
if (expectedKind && artifact.kind !== expectedKind) {
|
|
693
|
+
throw new Error(`Artifact kind does not match the expected ${expectedKind} result.`);
|
|
694
|
+
}
|
|
695
|
+
if (expectedKind === 'har' && artifact.mimeType !== 'application/json') {
|
|
696
|
+
throw new Error('HAR artifact MIME type is invalid.');
|
|
697
|
+
}
|
|
698
|
+
while (offset < artifact.byteLength) {
|
|
699
|
+
const response = await requestBridge(
|
|
700
|
+
client,
|
|
701
|
+
'artifact.read',
|
|
702
|
+
{ artifactId: artifact.artifactId, offset },
|
|
703
|
+
{ source: REQUEST_SOURCE }
|
|
704
|
+
);
|
|
705
|
+
if (!response.ok) throw new Error(response.error.message);
|
|
706
|
+
const result = /** @type {Record<string, unknown>} */ (response.result);
|
|
707
|
+
if (result.artifactId !== artifact.artifactId || result.offset !== offset) {
|
|
708
|
+
throw new Error('Artifact chunk metadata is inconsistent.');
|
|
709
|
+
}
|
|
710
|
+
const data = String(result.data ?? '');
|
|
711
|
+
const bytes = Buffer.from(data, 'base64');
|
|
712
|
+
if (bytes.toString('base64') !== data || bytes.length !== result.byteLength) {
|
|
713
|
+
throw new Error('Artifact chunk contains invalid base64 data.');
|
|
714
|
+
}
|
|
715
|
+
chunks.push(bytes);
|
|
716
|
+
if (result.nextOffset === null) break;
|
|
717
|
+
if (typeof result.nextOffset !== 'number' || result.nextOffset <= offset) {
|
|
718
|
+
throw new Error('Artifact chunk did not advance the read offset.');
|
|
719
|
+
}
|
|
720
|
+
offset = result.nextOffset;
|
|
721
|
+
}
|
|
722
|
+
const output = Buffer.concat(chunks);
|
|
723
|
+
const sha256 = createHash('sha256').update(output).digest('hex');
|
|
724
|
+
if (output.length !== artifact.byteLength || sha256 !== artifact.sha256) {
|
|
725
|
+
throw new Error('Artifact length or checksum does not match the capture metadata.');
|
|
726
|
+
}
|
|
727
|
+
return output;
|
|
728
|
+
} finally {
|
|
729
|
+
await requestBridge(
|
|
730
|
+
client,
|
|
731
|
+
'artifact.delete',
|
|
732
|
+
{ artifactId: artifact.artifactId },
|
|
733
|
+
{ source: REQUEST_SOURCE }
|
|
734
|
+
).catch(() => {});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** @param {Buffer} bytes */
|
|
739
|
+
function assertValidHarBytes(bytes) {
|
|
740
|
+
const text = bytes.toString('utf8');
|
|
741
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
742
|
+
throw new Error('HAR artifact is not valid UTF-8 JSON.');
|
|
743
|
+
}
|
|
744
|
+
let document;
|
|
745
|
+
try {
|
|
746
|
+
document = JSON.parse(text);
|
|
747
|
+
} catch {
|
|
748
|
+
throw new Error('HAR artifact is not valid JSON.');
|
|
749
|
+
}
|
|
750
|
+
assertValidHarDocument(document);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** @param {unknown} document */
|
|
754
|
+
function assertValidHarDocument(document) {
|
|
755
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
756
|
+
throw new Error('HAR response does not contain an inline HAR object.');
|
|
757
|
+
}
|
|
758
|
+
const log = Reflect.get(document, 'log');
|
|
759
|
+
if (
|
|
760
|
+
!log ||
|
|
761
|
+
typeof log !== 'object' ||
|
|
762
|
+
Array.isArray(log) ||
|
|
763
|
+
Reflect.get(log, 'version') !== '1.2' ||
|
|
764
|
+
!Array.isArray(Reflect.get(log, 'entries'))
|
|
765
|
+
) {
|
|
766
|
+
throw new Error('HAR response does not contain a valid HAR 1.2 document.');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Replace this invocation with a fresh Node process after npm has replaced the
|
|
772
|
+
* package on disk. Child stdio is inherited so command output remains stable.
|
|
773
|
+
*
|
|
774
|
+
* @returns {void}
|
|
775
|
+
*/
|
|
776
|
+
function relaunchCli() {
|
|
777
|
+
const result = spawnSync(
|
|
778
|
+
process.execPath,
|
|
779
|
+
[fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
|
780
|
+
{
|
|
781
|
+
stdio: 'inherit',
|
|
782
|
+
env: process.env,
|
|
783
|
+
}
|
|
784
|
+
);
|
|
785
|
+
if (result.error) {
|
|
786
|
+
throw result.error;
|
|
787
|
+
}
|
|
788
|
+
process.exitCode = result.status ?? 1;
|
|
587
789
|
}
|
|
588
790
|
|
|
589
791
|
/**
|