@browserbridge/bbx 1.12.3 → 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.
- 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.js +130 -1
- 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/guidance.js +6 -2
- 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 +24 -4
- package/packages/native-host/src/artifact-store.js +42 -4
- 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/defaults.js +3 -0
- package/packages/protocol/src/downloads.js +35 -0
- package/packages/protocol/src/emulation.js +155 -0
- package/packages/protocol/src/errors.js +6 -0
- package/packages/protocol/src/files.js +94 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/protocol.js +60 -2
- package/packages/protocol/src/recording.js +70 -0
- package/packages/protocol/src/registry.js +242 -14
- package/packages/protocol/src/replay.js +144 -0
- package/packages/protocol/src/summary.js +54 -1
- package/packages/protocol/src/types.ts +52 -2
- package/packages/protocol/src/workflows.ts +22 -0
- package/skills/browser-bridge/SKILL.md +38 -6
- 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/interaction.md +38 -2
- package/skills/browser-bridge/references/playbooks.md +48 -0
- package/skills/browser-bridge/references/protocol.md +3 -1
- 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
|
+
}
|
|
@@ -58,6 +58,9 @@ export const MAX_TOUCH_POINTS = 10;
|
|
|
58
58
|
/** Maximum steps accepted by one input.perform sequence. */
|
|
59
59
|
export const MAX_PERFORM_STEPS = 200;
|
|
60
60
|
|
|
61
|
+
/** Maximum UTF-8 JSON bytes retained from the final input.perform step. */
|
|
62
|
+
export const MAX_PERFORM_RESULT_BYTES = 16_384;
|
|
63
|
+
|
|
61
64
|
/** Default and maximum total budget for one input.perform sequence (ms). */
|
|
62
65
|
export const DEFAULT_PERFORM_TIMEOUT_MS = 30_000;
|
|
63
66
|
export const MAX_PERFORM_TIMEOUT_MS = 120_000;
|
|
@@ -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
|
+
}
|
|
@@ -18,6 +18,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
18
18
|
INPUT_UNSUPPORTED: 'INPUT_UNSUPPORTED',
|
|
19
19
|
INPUT_INVALID_TARGET: 'INPUT_INVALID_TARGET',
|
|
20
20
|
INPUT_FOCUS_CHANGED: 'INPUT_FOCUS_CHANGED',
|
|
21
|
+
DIALOG_BLOCKED: 'DIALOG_BLOCKED',
|
|
21
22
|
DIALOG_NOT_OPEN: 'DIALOG_NOT_OPEN',
|
|
22
23
|
DIALOG_ACTION_CONFLICT: 'DIALOG_ACTION_CONFLICT',
|
|
23
24
|
RESULT_TRUNCATED: 'RESULT_TRUNCATED',
|
|
@@ -120,6 +121,11 @@ export const ERROR_RECOVERY = Object.freeze({
|
|
|
120
121
|
alternativeMethod: 'dom.describe',
|
|
121
122
|
hint: 'Focus moved away from the resolved editable target before native text dispatch. Inspect focus handlers or target the control that retained focus.',
|
|
122
123
|
},
|
|
124
|
+
[ERROR_CODES.DIALOG_BLOCKED]: {
|
|
125
|
+
retry: false,
|
|
126
|
+
alternativeMethod: 'page.handle_dialog',
|
|
127
|
+
hint: 'A JavaScript dialog blocked the operation. Inspect it with page.handle_dialog, then explicitly accept or dismiss it. The operation may already have taken effect and may finish after the dialog closes. Inspect postconditions; never automatically replay the action or sequence.',
|
|
128
|
+
},
|
|
123
129
|
[ERROR_CODES.DIALOG_NOT_OPEN]: {
|
|
124
130
|
retry: false,
|
|
125
131
|
alternativeMethod: 'page.handle_dialog',
|
|
@@ -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;
|
|
@@ -1088,7 +1132,7 @@ export function normalizeTouchParams(params = {}) {
|
|
|
1088
1132
|
};
|
|
1089
1133
|
}
|
|
1090
1134
|
|
|
1091
|
-
/**
|
|
1135
|
+
/** Input, wait, and bounded inspection methods allowed in input.perform steps. */
|
|
1092
1136
|
const PERFORM_STEP_METHODS = new Set([
|
|
1093
1137
|
'input.click',
|
|
1094
1138
|
'input.focus',
|
|
@@ -1104,6 +1148,10 @@ const PERFORM_STEP_METHODS = new Set([
|
|
|
1104
1148
|
'viewport.scroll',
|
|
1105
1149
|
'dom.wait_for',
|
|
1106
1150
|
'page.wait_for_load_state',
|
|
1151
|
+
'page.get_state',
|
|
1152
|
+
'page.get_text',
|
|
1153
|
+
'dom.query',
|
|
1154
|
+
'dom.get_text',
|
|
1107
1155
|
]);
|
|
1108
1156
|
|
|
1109
1157
|
/** Step methods that accept executionMode=cdp. */
|
|
@@ -1201,7 +1249,12 @@ export function normalizeInputPerformParams(params = {}) {
|
|
|
1201
1249
|
atMs,
|
|
1202
1250
|
};
|
|
1203
1251
|
});
|
|
1204
|
-
return {
|
|
1252
|
+
return {
|
|
1253
|
+
steps,
|
|
1254
|
+
timeoutMs,
|
|
1255
|
+
continueOnError: params.continueOnError === true,
|
|
1256
|
+
returnLastResult: params.returnLastResult === true,
|
|
1257
|
+
};
|
|
1205
1258
|
}
|
|
1206
1259
|
|
|
1207
1260
|
/**
|
|
@@ -2192,6 +2245,8 @@ export function createRuntimeContext() {
|
|
|
2192
2245
|
INPUT_UNSUPPORTED: 'Execution mode does not support this input operation',
|
|
2193
2246
|
INPUT_INVALID_TARGET: 'Input target is incompatible with the requested operation',
|
|
2194
2247
|
INPUT_FOCUS_CHANGED: 'Focus moved before native text dispatch',
|
|
2248
|
+
DIALOG_BLOCKED:
|
|
2249
|
+
'Native JavaScript dialog blocked the operation; inspect before responding, never automatically replay',
|
|
2195
2250
|
DIALOG_NOT_OPEN: 'No JavaScript dialog is currently observable',
|
|
2196
2251
|
INVALID_REQUEST: 'Malformed method or params',
|
|
2197
2252
|
TIMEOUT: 'Operation exceeded time limit',
|
|
@@ -2207,6 +2262,9 @@ export function createRuntimeContext() {
|
|
|
2207
2262
|
'patch.apply_styles before patch.apply_dom',
|
|
2208
2263
|
'Verify with get_box_model not screenshots',
|
|
2209
2264
|
'batch independent reads',
|
|
2265
|
+
'Use input.perform for known authorized multi-step flows; returnLastResult: true returns the final action or read in the same call. Stop for decisions that require new evidence; never blindly replay a partially completed flow',
|
|
2266
|
+
'Input targets accept role/name or label locators and auto-wait for actionability; skip separate find and pre-action wait calls when the target is known',
|
|
2267
|
+
'Read action effects before requesting more page data; sequence steps observe only with explicit observe: true',
|
|
2210
2268
|
'Rollback all patches before finishing',
|
|
2211
2269
|
'page.evaluate to read framework state (React, Vue, Next.js data)',
|
|
2212
2270
|
'dom.find_by_text / dom.find_by_role for semantic element finding',
|
|
@@ -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
|
+
}
|