@browserbridge/bbx 1.12.5 → 1.14.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 (34) hide show
  1. package/PRIVACY.md +4 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +10 -0
  4. package/package.json +6 -6
  5. package/packages/agent-client/src/cli.js +130 -1
  6. package/packages/agent-client/src/command-registry.js +6 -0
  7. package/packages/agent-client/src/file-transfer.js +88 -0
  8. package/packages/agent-client/src/record-session.js +54 -0
  9. package/packages/agent-client/src/replay.js +124 -0
  10. package/packages/mcp-server/src/handlers-capture.js +9 -0
  11. package/packages/mcp-server/src/handlers-page.js +6 -0
  12. package/packages/mcp-server/src/handlers-utils.js +96 -0
  13. package/packages/mcp-server/src/server.js +22 -2
  14. package/packages/native-host/src/artifact-store.js +42 -4
  15. package/packages/native-host/src/daemon.js +142 -6
  16. package/packages/native-host/src/native-host.js +10 -0
  17. package/packages/native-host/src/recording-store.js +136 -0
  18. package/packages/protocol/src/downloads.js +35 -0
  19. package/packages/protocol/src/emulation.js +155 -0
  20. package/packages/protocol/src/files.js +94 -0
  21. package/packages/protocol/src/index.js +1 -0
  22. package/packages/protocol/src/protocol.js +44 -0
  23. package/packages/protocol/src/recording.js +70 -0
  24. package/packages/protocol/src/registry.js +224 -0
  25. package/packages/protocol/src/replay.js +144 -0
  26. package/packages/protocol/src/summary.js +49 -0
  27. package/packages/protocol/src/types.ts +37 -1
  28. package/packages/protocol/src/workflows.ts +22 -0
  29. package/skills/browser-bridge/SKILL.md +10 -0
  30. package/skills/browser-bridge/references/capture-context.md +9 -0
  31. package/skills/browser-bridge/references/emulation.md +18 -0
  32. package/skills/browser-bridge/references/files.md +50 -0
  33. package/skills/browser-bridge/references/playbooks.md +48 -0
  34. package/skills/browser-bridge/references/recording.md +54 -0
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+
3
+ import { BridgeError, ERROR_CODES } from './errors.js';
4
+
5
+ /** @typedef {import('./workflows.js').EmulationConfig} EmulationConfig */
6
+
7
+ /** Metrics presets deliberately do not impersonate a different browser engine. */
8
+ export const DEVICE_PRESETS = Object.freeze({
9
+ phone: { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, touch: true },
10
+ tablet: { width: 820, height: 1180, deviceScaleFactor: 2, mobile: true, touch: true },
11
+ desktop: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
12
+ });
13
+
14
+ export const NETWORK_PRESETS = Object.freeze({
15
+ offline: { offline: true, latencyMs: 0, downloadKbps: 0, uploadKbps: 0 },
16
+ 'slow-3g': { offline: false, latencyMs: 2000, downloadKbps: 400, uploadKbps: 400 },
17
+ 'fast-3g': { offline: false, latencyMs: 563, downloadKbps: 1440, uploadKbps: 675 },
18
+ wifi: { offline: false, latencyMs: 2, downloadKbps: 30000, uploadKbps: 15000 },
19
+ unthrottled: { offline: false, latencyMs: 0, downloadKbps: -1, uploadKbps: -1 },
20
+ });
21
+
22
+ /** @param {string} message @returns {never} */
23
+ export function invalidWorkflow(message) {
24
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, message);
25
+ }
26
+
27
+ /** @param {unknown} value @param {string} name @returns {Record<string, unknown>} */
28
+ export function objectParam(value, name) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
30
+ invalidWorkflow(`${name} must be an object.`);
31
+ }
32
+ return /** @type {Record<string, unknown>} */ (value);
33
+ }
34
+
35
+ /** @param {unknown} value @param {string} name @param {number} min @param {number} max @returns {number} */
36
+ export function numberParam(value, name, min, max) {
37
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
38
+ invalidWorkflow(`${name} must be between ${min} and ${max}.`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ /** @param {unknown} value @param {string} name @param {number} [max] @returns {string} */
44
+ export function stringParam(value, name, max = 256) {
45
+ if (
46
+ typeof value !== 'string' ||
47
+ !value.length ||
48
+ value.length > max ||
49
+ [...value].some((character) => character.charCodeAt(0) < 32)
50
+ ) {
51
+ invalidWorkflow(
52
+ `${name} must be a nonempty string of at most ${max} characters without control characters.`
53
+ );
54
+ }
55
+ return value;
56
+ }
57
+
58
+ /** @param {unknown} value @param {string} name @returns {boolean} */
59
+ export function booleanParam(value, name) {
60
+ if (typeof value !== 'boolean') invalidWorkflow(`${name} must be boolean.`);
61
+ return value;
62
+ }
63
+
64
+ /** @param {Record<string, unknown>} value @param {string[]} keys */
65
+ export function onlyKeys(value, keys) {
66
+ const unknown = Object.keys(value).find((key) => !keys.includes(key));
67
+ if (unknown) invalidWorkflow(`Unknown parameter: ${unknown}.`);
68
+ }
69
+
70
+ /** Validate once at the shared boundary; repeated normalization is idempotent.
71
+ * @param {Record<string, unknown>} params @returns {EmulationConfig} */
72
+ export function normalizeEmulation(params) {
73
+ onlyKeys(params, ['device', 'network', 'cpuRate', 'locale', 'timezoneId', 'geolocation']);
74
+ /** @type {EmulationConfig} */
75
+ const config = {};
76
+ if (params.device !== undefined) {
77
+ const input =
78
+ typeof params.device === 'string'
79
+ ? { preset: params.device }
80
+ : objectParam(params.device, 'device');
81
+ onlyKeys(input, ['preset', 'width', 'height', 'deviceScaleFactor', 'mobile', 'touch']);
82
+ const preset =
83
+ input.preset === undefined
84
+ ? {}
85
+ : Object.hasOwn(DEVICE_PRESETS, String(input.preset))
86
+ ? DEVICE_PRESETS[/** @type {keyof typeof DEVICE_PRESETS} */ (input.preset)]
87
+ : null;
88
+ if (!preset) invalidWorkflow('device.preset must be phone, tablet, or desktop.');
89
+ /** @type {Record<string, unknown>} */
90
+ const device = { ...preset, ...input };
91
+ config.device = {
92
+ width: Math.round(numberParam(device.width, 'width', 1, 8192)),
93
+ height: Math.round(numberParam(device.height, 'height', 1, 8192)),
94
+ deviceScaleFactor: numberParam(device.deviceScaleFactor ?? 1, 'deviceScaleFactor', 0.1, 4),
95
+ mobile: booleanParam(device.mobile ?? false, 'mobile'),
96
+ touch: booleanParam(device.touch ?? device.mobile ?? false, 'touch'),
97
+ };
98
+ }
99
+ if (params.network !== undefined) {
100
+ const input =
101
+ typeof params.network === 'string'
102
+ ? { preset: params.network }
103
+ : objectParam(params.network, 'network');
104
+ onlyKeys(input, ['preset', 'offline', 'latencyMs', 'downloadKbps', 'uploadKbps']);
105
+ const preset =
106
+ input.preset === undefined
107
+ ? NETWORK_PRESETS.unthrottled
108
+ : Object.hasOwn(NETWORK_PRESETS, String(input.preset))
109
+ ? NETWORK_PRESETS[/** @type {keyof typeof NETWORK_PRESETS} */ (input.preset)]
110
+ : null;
111
+ if (!preset)
112
+ invalidWorkflow(
113
+ 'Unknown network preset. Use offline, slow-3g, fast-3g, wifi, or unthrottled.'
114
+ );
115
+ const network = { ...preset, ...input };
116
+ config.network = {
117
+ offline: booleanParam(network.offline, 'offline'),
118
+ latencyMs: numberParam(network.latencyMs, 'latencyMs', 0, 60000),
119
+ downloadKbps: numberParam(network.downloadKbps, 'downloadKbps', -1, 10000000),
120
+ uploadKbps: numberParam(network.uploadKbps, 'uploadKbps', -1, 10000000),
121
+ };
122
+ for (const speed of [config.network.downloadKbps, config.network.uploadKbps]) {
123
+ if (speed < 0 && speed !== -1)
124
+ invalidWorkflow('Throughput must be nonnegative or -1 for unthrottled.');
125
+ }
126
+ }
127
+ if (params.cpuRate !== undefined) config.cpuRate = numberParam(params.cpuRate, 'cpuRate', 1, 100);
128
+ if (params.locale !== undefined) {
129
+ config.locale = stringParam(params.locale, 'locale', 80);
130
+ try {
131
+ Intl.getCanonicalLocales(config.locale);
132
+ } catch {
133
+ invalidWorkflow('Invalid locale.');
134
+ }
135
+ }
136
+ if (params.timezoneId !== undefined) {
137
+ config.timezoneId = stringParam(params.timezoneId, 'timezoneId', 100);
138
+ try {
139
+ new Intl.DateTimeFormat('en', { timeZone: config.timezoneId });
140
+ } catch {
141
+ invalidWorkflow('Invalid timezoneId.');
142
+ }
143
+ }
144
+ if (params.geolocation !== undefined) {
145
+ const geo = objectParam(params.geolocation, 'geolocation');
146
+ onlyKeys(geo, ['latitude', 'longitude', 'accuracy']);
147
+ config.geolocation = {
148
+ latitude: numberParam(geo.latitude, 'latitude', -90, 90),
149
+ longitude: numberParam(geo.longitude, 'longitude', -180, 180),
150
+ accuracy: numberParam(geo.accuracy ?? 100, 'accuracy', 0, 1000000),
151
+ };
152
+ }
153
+ if (!Object.keys(config).length) invalidWorkflow('Provide at least one emulation setting.');
154
+ return config;
155
+ }
@@ -0,0 +1,94 @@
1
+ // @ts-check
2
+
3
+ import { invalidWorkflow, numberParam, objectParam, onlyKeys, stringParam } from './emulation.js';
4
+
5
+ export const FILE_MAX_BYTES = 16 * 1024 * 1024;
6
+ export const FILE_CHUNK_BYTES = 192 * 1024;
7
+ export const FILE_TOTAL_BYTES = 32 * 1024 * 1024;
8
+ export const FILE_TTL_MS = 5 * 60 * 1000;
9
+
10
+ /** @param {string} method @param {Record<string, unknown>} params @returns {Record<string, unknown>} */
11
+ export function normalizeFileParams(method, params) {
12
+ if (method === 'files.stage') {
13
+ onlyKeys(params, ['transferId', 'name', 'mimeType', 'size', 'offset', 'data']);
14
+ /** @type {Record<string, unknown>} */
15
+ const result = {};
16
+ if (params.transferId !== undefined)
17
+ result.transferId = stringParam(params.transferId, 'transferId', 80);
18
+ else {
19
+ result.name = stringParam(params.name, 'name');
20
+ if (/[\\/]/u.test(String(result.name)))
21
+ invalidWorkflow('name must be a filename, not a path.');
22
+ result.mimeType = stringParam(params.mimeType ?? 'application/octet-stream', 'mimeType', 120);
23
+ result.size = numberParam(params.size, 'size', 0, FILE_MAX_BYTES);
24
+ if (!Number.isInteger(result.size)) invalidWorkflow('size must be an integer.');
25
+ }
26
+ result.offset = numberParam(params.offset ?? 0, 'offset', 0, FILE_MAX_BYTES);
27
+ if (!Number.isInteger(result.offset)) invalidWorkflow('offset must be an integer.');
28
+ const data = params.data ?? '';
29
+ if (
30
+ typeof data !== 'string' ||
31
+ data.length > (FILE_CHUNK_BYTES * 4) / 3 ||
32
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)
33
+ )
34
+ invalidWorkflow('data must be base64, at most 192 KiB decoded per chunk.');
35
+ result.data = data;
36
+ return result;
37
+ }
38
+ if (method === 'files.release') {
39
+ onlyKeys(params, ['transferId']);
40
+ return { transferId: stringParam(params.transferId, 'transferId', 80) };
41
+ }
42
+ if (method === 'input.set_files') {
43
+ onlyKeys(params, ['target', 'files', 'paths']);
44
+ const target = objectParam(params.target, 'target');
45
+ const files = params.files;
46
+ const paths = params.paths;
47
+ if (paths !== undefined) onlyKeys(target, ['selector']);
48
+ if ((files === undefined) === (paths === undefined))
49
+ invalidWorkflow('Provide files or paths, not both. Empty files clears the input.');
50
+ const values = files ?? paths;
51
+ if (!Array.isArray(values) || values.length > 10)
52
+ invalidWorkflow('files/paths must be an array of at most 10 strings.');
53
+ const normalized = values.map((value) =>
54
+ stringParam(
55
+ value,
56
+ files !== undefined ? 'file handle' : 'browser path',
57
+ files !== undefined ? 80 : 4096
58
+ )
59
+ );
60
+ if (
61
+ paths !== undefined &&
62
+ normalized.some((value) => !/^(?:\/|[A-Za-z]:[\\/]|\\\\)/u.test(value))
63
+ )
64
+ invalidWorkflow('paths must be absolute paths on the browser machine.');
65
+ if (paths !== undefined && (typeof target.selector !== 'string' || !target.selector))
66
+ invalidWorkflow('Browser-local paths require target.selector in the top document.');
67
+ return { target, ...(files !== undefined ? { files: normalized } : { paths: normalized }) };
68
+ }
69
+ if (method === 'input.paste_image') {
70
+ onlyKeys(params, ['target', 'transferId']);
71
+ return {
72
+ target: objectParam(params.target, 'target'),
73
+ transferId: stringParam(params.transferId, 'transferId', 80),
74
+ };
75
+ }
76
+ if (method === 'download.resource') {
77
+ onlyKeys(params, ['url', 'maxBytes', 'timeoutMs']);
78
+ const url = stringParam(params.url, 'url', 8192);
79
+ let parsed;
80
+ try {
81
+ parsed = new URL(url);
82
+ } catch {
83
+ invalidWorkflow('Invalid download URL.');
84
+ }
85
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password)
86
+ invalidWorkflow('Downloads require HTTP(S) without URL credentials.');
87
+ return {
88
+ url,
89
+ maxBytes: numberParam(params.maxBytes ?? 8 * 1024 * 1024, 'maxBytes', 1, FILE_MAX_BYTES),
90
+ timeoutMs: numberParam(params.timeoutMs ?? 30000, 'timeoutMs', 100, 60000),
91
+ };
92
+ }
93
+ return invalidWorkflow('Unknown file method.');
94
+ }
@@ -9,6 +9,7 @@ export * from './budget.js';
9
9
  export * from './capabilities.js';
