@browserbridge/bbx 1.12.5 → 1.14.1
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 +4 -0
- package/README.md +2 -0
- package/SECURITY.md +10 -0
- package/package.json +6 -6
- package/packages/agent-client/src/cli-output.js +8 -2
- package/packages/agent-client/src/cli.js +132 -3
- package/packages/agent-client/src/command-registry.js +6 -0
- package/packages/agent-client/src/file-transfer.js +88 -0
- package/packages/agent-client/src/record-session.js +54 -0
- package/packages/agent-client/src/replay.js +124 -0
- package/packages/mcp-server/src/handlers-capture.js +9 -0
- package/packages/mcp-server/src/handlers-page.js +6 -0
- package/packages/mcp-server/src/handlers-utils.js +96 -0
- package/packages/mcp-server/src/server.js +22 -2
- package/packages/native-host/src/artifact-store.js +42 -4
- package/packages/native-host/src/auth-token.js +12 -0
- package/packages/native-host/src/daemon.js +142 -6
- package/packages/native-host/src/native-host.js +10 -0
- package/packages/native-host/src/recording-store.js +136 -0
- package/packages/protocol/src/downloads.js +35 -0
- package/packages/protocol/src/emulation.js +155 -0
- package/packages/protocol/src/files.js +94 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/protocol.js +44 -0
- package/packages/protocol/src/recording.js +70 -0
- package/packages/protocol/src/registry.js +224 -0
- package/packages/protocol/src/replay.js +144 -0
- package/packages/protocol/src/summary.js +49 -0
- package/packages/protocol/src/types.ts +37 -1
- package/packages/protocol/src/workflows.ts +22 -0
- package/skills/browser-bridge/SKILL.md +10 -0
- package/skills/browser-bridge/references/capture-context.md +9 -0
- package/skills/browser-bridge/references/emulation.md +18 -0
- package/skills/browser-bridge/references/files.md +50 -0
- package/skills/browser-bridge/references/playbooks.md +48 -0
- package/skills/browser-bridge/references/recording.md +54 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { BridgeError, ERROR_CODES } from '../../protocol/src/index.js';
|
|
6
|
+
|
|
7
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
8
|
+
const TOTAL_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
const TTL = 24 * 60 * 60 * 1000;
|
|
10
|
+
const validId = /^journal_[a-f0-9]{64}$/u;
|
|
11
|
+
|
|
12
|
+
/** Durable append-only journals. The random ID is a read/delete capability, never a path.
|
|
13
|
+
* Writes require the original extension connection as well as the capability.
|
|
14
|
+
* Existing files survive process restarts; the last partial line is reported by replay readers. */
|
|
15
|
+
export class RecordingStore {
|
|
16
|
+
/** @param {string} directory */
|
|
17
|
+
constructor(directory) {
|
|
18
|
+
this.directory = directory;
|
|
19
|
+
/** @type {Map<string, string>} */
|
|
20
|
+
this.writers = new Map();
|
|
21
|
+
}
|
|
22
|
+
/** @param {string} id */
|
|
23
|
+
filename(id) {
|
|
24
|
+
if (!validId.test(id))
|
|
25
|
+
throw new BridgeError(ERROR_CODES.ARTIFACT_NOT_FOUND, 'Unknown recording journal.');
|
|
26
|
+
return path.join(this.directory, `${id}.jsonl`);
|
|
27
|
+
}
|
|
28
|
+
/** @returns {number} */
|
|
29
|
+
prune() {
|
|
30
|
+
fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const name of fs.readdirSync(this.directory)) {
|
|
33
|
+
const id = name.replace(/\.jsonl$/u, '');
|
|
34
|
+
if (!validId.test(id)) continue;
|
|
35
|
+
const filename = this.filename(id);
|
|
36
|
+
const stat = fs.lstatSync(filename);
|
|
37
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
38
|
+
if (Date.now() - stat.mtimeMs > TTL) {
|
|
39
|
+
fs.unlinkSync(filename);
|
|
40
|
+
this.writers.delete(id);
|
|
41
|
+
} else bytes += stat.size;
|
|
42
|
+
}
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
/** @param {string} extensionId */
|
|
46
|
+
begin(extensionId) {
|
|
47
|
+
const used = this.prune();
|
|
48
|
+
if (used >= TOTAL_BYTES || fs.readdirSync(this.directory).length >= 32)
|
|
49
|
+
throw new BridgeError(
|
|
50
|
+
ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED,
|
|
51
|
+
'Recording journal quota exceeded. Delete old journals.'
|
|
52
|
+
);
|
|
53
|
+
const journalId = `journal_${randomBytes(32).toString('hex')}`;
|
|
54
|
+
const fd = fs.openSync(this.filename(journalId), 'wx', 0o600);
|
|
55
|
+
fs.fsyncSync(fd);
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
this.writers.set(journalId, extensionId);
|
|
58
|
+
return { journalId, nextOffset: 0, expiresAt: new Date(Date.now() + TTL).toISOString() };
|
|
59
|
+
}
|
|
60
|
+
/** @param {string} id @param {string} extensionId @param {number} offset @param {string} data */
|
|
61
|
+
append(id, extensionId, offset, data) {
|
|
62
|
+
if (this.writers.get(id) !== extensionId)
|
|
63
|
+
throw new BridgeError(ERROR_CODES.ACCESS_DENIED, 'Journal writer connection does not match.');
|
|
64
|
+
if (
|
|
65
|
+
!Number.isSafeInteger(offset) ||
|
|
66
|
+
offset < 0 ||
|
|
67
|
+
data.length > 262144 ||
|
|
68
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)
|
|
69
|
+
)
|
|
70
|
+
throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Invalid journal chunk.');
|
|
71
|
+
const bytes = Buffer.from(data, 'base64');
|
|
72
|
+
if (this.prune() + bytes.length > TOTAL_BYTES || offset + bytes.length > MAX_BYTES)
|
|
73
|
+
throw new BridgeError(
|
|
74
|
+
ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED,
|
|
75
|
+
'Recording journal byte quota exceeded.'
|
|
76
|
+
);
|
|
77
|
+
const fd = fs.openSync(
|
|
78
|
+
this.filename(id),
|
|
79
|
+
fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
|
80
|
+
);
|
|
81
|
+
try {
|
|
82
|
+
if (fs.fstatSync(fd).size !== offset)
|
|
83
|
+
throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Journal offset mismatch.');
|
|
84
|
+
let written = 0;
|
|
85
|
+
while (written < bytes.length) {
|
|
86
|
+
const count = fs.writeSync(fd, bytes, written, bytes.length - written, offset + written);
|
|
87
|
+
if (!count) throw new Error('Journal write made no progress.');
|
|
88
|
+
written += count;
|
|
89
|
+
}
|
|
90
|
+
fs.fsyncSync(fd);
|
|
91
|
+
return { journalId: id, nextOffset: offset + written };
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** @param {string} id @param {number} offset @param {number} limit */
|
|
97
|
+
read(id, offset, limit) {
|
|
98
|
+
this.prune();
|
|
99
|
+
const filename = this.filename(id);
|
|
100
|
+
const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
101
|
+
try {
|
|
102
|
+
const size = fs.fstatSync(fd).size;
|
|
103
|
+
if (
|
|
104
|
+
!Number.isSafeInteger(offset) ||
|
|
105
|
+
offset < 0 ||
|
|
106
|
+
offset > size ||
|
|
107
|
+
!Number.isInteger(limit) ||
|
|
108
|
+
limit < 1 ||
|
|
109
|
+
limit > 196608
|
|
110
|
+
)
|
|
111
|
+
throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Invalid journal read range.');
|
|
112
|
+
const data = Buffer.alloc(Math.min(limit, size - offset));
|
|
113
|
+
const count = fs.readSync(fd, data, 0, data.length, offset);
|
|
114
|
+
return {
|
|
115
|
+
journalId: id,
|
|
116
|
+
offset,
|
|
117
|
+
byteLength: count,
|
|
118
|
+
totalBytes: size,
|
|
119
|
+
nextOffset: offset + count < size ? offset + count : null,
|
|
120
|
+
data: data.subarray(0, count).toString('base64'),
|
|
121
|
+
};
|
|
122
|
+
} finally {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** @param {string} id */
|
|
127
|
+
delete(id) {
|
|
128
|
+
fs.unlinkSync(this.filename(id));
|
|
129
|
+
this.writers.delete(id);
|
|
130
|
+
return { deleted: true };
|
|
131
|
+
}
|
|
132
|
+
/** @param {string} extensionId */
|
|
133
|
+
disconnect(extensionId) {
|
|
134
|
+
for (const [id, owner] of this.writers) if (owner === extensionId) this.writers.delete(id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { booleanParam, invalidWorkflow, onlyKeys, stringParam } from './emulation.js';
|
|
3
|
+
|
|
4
|
+
/** @param {string} method @param {Record<string, unknown>} params */
|
|
5
|
+
export function normalizeDownload(method, params) {
|
|
6
|
+
if (method !== 'download.start') {
|
|
7
|
+
onlyKeys(params, ['downloadId']);
|
|
8
|
+
return { downloadId: stringParam(params.downloadId, 'downloadId', 100) };
|
|
9
|
+
}
|
|
10
|
+
onlyKeys(params, ['url', 'filename', 'saveAs']);
|
|
11
|
+
const url = stringParam(params.url, 'url', 8192);
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = new URL(url);
|
|
15
|
+
} catch {
|
|
16
|
+
return invalidWorkflow('Invalid download URL.');
|
|
17
|
+
}
|
|
18
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
19
|
+
invalidWorkflow('Downloads require HTTP(S) without URL credentials.');
|
|
20
|
+
const filename =
|
|
21
|
+
params.filename === undefined ? undefined : stringParam(params.filename, 'filename', 240);
|
|
22
|
+
if (
|
|
23
|
+
filename &&
|
|
24
|
+
(/[\\/:]/u.test(filename) ||
|
|
25
|
+
[...filename].some((character) => character.charCodeAt(0) < 32) ||
|
|
26
|
+
filename === '.' ||
|
|
27
|
+
filename === '..')
|
|
28
|
+
)
|
|
29
|
+
invalidWorkflow('filename must be a single filename, not a path.');
|
|
30
|
+
return {
|
|
31
|
+
url,
|
|
32
|
+
...(filename ? { filename } : {}),
|
|
33
|
+
saveAs: booleanParam(params.saveAs ?? false, 'saveAs'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|