10
10
  export * from './defaults.js';
11
11
  export * from './errors.js';
12
+ export { normalizeEmulation, DEVICE_PRESETS, NETWORK_PRESETS } from './emulation.js';
12
13
  export * from './har.js';
13
14
  export * from './incidental-sanitizer.js';
14
15
  export * from './json-lines.js';
@@ -2,6 +2,10 @@
2
2
 
3
3
  import { applyBudget } from './budget.js';
4
4
  import { normalizeWebMcpParams } from './webmcp.js';
5
+ import { normalizeEmulation, onlyKeys } from './emulation.js';
6
+ import { normalizeFileParams } from './files.js';
7
+ import { normalizeDownload } from './downloads.js';
8
+ import { normalizeRecording } from './recording.js';
5
9
  import {
6
10
  ARTIFACT_CHUNK_BYTES,
7
11
  BUDGET_PRESETS,
@@ -539,6 +543,38 @@ function normalizeAutomaticRetryMeta(source, value) {
539
543
  */
540
544
  export function normalizeRequestParams(method, params) {
541
545
  switch (method) {
546
+ case 'download.start':
547
+ case 'download.status':
548
+ case 'download.pause':
549
+ case 'download.resume':
550
+ case 'download.cancel':
551
+ case 'download.release':
552
+ return normalizeDownload(method, params);
553
+ case 'session.start':
554
+ case 'session.journal_read':
555
+ case 'session.journal_delete':
556
+ case 'session.status':
557
+ case 'session.read':
558
+ case 'session.stop':
559
+ case 'session.export':
560
+ case 'session.release':
561
+ return normalizeRecording(method, params);
562
+ case 'files.stage':
563
+ case 'files.release':
564
+ case 'download.resource':
565
+ return normalizeFileParams(method, params);
566
+ case 'input.set_files':
567
+ case 'input.paste_image':
568
+ return {
569
+ ...normalizeFileParams(method, params),
570
+ target: normalizeInputAction(params).target,
571
+ };
572
+ case 'emulation.apply':
573
+ return normalizeEmulation(params);
574
+ case 'emulation.status':
575
+ case 'emulation.reset':
576
+ onlyKeys(params, []);
577
+ return {};
542
578
  case 'webmcp.list_tools':
543
579
  case 'webmcp.get_tool':
544
580
  case 'webmcp.execute_tool': {
@@ -691,6 +727,14 @@ export function normalizeAccessRequestParams(params = {}) {
691
727
  */
692
728
  export function getBridgeOperationTimeoutMs(method, params = {}) {
693
729
  switch (method) {
730
+ case 'session.read':
731
+ return Number(normalizeRecording(method, params).waitMs) + 5000;
732
+ case 'session.stop':
733
+ case 'session.export':
734
+ case 'session.release':
735
+ return 100000;
736
+ case 'download.resource':
737
+ return Number(normalizeFileParams(method, params).timeoutMs) + 5000;
694
738
  case 'webmcp.execute_tool': {
695
739
  const normalized = normalizeWebMcpParams(params);
696
740
  return normalized.approvalTimeoutMs + normalized.timeoutMs + 10_000;
@@ -0,0 +1,70 @@
1
+ // @ts-check
2
+
3
+ import { booleanParam, invalidWorkflow, numberParam, onlyKeys, stringParam } from './emulation.js';
4
+
5
+ /** @param {string} method @param {Record<string, unknown>} params @returns {Record<string, unknown>} */
6
+ export function normalizeRecording(method, params) {
7
+ if (method === 'session.journal_read' || method === 'session.journal_delete') {
8
+ onlyKeys(params, [
9
+ 'journalId',
10
+ ...(method === 'session.journal_read' ? ['offset', 'maxBytes'] : []),
11
+ ]);
12
+ const journalId = stringParam(params.journalId, 'journalId', 80);
13
+ if (!/^journal_[a-f0-9]{64}$/u.test(journalId)) invalidWorkflow('Invalid journal capability.');
14
+ if (method === 'session.journal_delete') return { journalId };
15
+ const offset = numberParam(params.offset ?? 0, 'offset', 0, Number.MAX_SAFE_INTEGER);
16
+ const maxBytes = numberParam(params.maxBytes ?? 196608, 'maxBytes', 1, 196608);
17
+ if (!Number.isInteger(offset) || !Number.isInteger(maxBytes))
18
+ invalidWorkflow('Journal ranges must be integers.');
19
+ return { journalId, offset, maxBytes };
20
+ }
21
+ if (method === 'session.start') {
22
+ onlyKeys(params, [
23
+ 'activity',
24
+ 'network',
25
+ 'bodies',
26
+ 'messages',
27
+ 'visual',
28
+ 'durationMs',
29
+ 'followTabs',
30
+ 'durable',
31
+ 'replay',
32
+ ]);
33
+ const activity = booleanParam(params.activity ?? true, 'activity');
34
+ const network = booleanParam(params.network ?? false, 'network');
35
+ const bodies = booleanParam(params.bodies ?? false, 'bodies');
36
+ const messages = booleanParam(params.messages ?? false, 'messages');
37
+ const visual = booleanParam(params.visual ?? false, 'visual');
38
+ if ((bodies || messages) && !network) invalidWorkflow('bodies/messages require network:true.');
39
+ if (!activity && !network && !visual && params.replay !== true)
40
+ invalidWorkflow('Enable at least one recording source.');
41
+ return {
42
+ activity,
43
+ network,
44
+ bodies,
45
+ messages,
46
+ visual,
47
+ followTabs: booleanParam(params.followTabs ?? false, 'followTabs'),
48
+ durable: booleanParam(params.durable ?? false, 'durable'),
49
+ replay: booleanParam(params.replay ?? false, 'replay'),
50
+ durationMs: numberParam(params.durationMs ?? 60000, 'durationMs', 1000, 1800000),
51
+ };
52
+ }
53
+ onlyKeys(params, [
54
+ 'sessionId',
55
+ ...(method === 'session.read' ? ['cursor', 'limit', 'waitMs'] : []),
56
+ ...(method === 'session.export' ? ['format'] : []),
57
+ ]);
58
+ const sessionId = stringParam(params.sessionId, 'sessionId', 80);
59
+ if (method === 'session.export') {
60
+ if (params.format !== undefined && params.format !== 'json' && params.format !== 'html')
61
+ invalidWorkflow('format must be json or html.');
62
+ return { sessionId, format: params.format ?? 'json' };
63
+ }
64
+ if (method !== 'session.read') return { sessionId };
65
+ const cursor = numberParam(params.cursor ?? 0, 'cursor', 0, Number.MAX_SAFE_INTEGER);
66
+ const limit = numberParam(params.limit ?? 25, 'limit', 1, 100);
67
+ if (!Number.isInteger(cursor) || !Number.isInteger(limit))
68
+ invalidWorkflow('cursor and limit must be integers.');
69
+ return { sessionId, cursor, limit, waitMs: numberParam(params.waitMs ?? 0, 'waitMs', 0, 25000) };
70
+ }
@@ -100,6 +100,43 @@ const METHOD_POLICIES = Object.freeze({
100
100
  * @type {Readonly<Record<import('./types.js').BridgeMethod, string>>}
101
101
  */
102
102
  const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
103
+ 'session.journal_read':
104
+ 'Read exact base64 bytes of a durable recording journal. Requires its secret journalId. Works after extension or daemon restart; page until nextOffset=null. Expires 24 hours after the last write.',
105
+ 'session.journal_delete': 'Delete one durable recording journal using its secret journalId.',
106
+ 'input.paste_image':
107
+ 'Paste a staged PNG using the real clipboard and trusted Chrome input. Overwrites clipboard. Requires a focused top-document editor. Never automatically retry; verify insertion.',
108
+ 'download.start':
109
+ 'Start a browser-native download without the artifact size ceiling. Returns a secret downloadId for subsequent control. Saves on the browser machine.',
110
+ 'download.status': 'Read progress and state of one BBX-created browser download.',
111
+ 'download.pause': 'Pause one BBX-created browser download.',
112
+ 'download.resume': 'Resume one resumable BBX-created browser download.',
113
+ 'download.cancel': 'Cancel one BBX-created browser download.',
114
+ 'download.release':
115
+ 'Release a terminal download handle without deleting its file or browser history.',
116
+ 'session.start':
117
+ 'Start bounded recording. activity=true; network,bodies,messages,visual,followTabs,durable,replay default false. bodies/messages require network. durationMs=1000..1800000, default 60000. followTabs tracks up to eight opener descendants inside the enabled window. replay captures the root renderer stream. durable returns a secret journalId for crash recovery. Input values are redacted; pixels and binary bodies are not. Keep sessionId on one connection; two retained sessions maximum.',
118
+ 'session.status':
119
+ 'Probe a recording by sessionId, including the live page recorder, counters, gaps, body backlog and visual frame count. No event payloads.',
120
+ 'session.read':
121
+ 'Read bounded recording events with sessionId, cursor=0, limit=25 (max 100), waitMs=0 (max 25000). Cursors belong to this sessionId. Returns nextCursor and explicit capture gaps. No response bodies or images in this read.',
122
+ 'session.stop':
123
+ 'Stop intake, collect final activity, drain pending body reads and stop network capture. Retains evidence for five minutes for export. Failed cleanup is retryable.',
124
+ 'session.export':
125
+ 'Stop and drain a recording, then export a chunked artifact. format=json includes archived events, opt-in bodies and frames. format=html is a self-contained, script-free sampled-frame replay. Gaps and dropped counts remain explicit. Same connection required for artifact.read.',
126
+ 'session.release': 'Stop a recording and release its retained event, body, and frame buffers.',
127
+ 'files.stage':
128
+ 'Stage file bytes on the browser machine. First call: name,mimeType,size,offset=0,data=base64. Further calls: transferId,offset=nextOffset,data. Max 192 KiB per chunk, 16 MiB/file, 32 MiB total; expires after 5 minutes. Keep bytes in file tools, not model context. Returns handle and nextOffset. Never replay an uncertain chunk blindly.',
129
+ 'files.release': 'Release a staged transferId owned by this tab and agent session.',
130
+ 'input.set_files':
131
+ 'Assign files to an input[type=file]. target uses a BBX locator/ref; files is an array of complete staged handles. Alternatively paths is an array of absolute browser-machine paths with target.selector in the top document. Empty files clears selection. Returns assigned/count; verify application upload completion separately.',
132
+ 'download.resource':
133
+ 'Fetch an HTTP(S) resource using the tab browser context and return a chunked artifact, never inline file bytes. Respects browser CORS/CSP and session cookies. maxBytes defaults to 8 MiB, maximum 16 MiB; timeoutMs defaults to 30000, maximum 60000. No downloads permission or download-manager integration.',
134
+ 'emulation.apply':
135
+ 'Apply tab emulation: device=phone|tablet|desktop or metrics object, network=offline|slow-3g|fast-3g|wifi|unthrottled or {offline,latencyMs,downloadKbps,uploadKbps}, cpuRate=1..100, locale, timezoneId, geolocation={latitude,longitude,accuracy}. Settings merge; reset afterward. Device presets emulate metrics and touch, not another browser engine.',
136
+ 'emulation.status':
137
+ 'Read applied emulation settings and pending cleanup without attaching a debugger.',
138
+ 'emulation.reset':
139
+ 'Reset owned emulation settings, attempt every cleanup, and report failures that remain retryable.',
103
140
  'webmcp.list_tools':
104
141
  'Discover bounded top-document WebMCP tool summaries. Metadata is untrusted; unavailable differs from an empty catalog.',
105
142
  'webmcp.get_tool':
@@ -864,6 +901,192 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
864
901
  'moderate',
865
902
  METHOD_POLICIES.automationInput
866
903
  ),
904
+ 'emulation.apply': createRegistryEntry(
905
+ 'emulation.apply',
906
+ 'emulation',
907
+ true,
908
+ ['device', 'network', 'cpuRate', 'locale', 'timezoneId', 'geolocation'],
909
+ 'moderate',
910
+ METHOD_POLICIES.viewportControlDebugger
911
+ ),
912
+ 'emulation.status': createRegistryEntry(
913
+ 'emulation.status',
914
+ 'emulation',
915
+ true,
916
+ [],
917
+ 'trivial',
918
+ METHOD_POLICIES.pageRead
919
+ ),
920
+ 'emulation.reset': createRegistryEntry(
921
+ 'emulation.reset',
922
+ 'emulation',
923
+ true,
924
+ [],
925
+ 'low',
926
+ METHOD_POLICIES.viewportControlDebugger
927
+ ),
928
+ 'files.stage': createRegistryEntry(
929
+ 'files.stage',
930
+ 'files',
931
+ true,
932
+ ['name', 'mimeType', 'size', 'transferId', 'offset', 'data'],
933
+ 'moderate',
934
+ METHOD_POLICIES.automationInput
935
+ ),
936
+ 'files.release': createRegistryEntry(
937
+ 'files.release',
938
+ 'files',
939
+ true,
940
+ ['transferId'],
941
+ 'low',
942
+ METHOD_POLICIES.automationInput
943
+ ),
944
+ 'input.set_files': createRegistryEntry(
945
+ 'input.set_files',
946
+ 'files',
947
+ true,
948
+ ['target', 'files', 'paths'],
949
+ 'moderate',
950
+ METHOD_POLICIES.automationInputDebugger
951
+ ),
952
+ 'input.paste_image': createRegistryEntry(
953
+ 'input.paste_image',
954
+ 'files',
955
+ true,
956
+ ['target', 'transferId'],
957
+ 'moderate',
958
+ METHOD_POLICIES.automationInputDebugger
959
+ ),
960
+ 'download.resource': createRegistryEntry(
961
+ 'download.resource',
962
+ 'files',
963
+ true,
964
+ ['url', 'maxBytes', 'timeoutMs'],
965
+ 'moderate',
966
+ METHOD_POLICIES.networkRead
967
+ ),
968
+ 'download.start': createRegistryEntry(
969
+ 'download.start',
970
+ 'files',
971
+ true,
972
+ ['url', 'filename', 'saveAs'],
973
+ 'moderate',
974
+ METHOD_POLICIES.automationInput
975
+ ),
976
+ 'download.status': createRegistryEntry(
977
+ 'download.status',
978
+ 'files',
979
+ true,
980
+ ['downloadId'],
981
+ 'low',
982
+ METHOD_POLICIES.networkRead
983
+ ),
984
+ 'download.pause': createRegistryEntry(
985
+ 'download.pause',
986
+ 'files',
987
+ true,
988
+ ['downloadId'],
989
+ 'low',
990
+ METHOD_POLICIES.automationInput
991
+ ),
992
+ 'download.resume': createRegistryEntry(
993
+ 'download.resume',
994
+ 'files',
995
+ true,
996
+ ['downloadId'],
997
+ 'low',
998
+ METHOD_POLICIES.automationInput
999
+ ),
1000
+ 'download.cancel': createRegistryEntry(
1001
+ 'download.cancel',
1002
+ 'files',
1003
+ true,
1004
+ ['downloadId'],
1005
+ 'low',
1006
+ METHOD_POLICIES.automationInput
1007
+ ),
1008
+ 'download.release': createRegistryEntry(
1009
+ 'download.release',
1010
+ 'files',
1011
+ true,
1012
+ ['downloadId'],
1013
+ 'low',
1014
+ METHOD_POLICIES.automationInput
1015
+ ),
1016
+ 'session.start': createRegistryEntry(
1017
+ 'session.start',
1018
+ 'session',
1019
+ true,
1020
+ [
1021
+ 'activity',
1022
+ 'network',
1023
+ 'bodies',
1024
+ 'messages',
1025
+ 'visual',
1026
+ 'durationMs',
1027
+ 'followTabs',
1028
+ 'durable',
1029
+ 'replay',
1030
+ ],
1031
+ 'moderate',
1032
+ METHOD_POLICIES.automationInputDebugger
1033
+ ),
1034
+ 'session.journal_read': createRegistryEntry(
1035
+ 'session.journal_read',
1036
+ 'session',
1037
+ false,
1038
+ ['journalId', 'offset', 'maxBytes'],
1039
+ 'low',
1040
+ METHOD_POLICIES.none
1041
+ ),
1042
+ 'session.journal_delete': createRegistryEntry(
1043
+ 'session.journal_delete',
1044
+ 'session',
1045
+ false,
1046
+ ['journalId'],
1047
+ 'low',
1048
+ METHOD_POLICIES.none
1049
+ ),
1050
+ 'session.status': createRegistryEntry(
1051
+ 'session.status',
1052
+ 'session',
1053
+ true,
1054
+ ['sessionId'],
1055
+ 'low',
1056
+ METHOD_POLICIES.pageRead
1057
+ ),
1058
+ 'session.read': createRegistryEntry(
1059
+ 'session.read',
1060
+ 'session',
1061
+ true,
1062
+ ['sessionId', 'cursor', 'limit', 'waitMs'],
1063
+ 'low',
1064
+ METHOD_POLICIES.pageRead
1065
+ ),
1066
+ 'session.stop': createRegistryEntry(
1067
+ 'session.stop',
1068
+ 'session',
1069
+ true,
1070
+ ['sessionId'],
1071
+ 'moderate',
1072
+ METHOD_POLICIES.automationInputDebugger
1073
+ ),
1074
+ 'session.export': createRegistryEntry(
1075
+ 'session.export',
1076
+ 'session',
1077
+ true,
1078
+ ['sessionId', 'format'],
1079
+ 'moderate',
1080
+ METHOD_POLICIES.automationInputDebugger
1081
+ ),
1082
+ 'session.release': createRegistryEntry(
1083
+ 'session.release',
1084
+ 'session',
1085
+ true,
1086
+ ['sessionId'],
1087
+ 'low',
1088
+ METHOD_POLICIES.automationInputDebugger
1089
+ ),
867
1090
  });
868
1091
 
869
1092
  /** @type {ReadonlyArray<import('./types.js').BridgeMethod>} */
@@ -876,6 +1099,7 @@ export const METHOD_SET = new Set(BRIDGE_METHODS);
876
1099
 
877
1100
  /** Methods whose read-only forms may run concurrently without side effects. */
878
1101
  const BATCH_SAFE_METHODS = new Set([
1102
+ 'emulation.status',
879
1103
  'webmcp.list_tools',
880
1104
  'webmcp.get_tool',
881
1105
  'health.ping',